From 451b3870c5cb454399385c15568c41fad92a1e51 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 10:53:41 +0000 Subject: [PATCH 01/35] Add peak shaving feature implementation plan Document the design for price-based peak shaving using a new "next" logic module that limits PV-to-battery charging (mode 8) during low-price hours to maximize grid feed-in and reserve battery capacity for peak solar hours. https://claude.ai/code/session_013TjsRDZGUJM5YT15HaS6YK --- PLAN.md | 352 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..554c92d3 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,352 @@ +# Peak Shaving Feature — Implementation Plan + +## Overview + +Add peak shaving to batcontrol: manage PV battery charging rate so the battery fills up gradually, reaching full capacity by a target hour (`allow_full_battery_after`). This prevents the battery from being full too early (losing midday PV to grid overflow) and maximizes PV self-consumption. Peak shaving is automatically disabled when EVCC reports active EV charging. + +Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging. + +--- + +## 1. Configuration — Top-Level `peak_shaving` Section + +```yaml +peak_shaving: + enabled: false + allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour +``` + +**`allow_full_battery_after`** — Target hour for the battery to be full: +- **Before this hour:** PV charge rate is limited to spread charging evenly. The battery fills gradually instead of reaching 100% early and overflowing PV to grid. +- **At/after this hour:** No PV charge limit. Battery is allowed to be 100% full. PV overflow to grid is acceptable (e.g., EV arrives home and the charger absorbs excess). +- **During EV charging (EVCC `charging=true`):** Peak shaving disabled entirely. All energy flows to the car. + +--- + +## 2. EVCC Integration — Derive Root Topic & Subscribe to Power + +### 2.1 Topic Derivation + +Users configure loadpoint topics like: +```yaml +loadpoint_topic: + - evcc/loadpoints/1/charging +``` + +Derive the root by stripping `/charging`: +- `evcc/loadpoints/1/charging` → root = `evcc/loadpoints/1` + +Subscribe to: `{root}/chargePower` — current charging power in W + +### 2.2 Changes to `evcc_api.py` + +**New state:** +```python +self.evcc_loadpoint_power = {} # root_topic → charge power (W) +self.list_topics_charge_power = [] # derived chargePower topics +``` + +**In `__init__`:** For each loadpoint topic ending in `/charging`: +```python +root = topic[:-len('/charging')] +power_topic = root + '/chargePower' +self.list_topics_charge_power.append(power_topic) +self.evcc_loadpoint_power[root] = 0.0 +self.client.message_callback_add(power_topic, self._handle_message) +``` + +Topics not ending in `/charging`: log warning, skip power subscription. + +**In `on_connect`:** Subscribe to chargePower topics. + +**In `_handle_message`:** Route to `handle_charge_power_message`. + +**New handler:** +```python +def handle_charge_power_message(self, message): + try: + power = float(message.payload) + root = message.topic[:-len('/chargePower')] + self.evcc_loadpoint_power[root] = power + except (ValueError, TypeError): + logger.error('Could not parse chargePower: %s', message.payload) +``` + +**New public method:** +```python +def get_total_charge_power(self) -> float: + return sum(self.evcc_loadpoint_power.values()) +``` + +**`shutdown`:** Unsubscribe from chargePower topics. + +### 2.3 Backward Compatibility + +- Non-`/charging` topics: warning logged, no power sub, existing behavior unchanged +- `get_total_charge_power()` returns 0.0 when no data received + +--- + +## 3. Logic Changes — Peak Shaving via PV Charge Rate Limiting + +### 3.1 Core Algorithm + +The simulation spreads battery charging over time so the battery reaches full at the target hour: + +``` +slots_remaining = slots from now until allow_full_battery_after +free_capacity = battery free capacity in Wh +expected_pv = sum of production forecast for those slots (Wh) +``` + +If expected PV production exceeds free capacity, PV would fill the battery too early. We calculate the **maximum PV charge rate** that fills the battery evenly: + +``` +ideal_charge_rate_wh = free_capacity / slots_remaining # Wh per slot +ideal_charge_rate_w = ideal_charge_rate_wh * (60 / interval_minutes) # Convert to W +``` + +Set `limit_battery_charge_rate = ideal_charge_rate_w` → MODE 8. + +If expected PV is less than free capacity, no limit needed (battery won't fill early). + +### 3.2 Sequential Simulation + +Following the default logic's pattern of iterating through future slots: + +```python +def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): + """Calculate PV charge rate limit to fill battery by target hour. + + Returns: int — charge rate limit in W, or -1 if no limit needed + """ + slot_start = calc_timestamp.replace( + minute=(calc_timestamp.minute // self.interval_minutes) * self.interval_minutes, + second=0, microsecond=0 + ) + target_time = calc_timestamp.replace( + hour=self.peak_shaving_allow_full_after, + minute=0, second=0, microsecond=0 + ) + + if target_time <= slot_start: + return -1 # Past target hour, no limit + + slots_remaining = int( + (target_time - slot_start).total_seconds() / (self.interval_minutes * 60) + ) + slots_remaining = min(slots_remaining, len(calc_input.production)) + + if slots_remaining <= 0: + return -1 + + # Sum expected PV production (Wh) over remaining slots + interval_hours = self.interval_minutes / 60.0 + expected_pv_wh = float(np.sum( + calc_input.production[:slots_remaining] + )) * interval_hours + + free_capacity = calc_input.free_capacity + + if free_capacity <= 0: + return 0 # Battery is full, block PV charging + + if expected_pv_wh <= free_capacity: + return -1 # PV won't fill battery early, no limit needed + + # Spread charging evenly across remaining slots + wh_per_slot = free_capacity / slots_remaining + charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + + return int(charge_rate_w) +``` + +### 3.3 EVCC Charging Disables Peak Shaving + +When EVCC reports `charging=true`, peak shaving is disabled. All energy goes to EV. + +### 3.4 Implementation in `default.py` + +**Post-processing step** in `calculate_inverter_mode()`, after existing logic: + +```python +if self.peak_shaving_enabled and not calc_input.evcc_is_charging: + inverter_control_settings = self._apply_peak_shaving( + inverter_control_settings, calc_input, calc_timestamp) +``` + +**`_apply_peak_shaving()`:** + +```python +def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): + """Limit PV charge rate to fill battery by target hour.""" + current_hour = calc_timestamp.hour + + # After target hour: no limit, battery may be full + if current_hour >= self.peak_shaving_allow_full_after: + return settings + + charge_limit = self._calculate_peak_shaving_charge_limit( + calc_input, calc_timestamp) + + if charge_limit >= 0: + # Apply PV charge rate limit + # If existing logic already set a tighter limit, keep the tighter one + if settings.limit_battery_charge_rate < 0: + # No existing limit — apply peak shaving limit + settings.limit_battery_charge_rate = charge_limit + else: + # Keep the more restrictive limit + settings.limit_battery_charge_rate = min( + settings.limit_battery_charge_rate, charge_limit) + + logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', + settings.limit_battery_charge_rate, + self.peak_shaving_allow_full_after) + + return settings +``` + +### 3.5 Data Flow + +**New fields on `CalculationInput`:** +```python +@dataclass +class CalculationInput: + # ... existing fields ... + ev_charge_power: float = 0.0 # W — real-time total EV charge power + evcc_is_charging: bool = False # Whether any loadpoint is charging +``` + +In `core.py.run()`: +```python +ev_charge_power = 0.0 +evcc_is_charging = False +if self.evcc_api is not None: + ev_charge_power = self.evcc_api.get_total_charge_power() + evcc_is_charging = self.evcc_api.evcc_is_charging +``` + +### 3.6 Config in Logic + +In `logic.py` factory: +```python +peak_shaving_config = config.get('peak_shaving', {}) +logic.set_peak_shaving_config(peak_shaving_config) +``` + +In `default.py`: +```python +def set_peak_shaving_config(self, config: dict): + self.peak_shaving_enabled = config.get('enabled', False) + self.peak_shaving_allow_full_after = config.get('allow_full_battery_after', 14) +``` + +--- + +## 4. Core Integration — `core.py` + +### 4.1 Init + +No new instance vars. Config in `self.config` is passed to logic factory. + +### 4.2 Run Loop + +Before `CalculationInput`: +```python +ev_charge_power = 0.0 +evcc_is_charging = False +if self.evcc_api is not None: + ev_charge_power = self.evcc_api.get_total_charge_power() + evcc_is_charging = self.evcc_api.evcc_is_charging +``` + +Add to `CalculationInput` constructor. + +### 4.3 Mode Selection + +In the mode selection block (after logic.calculate), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed — peak shaving sets the limit in InverterControlSettings and the existing dispatch handles it. + +--- + +## 5. MQTT API — Publish Peak Shaving State + +Publish topics: +- `/peak_shaving/enabled` — boolean +- `/peak_shaving/allow_full_battery_after` — hour (0-23) +- `/peak_shaving/ev_charge_power` — W (real-time) + +Settable topics: +- `peak_shaving/enabled/set` +- `peak_shaving/allow_full_battery_after/set` + +Home Assistant discovery for all. + +--- + +## 6. Tests + +### 6.1 Logic Tests (`tests/batcontrol/logic/test_peak_shaving.py`) + +**Simulation tests:** +- Battery nearly full, lots of PV expected → low charge limit +- Battery mostly empty, moderate PV → no limit (PV won't fill battery) +- Battery already full → charge limit = 0 +- Past target hour → no limit (-1) +- 1 slot remaining → calculated rate for that slot + +**Decision tests:** +- Peak shaving disabled → no change +- EVCC charging=true → peak shaving disabled +- Before target hour, limit calculated → MODE 8 with limit +- After target hour → no change to existing settings +- Existing tighter limit from logic → kept (more restrictive wins) + +### 6.2 EVCC Tests (`tests/batcontrol/test_evcc_power.py`) + +- Topic derivation: `evcc/loadpoints/1/charging` → root `evcc/loadpoints/1` +- Non-standard topic → warning, no power sub +- `get_total_charge_power()` multi-loadpoint sum +- `get_total_charge_power()` returns 0.0 initially +- chargePower parsing (valid/invalid) + +### 6.3 Config Tests + +- With `peak_shaving` → loads correctly +- Without `peak_shaving` → disabled by default + +--- + +## 7. Implementation Order + +1. **Config** — Add `peak_shaving` to dummy config +2. **EVCC** — Topic derivation, chargePower sub, `get_total_charge_power()` +3. **Data model** — Add fields to `CalculationInput` +4. **Logic** — `set_peak_shaving_config()`, `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` +5. **Logic factory** — Pass peak_shaving config +6. **Core** — Wire EV data into CalculationInput +7. **MQTT** — Publish topics + discovery +8. **Tests** + +--- + +## 8. Files Modified + +| File | Change | +|------|--------| +| `config/batcontrol_config_dummy.yaml` | Add `peak_shaving` section | +| `src/batcontrol/evcc_api.py` | Topic derivation, chargePower sub, `get_total_charge_power()` | +| `src/batcontrol/logic/logic_interface.py` | Add `ev_charge_power`, `evcc_is_charging` to `CalculationInput` | +| `src/batcontrol/logic/default.py` | Peak shaving simulation + PV charge rate limiting | +| `src/batcontrol/logic/logic.py` | Pass peak_shaving config | +| `src/batcontrol/core.py` | Wire EV data into CalculationInput | +| `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | +| `tests/batcontrol/logic/test_peak_shaving.py` | New | +| `tests/batcontrol/test_evcc_power.py` | New | + +--- + +## 9. Open Questions + +1. **EVCC chargePower topic** — Is `{loadpoint_root}/chargePower` the correct EVCC MQTT topic name? +2. **Interaction with grid charging** — If price logic wants to grid-charge (MODE -1) but peak shaving wants to limit PV charge (MODE 8), which wins? Current plan: peak shaving only affects PV charge rate, doesn't block grid charging decisions. From db8853f0af0478c8493415d32b7f2e9e0605fcd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 11:15:42 +0000 Subject: [PATCH 02/35] Update peak shaving plan based on review feedback - Rename EVCC section: use existing charging state only, drop chargePower subscription - Algorithm uses net PV surplus (production - consumption) instead of raw production - Move peak shaving config to CalculationParameters (consistent with existing interface) - Add always_allow_discharge region bypass for high SOC - Add force_charge (MODE -1) priority over peak shaving with warning log - Remove unused ev_charge_power field from data model - Add known limitations section (flat charge distribution, no intra-day adjustment) - Reduce modified files from 9 to 6 https://claude.ai/code/session_01T62gAfD8CBB9uSU4w3uq1y --- PLAN.md | 330 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 173 insertions(+), 157 deletions(-) diff --git a/PLAN.md b/PLAN.md index 554c92d3..2ec3b2b4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2,7 +2,11 @@ ## Overview -Add peak shaving to batcontrol: manage PV battery charging rate so the battery fills up gradually, reaching full capacity by a target hour (`allow_full_battery_after`). This prevents the battery from being full too early (losing midday PV to grid overflow) and maximizes PV self-consumption. Peak shaving is automatically disabled when EVCC reports active EV charging. +Add peak shaving to batcontrol: manage PV battery charging rate so the battery fills up gradually, reaching full capacity by a target hour (`allow_full_battery_after`). This prevents the battery from being full too early in the day. + +**Problem:** All PV systems in the country produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest and — for newer installations — feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible into the battery, rather than feeding it into the grid during peak hours. + +**EVCC interaction:** When an EV is actively charging (`charging=true`), peak shaving is disabled — the EV consumes the excess PV. When an EV is connected in EVCC "pv" mode (waiting for surplus), EVCC+EV will naturally absorb excessive PV energy once the surplus threshold is reached. Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging. @@ -23,67 +27,25 @@ peak_shaving: --- -## 2. EVCC Integration — Derive Root Topic & Subscribe to Power - -### 2.1 Topic Derivation - -Users configure loadpoint topics like: -```yaml -loadpoint_topic: - - evcc/loadpoints/1/charging -``` - -Derive the root by stripping `/charging`: -- `evcc/loadpoints/1/charging` → root = `evcc/loadpoints/1` - -Subscribe to: `{root}/chargePower` — current charging power in W - -### 2.2 Changes to `evcc_api.py` - -**New state:** -```python -self.evcc_loadpoint_power = {} # root_topic → charge power (W) -self.list_topics_charge_power = [] # derived chargePower topics -``` - -**In `__init__`:** For each loadpoint topic ending in `/charging`: -```python -root = topic[:-len('/charging')] -power_topic = root + '/chargePower' -self.list_topics_charge_power.append(power_topic) -self.evcc_loadpoint_power[root] = 0.0 -self.client.message_callback_add(power_topic, self._handle_message) -``` - -Topics not ending in `/charging`: log warning, skip power subscription. +## 2. EVCC Integration — Use Existing Charging State -**In `on_connect`:** Subscribe to chargePower topics. +### 2.1 Approach -**In `_handle_message`:** Route to `handle_charge_power_message`. +Peak shaving uses the **existing** `evcc_is_charging` boolean from `evcc_api.py` to decide whether to apply PV charge limiting. No new EVCC subscriptions or topics are needed. -**New handler:** -```python -def handle_charge_power_message(self, message): - try: - power = float(message.payload) - root = message.topic[:-len('/chargePower')] - self.evcc_loadpoint_power[root] = power - except (ValueError, TypeError): - logger.error('Could not parse chargePower: %s', message.payload) -``` +- `evcc_is_charging = True` → peak shaving disabled (EV is consuming energy) +- `evcc_is_charging = False` → peak shaving may apply -**New public method:** -```python -def get_total_charge_power(self) -> float: - return sum(self.evcc_loadpoint_power.values()) -``` +**Note:** We intentionally do **not** subscribe to or rely on `chargePower`. The existing `charging` topic is sufficient for the peak shaving decision. -**`shutdown`:** Unsubscribe from chargePower topics. +### 2.2 No Changes to `evcc_api.py` -### 2.3 Backward Compatibility +The existing EVCC integration already provides: +- `self.evcc_is_charging` — whether any loadpoint is actively charging +- Discharge blocking during EV charging +- Battery halt SOC management -- Non-`/charging` topics: warning logged, no power sub, existing behavior unchanged -- `get_total_charge_power()` returns 0.0 when no data received +Peak shaving only needs the `evcc_is_charging` boolean, which is already available in `core.py` via `self.evcc_api.evcc_is_charging`. --- @@ -91,15 +53,15 @@ def get_total_charge_power(self) -> float: ### 3.1 Core Algorithm -The simulation spreads battery charging over time so the battery reaches full at the target hour: +The algorithm spreads battery charging over time so the battery reaches full at the target hour: ``` slots_remaining = slots from now until allow_full_battery_after free_capacity = battery free capacity in Wh -expected_pv = sum of production forecast for those slots (Wh) +expected_pv_surplus = sum of (production - consumption) for those slots, only positive values (Wh) ``` -If expected PV production exceeds free capacity, PV would fill the battery too early. We calculate the **maximum PV charge rate** that fills the battery evenly: +If expected **PV surplus** (production minus consumption) exceeds free capacity, PV would fill the battery too early. We calculate the **maximum PV charge rate** that fills the battery evenly: ``` ideal_charge_rate_wh = free_capacity / slots_remaining # Wh per slot @@ -108,9 +70,11 @@ ideal_charge_rate_w = ideal_charge_rate_wh * (60 / interval_minutes) # Convert Set `limit_battery_charge_rate = ideal_charge_rate_w` → MODE 8. -If expected PV is less than free capacity, no limit needed (battery won't fill early). +If expected PV surplus is less than free capacity, no limit needed (battery won't fill early). -### 3.2 Sequential Simulation +**Note:** The charge limit is distributed evenly across slots. This is a simplification — PV production peaks midday while the limit is flat. This means the limit may have no effect in low-PV morning slots and may clip excess in high-PV midday slots. The battery may not reach exactly 100% by the target hour. This is acceptable for v1; a PV-weighted distribution could be added later. + +### 3.2 Algorithm Implementation Following the default logic's pattern of iterating through future slots: @@ -125,7 +89,7 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): second=0, microsecond=0 ) target_time = calc_timestamp.replace( - hour=self.peak_shaving_allow_full_after, + hour=self.calculation_parameters.peak_shaving_allow_full_after, minute=0, second=0, microsecond=0 ) @@ -140,20 +104,22 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): if slots_remaining <= 0: return -1 - # Sum expected PV production (Wh) over remaining slots + # Calculate PV surplus per slot (only count positive surplus — when PV > consumption) + pv_surplus = calc_input.production[:slots_remaining] - calc_input.consumption[:slots_remaining] + pv_surplus = np.clip(pv_surplus, 0, None) # Only positive surplus counts + + # Sum expected PV surplus energy (Wh) over remaining slots interval_hours = self.interval_minutes / 60.0 - expected_pv_wh = float(np.sum( - calc_input.production[:slots_remaining] - )) * interval_hours + expected_surplus_wh = float(np.sum(pv_surplus)) * interval_hours free_capacity = calc_input.free_capacity + if expected_surplus_wh <= free_capacity: + return -1 # PV surplus won't fill battery early, no limit needed + if free_capacity <= 0: return 0 # Battery is full, block PV charging - if expected_pv_wh <= free_capacity: - return -1 # PV won't fill battery early, no limit needed - # Spread charging evenly across remaining slots wh_per_slot = free_capacity / slots_remaining charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W @@ -165,12 +131,31 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): When EVCC reports `charging=true`, peak shaving is disabled. All energy goes to EV. -### 3.4 Implementation in `default.py` +### 3.4 Always-Allow-Discharge Region Skips Peak Shaving + +When `stored_energy >= max_capacity * always_allow_discharge_limit`, the battery is in the "always allow discharge" region. In this region, peak shaving is **not applied** — the system is already at high SOC and the normal discharge logic takes over. This also avoids toggling issues when SOC fluctuates near 100%. + +### 3.5 Force Charge (MODE -1) Takes Priority Over Peak Shaving + +If the price-based logic decides to force charge from grid (`charge_from_grid=True`), this overrides peak shaving. The force charge decision means energy is cheap enough to justify grid charging — peak shaving should not interfere. + +When this occurs, a warning is logged: +``` +[PeakShaving] Skipped: force_charge (MODE -1) active, grid charging takes priority +``` + +In practice, force charge should rarely trigger during peak shaving hours because: +- Peak shaving hours have high PV production +- Prices are typically low during peak PV (no incentive to grid-charge) +- There should be enough PV to fill the battery by the target hour + +### 3.6 Implementation in `default.py` -**Post-processing step** in `calculate_inverter_mode()`, after existing logic: +**Post-processing step** in `calculate_inverter_mode()`, after existing logic returns `inverter_control_settings`: ```python -if self.peak_shaving_enabled and not calc_input.evcc_is_charging: +# Apply peak shaving as post-processing step +if self.calculation_parameters.peak_shaving_enabled: inverter_control_settings = self._apply_peak_shaving( inverter_control_settings, calc_input, calc_timestamp) ``` @@ -179,11 +164,32 @@ if self.peak_shaving_enabled and not calc_input.evcc_is_charging: ```python def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): - """Limit PV charge rate to fill battery by target hour.""" - current_hour = calc_timestamp.hour + """Limit PV charge rate to spread battery charging until target hour. - # After target hour: no limit, battery may be full - if current_hour >= self.peak_shaving_allow_full_after: + Skipped when: + - Past the target hour (allow_full_battery_after) + - Battery is in always_allow_discharge region (high SOC) + - EVCC is actively charging an EV + - Force charge from grid is active (MODE -1) + """ + # After target hour: no limit + if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: + return settings + + # In always_allow_discharge region: skip peak shaving + if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): + logger.debug('[PeakShaving] Skipped: battery in always_allow_discharge region') + return settings + + # EVCC charging: skip peak shaving + if self.calculation_parameters.evcc_is_charging: + logger.debug('[PeakShaving] Skipped: EVCC is charging') + return settings + + # Force charge takes priority over peak shaving + if settings.charge_from_grid: + logger.warning('[PeakShaving] Skipped: force_charge (MODE -1) active, ' + 'grid charging takes priority') return settings charge_limit = self._calculate_peak_shaving_charge_limit( @@ -191,7 +197,6 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): if charge_limit >= 0: # Apply PV charge rate limit - # If existing logic already set a tighter limit, keep the tighter one if settings.limit_battery_charge_rate < 0: # No existing limit — apply peak shaving limit settings.limit_battery_charge_rate = charge_limit @@ -202,45 +207,52 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', settings.limit_battery_charge_rate, - self.peak_shaving_allow_full_after) + self.calculation_parameters.peak_shaving_allow_full_after) return settings ``` -### 3.5 Data Flow +### 3.7 Data Flow — Extended `CalculationParameters` + +Peak shaving configuration is passed via the existing `CalculationParameters` dataclass (consistent with existing interface pattern): -**New fields on `CalculationInput`:** ```python @dataclass -class CalculationInput: - # ... existing fields ... - ev_charge_power: float = 0.0 # W — real-time total EV charge power - evcc_is_charging: bool = False # Whether any loadpoint is charging +class CalculationParameters: + """ Calculations from Battery control configuration """ + max_charging_from_grid_limit: float + min_price_difference: float + min_price_difference_rel: float + max_capacity: float # Maximum capacity of the battery in Wh (excludes MAX_SOC) + # Peak shaving parameters + peak_shaving_enabled: bool = False + peak_shaving_allow_full_after: int = 14 # Hour (0-23) + evcc_is_charging: bool = False # Whether any EVCC loadpoint is actively charging ``` -In `core.py.run()`: +In `core.py`, the `CalculationParameters` constructor is extended: + ```python -ev_charge_power = 0.0 evcc_is_charging = False if self.evcc_api is not None: - ev_charge_power = self.evcc_api.get_total_charge_power() evcc_is_charging = self.evcc_api.evcc_is_charging -``` -### 3.6 Config in Logic - -In `logic.py` factory: -```python -peak_shaving_config = config.get('peak_shaving', {}) -logic.set_peak_shaving_config(peak_shaving_config) +peak_shaving_config = self.config.get('peak_shaving', {}) + +calc_parameters = CalculationParameters( + self.max_charging_from_grid_limit, + self.min_price_difference, + self.min_price_difference_rel, + self.get_max_capacity(), + peak_shaving_enabled=peak_shaving_config.get('enabled', False), + peak_shaving_allow_full_after=peak_shaving_config.get('allow_full_battery_after', 14), + evcc_is_charging=evcc_is_charging, +) ``` -In `default.py`: -```python -def set_peak_shaving_config(self, config: dict): - self.peak_shaving_enabled = config.get('enabled', False) - self.peak_shaving_allow_full_after = config.get('allow_full_battery_after', 14) -``` +**No changes needed to `CalculationInput`** — the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. + +**No changes needed to `logic.py` factory** — configuration flows through `CalculationParameters` via the existing `set_calculation_parameters()` method. --- @@ -248,39 +260,35 @@ def set_peak_shaving_config(self, config: dict): ### 4.1 Init -No new instance vars. Config in `self.config` is passed to logic factory. +No new instance vars needed. Peak shaving config is read from `self.config` each run cycle and passed via `CalculationParameters`. ### 4.2 Run Loop -Before `CalculationInput`: -```python -ev_charge_power = 0.0 -evcc_is_charging = False -if self.evcc_api is not None: - ev_charge_power = self.evcc_api.get_total_charge_power() - evcc_is_charging = self.evcc_api.evcc_is_charging -``` - -Add to `CalculationInput` constructor. +Extend `CalculationParameters` construction (see Section 3.7). No other changes to the run loop. ### 4.3 Mode Selection -In the mode selection block (after logic.calculate), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed — peak shaving sets the limit in InverterControlSettings and the existing dispatch handles it. +In the mode selection block (after `logic.calculate()`), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed — peak shaving sets the limit in `InverterControlSettings` and the existing dispatch handles it. --- ## 5. MQTT API — Publish Peak Shaving State Publish topics: -- `/peak_shaving/enabled` — boolean -- `/peak_shaving/allow_full_battery_after` — hour (0-23) -- `/peak_shaving/ev_charge_power` — W (real-time) +- `{base}/peak_shaving/enabled` — boolean (`true`/`false`, plain text, retained) +- `{base}/peak_shaving/allow_full_battery_after` — integer hour 0-23 (plain text, retained) +- `{base}/peak_shaving/charge_limit` — current calculated charge limit in W (plain text, not retained, -1 if inactive) Settable topics: -- `peak_shaving/enabled/set` -- `peak_shaving/allow_full_battery_after/set` +- `{base}/peak_shaving/enabled/set` — accepts `true`/`false` +- `{base}/peak_shaving/allow_full_battery_after/set` — accepts integer 0-23 + +Home Assistant discovery: +- `peak_shaving/enabled` → switch entity +- `peak_shaving/allow_full_battery_after` → number entity (min: 0, max: 23, step: 1) +- `peak_shaving/charge_limit` → sensor entity (unit: W) -Home Assistant discovery for all. +QoS: 1 for all topics (consistent with existing MQTT API). --- @@ -288,45 +296,41 @@ Home Assistant discovery for all. ### 6.1 Logic Tests (`tests/batcontrol/logic/test_peak_shaving.py`) -**Simulation tests:** -- Battery nearly full, lots of PV expected → low charge limit -- Battery mostly empty, moderate PV → no limit (PV won't fill battery) -- Battery already full → charge limit = 0 +**Algorithm tests (`_calculate_peak_shaving_charge_limit`):** +- High PV surplus, small free capacity → low charge limit +- Low PV surplus, large free capacity → no limit (-1) +- PV surplus exactly matches free capacity → no limit (-1) +- Battery full (`free_capacity = 0`) → charge limit = 0 - Past target hour → no limit (-1) -- 1 slot remaining → calculated rate for that slot - -**Decision tests:** -- Peak shaving disabled → no change -- EVCC charging=true → peak shaving disabled -- Before target hour, limit calculated → MODE 8 with limit -- After target hour → no change to existing settings -- Existing tighter limit from logic → kept (more restrictive wins) - -### 6.2 EVCC Tests (`tests/batcontrol/test_evcc_power.py`) +- 1 slot remaining → rate for that single slot +- Consumption reduces effective PV — e.g., 3kW PV, 2kW consumption = 1kW surplus -- Topic derivation: `evcc/loadpoints/1/charging` → root `evcc/loadpoints/1` -- Non-standard topic → warning, no power sub -- `get_total_charge_power()` multi-loadpoint sum -- `get_total_charge_power()` returns 0.0 initially -- chargePower parsing (valid/invalid) +**Decision tests (`_apply_peak_shaving`):** +- `peak_shaving_enabled = False` → no change to settings +- `evcc_is_charging = True` → peak shaving skipped +- `charge_from_grid = True` → peak shaving skipped, warning logged +- Battery in always_allow_discharge region → peak shaving skipped +- Before target hour, limit calculated → `limit_battery_charge_rate` set +- After target hour → no change +- Existing tighter limit from other logic → kept (more restrictive wins) +- Peak shaving limit tighter than existing → peak shaving limit applied -### 6.3 Config Tests +### 6.2 Config Tests -- With `peak_shaving` → loads correctly -- Without `peak_shaving` → disabled by default +- With `peak_shaving` section → `CalculationParameters` fields set correctly +- Without `peak_shaving` section → `peak_shaving_enabled = False` (default) +- Invalid `allow_full_battery_after` values (edge cases: 0, 23) --- ## 7. Implementation Order -1. **Config** — Add `peak_shaving` to dummy config -2. **EVCC** — Topic derivation, chargePower sub, `get_total_charge_power()` -3. **Data model** — Add fields to `CalculationInput` -4. **Logic** — `set_peak_shaving_config()`, `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` -5. **Logic factory** — Pass peak_shaving config -6. **Core** — Wire EV data into CalculationInput -7. **MQTT** — Publish topics + discovery -8. **Tests** +1. **Config** — Add `peak_shaving` section to dummy config +2. **Data model** — Extend `CalculationParameters` with peak shaving fields +3. **Logic** — `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` in `default.py` +4. **Core** — Wire EVCC state + peak shaving config into `CalculationParameters` +5. **MQTT** — Publish topics + settable topics + HA discovery +6. **Tests** --- @@ -335,18 +339,30 @@ Home Assistant discovery for all. | File | Change | |------|--------| | `config/batcontrol_config_dummy.yaml` | Add `peak_shaving` section | -| `src/batcontrol/evcc_api.py` | Topic derivation, chargePower sub, `get_total_charge_power()` | -| `src/batcontrol/logic/logic_interface.py` | Add `ev_charge_power`, `evcc_is_charging` to `CalculationInput` | -| `src/batcontrol/logic/default.py` | Peak shaving simulation + PV charge rate limiting | -| `src/batcontrol/logic/logic.py` | Pass peak_shaving config | -| `src/batcontrol/core.py` | Wire EV data into CalculationInput | +| `src/batcontrol/logic/logic_interface.py` | Add peak shaving fields + `evcc_is_charging` to `CalculationParameters` | +| `src/batcontrol/logic/default.py` | `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` | +| `src/batcontrol/core.py` | Wire EVCC state + peak shaving config into `CalculationParameters` | | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | -| `tests/batcontrol/logic/test_peak_shaving.py` | New | -| `tests/batcontrol/test_evcc_power.py` | New | +| `tests/batcontrol/logic/test_peak_shaving.py` | New — algorithm + decision tests | + +**Not modified:** `evcc_api.py` (no changes needed), `logic.py` factory (config flows through `CalculationParameters`) --- -## 9. Open Questions +## 9. Resolved Design Decisions + +1. **EVCC integration:** Use existing `evcc_is_charging` boolean only. No `chargePower` subscription — it was reported as unreliable and is not needed for the on/off peak shaving decision. + +2. **Grid charging interaction (MODE -1 vs MODE 8):** `force_charge` takes priority. If price logic triggers grid charging during peak shaving hours, a warning is logged but grid charging proceeds. This should rarely occur in practice because PV-heavy hours have low prices. + +3. **Interface consistency:** Peak shaving config is passed via `CalculationParameters` (extended with new fields), following the existing pattern. No separate `set_peak_shaving_config()` method. + +4. **High SOC handling:** When battery is in `always_allow_discharge` region, peak shaving is skipped entirely. This avoids toggling at near-full SOC and is consistent with the system's existing high-SOC behavior. + +5. **Algorithm uses net PV surplus:** The charge limit calculation uses `production - consumption` (positive only), not raw production. This prevents over-throttling when household consumption absorbs most of the PV. + +## 10. Known Limitations (v1) + +1. **Flat charge distribution:** The charge rate limit is uniform across all slots, but PV production peaks midday. The battery may not reach exactly 100% by the target hour. Acceptable for v1. -1. **EVCC chargePower topic** — Is `{loadpoint_root}/chargePower` the correct EVCC MQTT topic name? -2. **Interaction with grid charging** — If price logic wants to grid-charge (MODE -1) but peak shaving wants to limit PV charge (MODE 8), which wins? Current plan: peak shaving only affects PV charge rate, doesn't block grid charging decisions. +2. **No intra-day target adjustment:** If clouds reduce PV significantly, the limit stays as calculated until the next evaluation cycle (every 3 minutes). The system self-corrects because free capacity stays high, which increases the allowed charge rate. From d27ed4e60030753f216499446eca44c649c0cd9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 11:19:16 +0000 Subject: [PATCH 03/35] Move EVCC charging check from logic layer to core.py EVCC is an external integration concern, not part of calculation logic. The evcc_is_charging guard now lives in core.py (similar to discharge_blocked), keeping the logic layer clean and independent of EVCC. - Remove evcc_is_charging from CalculationParameters - Add EVCC charging guard in core.py after logic.calculate() - Remove EVCC check from _apply_peak_shaving() in default.py - Update tests section accordingly https://claude.ai/code/session_01T62gAfD8CBB9uSU4w3uq1y --- PLAN.md | 66 +++++++++++++++++++++++++++------------------------------ 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/PLAN.md b/PLAN.md index 2ec3b2b4..e9d09c8f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -31,21 +31,16 @@ peak_shaving: ### 2.1 Approach -Peak shaving uses the **existing** `evcc_is_charging` boolean from `evcc_api.py` to decide whether to apply PV charge limiting. No new EVCC subscriptions or topics are needed. +The EVCC check is handled in `core.py`, **not** in the logic layer. EVCC is an external integration concern independent of the calculation logic — similar to how `discharge_blocked` is handled in `core.py` today. -- `evcc_is_charging = True` → peak shaving disabled (EV is consuming energy) +- `evcc_is_charging = True` → `core.py` skips peak shaving entirely (does not pass it to logic) - `evcc_is_charging = False` → peak shaving may apply **Note:** We intentionally do **not** subscribe to or rely on `chargePower`. The existing `charging` topic is sufficient for the peak shaving decision. -### 2.2 No Changes to `evcc_api.py` +### 2.2 No Changes to `evcc_api.py` or Logic Layer -The existing EVCC integration already provides: -- `self.evcc_is_charging` — whether any loadpoint is actively charging -- Discharge blocking during EV charging -- Battery halt SOC management - -Peak shaving only needs the `evcc_is_charging` boolean, which is already available in `core.py` via `self.evcc_api.evcc_is_charging`. +The existing EVCC integration already provides `self.evcc_is_charging` in `core.py` via `self.evcc_api.evcc_is_charging`. The logic layer (`default.py`) does not need to know about EVCC — the decision to skip peak shaving during EV charging is made in `core.py` before/after calling the logic. --- @@ -127,15 +122,11 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): return int(charge_rate_w) ``` -### 3.3 EVCC Charging Disables Peak Shaving - -When EVCC reports `charging=true`, peak shaving is disabled. All energy goes to EV. - -### 3.4 Always-Allow-Discharge Region Skips Peak Shaving +### 3.3 Always-Allow-Discharge Region Skips Peak Shaving When `stored_energy >= max_capacity * always_allow_discharge_limit`, the battery is in the "always allow discharge" region. In this region, peak shaving is **not applied** — the system is already at high SOC and the normal discharge logic takes over. This also avoids toggling issues when SOC fluctuates near 100%. -### 3.5 Force Charge (MODE -1) Takes Priority Over Peak Shaving +### 3.4 Force Charge (MODE -1) Takes Priority Over Peak Shaving If the price-based logic decides to force charge from grid (`charge_from_grid=True`), this overrides peak shaving. The force charge decision means energy is cheap enough to justify grid charging — peak shaving should not interfere. @@ -149,7 +140,7 @@ In practice, force charge should rarely trigger during peak shaving hours becaus - Prices are typically low during peak PV (no incentive to grid-charge) - There should be enough PV to fill the battery by the target hour -### 3.6 Implementation in `default.py` +### 3.5 Implementation in `default.py` **Post-processing step** in `calculate_inverter_mode()`, after existing logic returns `inverter_control_settings`: @@ -169,8 +160,9 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): Skipped when: - Past the target hour (allow_full_battery_after) - Battery is in always_allow_discharge region (high SOC) - - EVCC is actively charging an EV - Force charge from grid is active (MODE -1) + + Note: EVCC charging check is handled in core.py, not here. """ # After target hour: no limit if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: @@ -181,11 +173,6 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): logger.debug('[PeakShaving] Skipped: battery in always_allow_discharge region') return settings - # EVCC charging: skip peak shaving - if self.calculation_parameters.evcc_is_charging: - logger.debug('[PeakShaving] Skipped: EVCC is charging') - return settings - # Force charge takes priority over peak shaving if settings.charge_from_grid: logger.warning('[PeakShaving] Skipped: force_charge (MODE -1) active, ' @@ -212,7 +199,7 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): return settings ``` -### 3.7 Data Flow — Extended `CalculationParameters` +### 3.6 Data Flow — Extended `CalculationParameters` Peak shaving configuration is passed via the existing `CalculationParameters` dataclass (consistent with existing interface pattern): @@ -227,16 +214,11 @@ class CalculationParameters: # Peak shaving parameters peak_shaving_enabled: bool = False peak_shaving_allow_full_after: int = 14 # Hour (0-23) - evcc_is_charging: bool = False # Whether any EVCC loadpoint is actively charging ``` In `core.py`, the `CalculationParameters` constructor is extended: ```python -evcc_is_charging = False -if self.evcc_api is not None: - evcc_is_charging = self.evcc_api.evcc_is_charging - peak_shaving_config = self.config.get('peak_shaving', {}) calc_parameters = CalculationParameters( @@ -246,7 +228,6 @@ calc_parameters = CalculationParameters( self.get_max_capacity(), peak_shaving_enabled=peak_shaving_config.get('enabled', False), peak_shaving_allow_full_after=peak_shaving_config.get('allow_full_battery_after', 14), - evcc_is_charging=evcc_is_charging, ) ``` @@ -264,9 +245,25 @@ No new instance vars needed. Peak shaving config is read from `self.config` each ### 4.2 Run Loop -Extend `CalculationParameters` construction (see Section 3.7). No other changes to the run loop. +Extend `CalculationParameters` construction (see Section 3.6). + +### 4.3 EVCC Charging Check + +The EVCC charging check is handled in `core.py`, keeping EVCC concerns out of the logic layer. This follows the same pattern as `discharge_blocked` (line 536-541 in current code). + +After `logic.calculate()` returns and before mode dispatch, if EVCC is charging and peak shaving produced a charge limit, the limit is cleared: + +```python +# EVCC charging disables peak shaving (handled in core, not logic) +evcc_is_charging = (self.evcc_api is not None and self.evcc_api.evcc_is_charging) +if evcc_is_charging and inverter_settings.limit_battery_charge_rate >= 0: + logger.debug('[PeakShaving] Skipped: EVCC is charging') + inverter_settings.limit_battery_charge_rate = -1 +``` + +**Note:** This is a simple approach. If `limit_battery_charge_rate` was set by non-peak-shaving logic, this would also clear it. However, in the current codebase, MODE 8 with `allow_discharge=True` is only set by peak shaving (the existing logic uses MODE 8 only in combination with `allow_discharge=False` for the "avoid discharge" path). If this changes in the future, a more targeted approach (e.g., a flag on the settings) would be needed. -### 4.3 Mode Selection +### 4.4 Mode Selection In the mode selection block (after `logic.calculate()`), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed — peak shaving sets the limit in `InverterControlSettings` and the existing dispatch handles it. @@ -307,7 +304,6 @@ QoS: 1 for all topics (consistent with existing MQTT API). **Decision tests (`_apply_peak_shaving`):** - `peak_shaving_enabled = False` → no change to settings -- `evcc_is_charging = True` → peak shaving skipped - `charge_from_grid = True` → peak shaving skipped, warning logged - Battery in always_allow_discharge region → peak shaving skipped - Before target hour, limit calculated → `limit_battery_charge_rate` set @@ -339,9 +335,9 @@ QoS: 1 for all topics (consistent with existing MQTT API). | File | Change | |------|--------| | `config/batcontrol_config_dummy.yaml` | Add `peak_shaving` section | -| `src/batcontrol/logic/logic_interface.py` | Add peak shaving fields + `evcc_is_charging` to `CalculationParameters` | +| `src/batcontrol/logic/logic_interface.py` | Add peak shaving fields to `CalculationParameters` | | `src/batcontrol/logic/default.py` | `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` | -| `src/batcontrol/core.py` | Wire EVCC state + peak shaving config into `CalculationParameters` | +| `src/batcontrol/core.py` | Wire peak shaving config into `CalculationParameters`, EVCC charging guard | | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | | `tests/batcontrol/logic/test_peak_shaving.py` | New — algorithm + decision tests | @@ -351,7 +347,7 @@ QoS: 1 for all topics (consistent with existing MQTT API). ## 9. Resolved Design Decisions -1. **EVCC integration:** Use existing `evcc_is_charging` boolean only. No `chargePower` subscription — it was reported as unreliable and is not needed for the on/off peak shaving decision. +1. **EVCC integration:** Use existing `evcc_is_charging` boolean only, checked in `core.py` (not in logic layer). No `chargePower` subscription — it was reported as unreliable and is not needed for the on/off peak shaving decision. EVCC is an external integration concern and stays in `core.py`, following the same pattern as `discharge_blocked`. 2. **Grid charging interaction (MODE -1 vs MODE 8):** `force_charge` takes priority. If price logic triggers grid charging during peak shaving hours, a warning is logged but grid charging proceeds. This should rarely occur in practice because PV-heavy hours have low prices. From c65bc1af751fbc4fe8d02d564a7900146fe91fdb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 11:28:12 +0000 Subject: [PATCH 04/35] Restructure plan: independent NextLogic class + EVCC mode/connected topics Major changes: - Peak shaving lives in new NextLogic class (type: next), DefaultLogic untouched - EVCC: subscribe to loadpoint mode + connected topics (derived from loadpoint_topic) - Disable peak shaving when EV connected + mode=pv (immediately, not just on charging) - evcc_ev_expects_pv_surplus property for the connected+pv check - Updated file list: new next.py, evcc_api.py now modified, default.py not modified https://claude.ai/code/session_01T62gAfD8CBB9uSU4w3uq1y --- PLAN.md | 292 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 222 insertions(+), 70 deletions(-) diff --git a/PLAN.md b/PLAN.md index e9d09c8f..34df7620 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,13 +6,20 @@ Add peak shaving to batcontrol: manage PV battery charging rate so the battery f **Problem:** All PV systems in the country produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest and — for newer installations — feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible into the battery, rather than feeding it into the grid during peak hours. -**EVCC interaction:** When an EV is actively charging (`charging=true`), peak shaving is disabled — the EV consumes the excess PV. When an EV is connected in EVCC "pv" mode (waiting for surplus), EVCC+EV will naturally absorb excessive PV energy once the surplus threshold is reached. +**EVCC interaction:** +- When an EV is actively charging (`charging=true`), peak shaving is disabled — the EV consumes the excess PV. +- When an EV is connected and the loadpoint mode is `pv`, peak shaving is also disabled — EVCC+EV will naturally absorb excessive PV energy once the surplus threshold is reached. +- If the EV disconnects or the mode changes away from `pv`, peak shaving is re-enabled. Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging. +**Logic architecture:** Peak shaving is implemented as a **new independent logic class** (`NextLogic`), selectable via `type: next` in the config. The existing `DefaultLogic` remains untouched. This allows users to opt into the new behavior while keeping the stable default path. + --- -## 1. Configuration — Top-Level `peak_shaving` Section +## 1. Configuration + +### 1.1 Top-Level `peak_shaving` Section ```yaml peak_shaving: @@ -23,30 +30,124 @@ peak_shaving: **`allow_full_battery_after`** — Target hour for the battery to be full: - **Before this hour:** PV charge rate is limited to spread charging evenly. The battery fills gradually instead of reaching 100% early and overflowing PV to grid. - **At/after this hour:** No PV charge limit. Battery is allowed to be 100% full. PV overflow to grid is acceptable (e.g., EV arrives home and the charger absorbs excess). -- **During EV charging (EVCC `charging=true`):** Peak shaving disabled entirely. All energy flows to the car. +- **During EV charging or EV connected in PV mode:** Peak shaving disabled entirely. + +### 1.2 Logic Type Selection + +```yaml +# In the top-level config or battery_control section: +type: next # Use 'next' to enable peak shaving logic (default: 'default') +``` + +The `type: next` logic includes all existing `DefaultLogic` behavior plus peak shaving as a post-processing step. --- -## 2. EVCC Integration — Use Existing Charging State +## 2. EVCC Integration — Loadpoint Mode & Connected State ### 2.1 Approach -The EVCC check is handled in `core.py`, **not** in the logic layer. EVCC is an external integration concern independent of the calculation logic — similar to how `discharge_blocked` is handled in `core.py` today. +Peak shaving is disabled when **any** of the following EVCC conditions are true: +1. **`charging = true`** — EV is actively charging (already tracked) +2. **`connected = true` AND `mode = pv`** — EV is plugged in and waiting for PV surplus + +The EVCC check is handled in `core.py`, **not** in the logic layer. EVCC is an external integration concern, same pattern as `discharge_blocked`. + +### 2.2 New EVCC Topics — Derived from `loadpoint_topic` + +The `mode` and `connected` topics are derived from the existing `loadpoint_topic` config by stripping `/charging` and appending the relevant suffix: + +``` +evcc/loadpoints/1/charging → evcc/loadpoints/1/mode + → evcc/loadpoints/1/connected +``` + +Topics not ending in `/charging`: log warning, skip mode/connected subscription. + +### 2.3 Changes to `evcc_api.py` + +**New state:** +```python +self.evcc_loadpoint_mode = {} # topic_root → mode string ("pv", "now", "minpv", "off") +self.evcc_loadpoint_connected = {} # topic_root → bool +self.list_topics_mode = [] # derived mode topics +self.list_topics_connected = [] # derived connected topics +``` + +**In `__init__`:** For each loadpoint topic ending in `/charging`: +```python +root = topic[:-len('/charging')] +mode_topic = root + '/mode' +connected_topic = root + '/connected' +self.list_topics_mode.append(mode_topic) +self.list_topics_connected.append(connected_topic) +self.evcc_loadpoint_mode[root] = None +self.evcc_loadpoint_connected[root] = False +self.client.message_callback_add(mode_topic, self._handle_message) +self.client.message_callback_add(connected_topic, self._handle_message) +``` -- `evcc_is_charging = True` → `core.py` skips peak shaving entirely (does not pass it to logic) -- `evcc_is_charging = False` → peak shaving may apply +**In `on_connect`:** Subscribe to mode and connected topics. -**Note:** We intentionally do **not** subscribe to or rely on `chargePower`. The existing `charging` topic is sufficient for the peak shaving decision. +**In `_handle_message`:** Route to new handlers based on topic matching. -### 2.2 No Changes to `evcc_api.py` or Logic Layer +**New handlers:** +```python +def handle_mode_message(self, message): + """Handle incoming loadpoint mode messages.""" + root = message.topic[:-len('/mode')] + mode = message.payload.decode('utf-8').strip().lower() + old_mode = self.evcc_loadpoint_mode.get(root) + if old_mode != mode: + logger.info('Loadpoint %s mode changed: %s → %s', root, old_mode, mode) + self.evcc_loadpoint_mode[root] = mode + +def handle_connected_message(self, message): + """Handle incoming loadpoint connected messages.""" + root = message.topic[:-len('/connected')] + connected = re.match(b'true', message.payload, re.IGNORECASE) is not None + old_connected = self.evcc_loadpoint_connected.get(root, False) + if old_connected != connected: + logger.info('Loadpoint %s connected: %s', root, connected) + self.evcc_loadpoint_connected[root] = connected +``` + +**New public property:** +```python +@property +def evcc_ev_expects_pv_surplus(self) -> bool: + """True if any loadpoint has an EV connected in PV mode.""" + for root in self.evcc_loadpoint_connected: + if self.evcc_loadpoint_connected.get(root, False) and \ + self.evcc_loadpoint_mode.get(root) == 'pv': + return True + return False +``` -The existing EVCC integration already provides `self.evcc_is_charging` in `core.py` via `self.evcc_api.evcc_is_charging`. The logic layer (`default.py`) does not need to know about EVCC — the decision to skip peak shaving during EV charging is made in `core.py` before/after calling the logic. +**`shutdown`:** Unsubscribe from mode and connected topics. + +### 2.4 Backward Compatibility + +- Topics not ending in `/charging`: warning logged, no mode/connected sub, existing behavior unchanged +- `evcc_ev_expects_pv_surplus` returns `False` when no data received +- Existing `evcc_is_charging` behavior is completely unchanged --- -## 3. Logic Changes — Peak Shaving via PV Charge Rate Limiting +## 3. New Logic Class — `NextLogic` + +### 3.1 Architecture + +`NextLogic` is an **independent** `LogicInterface` implementation in `src/batcontrol/logic/next.py`. It contains all the logic from `DefaultLogic` plus peak shaving as a post-processing step. -### 3.1 Core Algorithm +The implementation approach: +- Copy `DefaultLogic` to create `NextLogic` +- Add peak shaving methods (`_apply_peak_shaving`, `_calculate_peak_shaving_charge_limit`) +- Add post-processing call in `calculate_inverter_mode()` + +This keeps `DefaultLogic` completely untouched and allows the `next` logic to evolve independently. + +### 3.2 Core Algorithm The algorithm spreads battery charging over time so the battery reaches full at the target hour: @@ -69,9 +170,7 @@ If expected PV surplus is less than free capacity, no limit needed (battery won' **Note:** The charge limit is distributed evenly across slots. This is a simplification — PV production peaks midday while the limit is flat. This means the limit may have no effect in low-PV morning slots and may clip excess in high-PV midday slots. The battery may not reach exactly 100% by the target hour. This is acceptable for v1; a PV-weighted distribution could be added later. -### 3.2 Algorithm Implementation - -Following the default logic's pattern of iterating through future slots: +### 3.3 Algorithm Implementation ```python def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): @@ -122,11 +221,11 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): return int(charge_rate_w) ``` -### 3.3 Always-Allow-Discharge Region Skips Peak Shaving +### 3.4 Always-Allow-Discharge Region Skips Peak Shaving When `stored_energy >= max_capacity * always_allow_discharge_limit`, the battery is in the "always allow discharge" region. In this region, peak shaving is **not applied** — the system is already at high SOC and the normal discharge logic takes over. This also avoids toggling issues when SOC fluctuates near 100%. -### 3.4 Force Charge (MODE -1) Takes Priority Over Peak Shaving +### 3.5 Force Charge (MODE -1) Takes Priority Over Peak Shaving If the price-based logic decides to force charge from grid (`charge_from_grid=True`), this overrides peak shaving. The force charge decision means energy is cheap enough to justify grid charging — peak shaving should not interfere. @@ -140,15 +239,17 @@ In practice, force charge should rarely trigger during peak shaving hours becaus - Prices are typically low during peak PV (no incentive to grid-charge) - There should be enough PV to fill the battery by the target hour -### 3.5 Implementation in `default.py` +### 3.6 Peak Shaving Post-Processing in `NextLogic` -**Post-processing step** in `calculate_inverter_mode()`, after existing logic returns `inverter_control_settings`: +In `calculate_inverter_mode()`, after the existing DefaultLogic calculation returns `inverter_control_settings`: ```python # Apply peak shaving as post-processing step if self.calculation_parameters.peak_shaving_enabled: inverter_control_settings = self._apply_peak_shaving( inverter_control_settings, calc_input, calc_timestamp) + +return inverter_control_settings ``` **`_apply_peak_shaving()`:** @@ -162,7 +263,7 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): - Battery is in always_allow_discharge region (high SOC) - Force charge from grid is active (MODE -1) - Note: EVCC charging check is handled in core.py, not here. + Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. """ # After target hour: no limit if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: @@ -199,7 +300,7 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): return settings ``` -### 3.6 Data Flow — Extended `CalculationParameters` +### 3.7 Data Flow — Extended `CalculationParameters` Peak shaving configuration is passed via the existing `CalculationParameters` dataclass (consistent with existing interface pattern): @@ -216,7 +317,44 @@ class CalculationParameters: peak_shaving_allow_full_after: int = 14 # Hour (0-23) ``` -In `core.py`, the `CalculationParameters` constructor is extended: +**No changes needed to `CalculationInput`** — the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. + +--- + +## 4. Logic Factory — `logic.py` + +The factory gains a new type `next`: + +```python +@staticmethod +def create_logic(config: dict, timezone) -> LogicInterface: + request_type = config.get('type', 'default').lower() + interval_minutes = config.get('time_resolution_minutes', 60) + + if request_type == 'default': + logic = DefaultLogic(timezone, interval_minutes=interval_minutes) + # ... existing expert tuning ... + elif request_type == 'next': + logic = NextLogic(timezone, interval_minutes=interval_minutes) + # ... same expert tuning as default ... + else: + raise RuntimeError(f'[Logic] Unknown logic type {config["type"]}') + return logic +``` + +The `NextLogic` supports the same expert tuning attributes as `DefaultLogic`. + +--- + +## 5. Core Integration — `core.py` + +### 5.1 Init + +No new instance vars needed. Peak shaving config is read from `self.config` each run cycle and passed via `CalculationParameters`. + +### 5.2 Run Loop + +Extend `CalculationParameters` construction: ```python peak_shaving_config = self.config.get('peak_shaving', {}) @@ -231,45 +369,36 @@ calc_parameters = CalculationParameters( ) ``` -**No changes needed to `CalculationInput`** — the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. - -**No changes needed to `logic.py` factory** — configuration flows through `CalculationParameters` via the existing `set_calculation_parameters()` method. - ---- - -## 4. Core Integration — `core.py` - -### 4.1 Init - -No new instance vars needed. Peak shaving config is read from `self.config` each run cycle and passed via `CalculationParameters`. - -### 4.2 Run Loop +### 5.3 EVCC Peak Shaving Guard -Extend `CalculationParameters` construction (see Section 3.6). +The EVCC check is handled in `core.py`, keeping EVCC concerns out of the logic layer. This follows the same pattern as `discharge_blocked` (line 536-541 in current code). -### 4.3 EVCC Charging Check - -The EVCC charging check is handled in `core.py`, keeping EVCC concerns out of the logic layer. This follows the same pattern as `discharge_blocked` (line 536-541 in current code). - -After `logic.calculate()` returns and before mode dispatch, if EVCC is charging and peak shaving produced a charge limit, the limit is cleared: +After `logic.calculate()` returns and before mode dispatch, peak shaving is overridden if EVCC conditions require it: ```python -# EVCC charging disables peak shaving (handled in core, not logic) -evcc_is_charging = (self.evcc_api is not None and self.evcc_api.evcc_is_charging) -if evcc_is_charging and inverter_settings.limit_battery_charge_rate >= 0: - logger.debug('[PeakShaving] Skipped: EVCC is charging') - inverter_settings.limit_battery_charge_rate = -1 +# EVCC disables peak shaving (handled in core, not logic) +if self.evcc_api is not None: + evcc_disable_peak_shaving = ( + self.evcc_api.evcc_is_charging or + self.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving and inverter_settings.limit_battery_charge_rate >= 0: + if self.evcc_api.evcc_is_charging: + logger.debug('[PeakShaving] Disabled: EVCC is actively charging') + else: + logger.debug('[PeakShaving] Disabled: EV connected in PV mode') + inverter_settings.limit_battery_charge_rate = -1 ``` -**Note:** This is a simple approach. If `limit_battery_charge_rate` was set by non-peak-shaving logic, this would also clear it. However, in the current codebase, MODE 8 with `allow_discharge=True` is only set by peak shaving (the existing logic uses MODE 8 only in combination with `allow_discharge=False` for the "avoid discharge" path). If this changes in the future, a more targeted approach (e.g., a flag on the settings) would be needed. +**Note:** This clears any `limit_battery_charge_rate` set by the logic, not just peak shaving. In the current codebase this is safe because MODE 8 with `allow_discharge=True` is only set by peak shaving. If this changes in the future, a more targeted approach (e.g., a flag on the settings) would be needed. -### 4.4 Mode Selection +### 5.4 Mode Selection In the mode selection block (after `logic.calculate()`), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed — peak shaving sets the limit in `InverterControlSettings` and the existing dispatch handles it. --- -## 5. MQTT API — Publish Peak Shaving State +## 6. MQTT API — Publish Peak Shaving State Publish topics: - `{base}/peak_shaving/enabled` — boolean (`true`/`false`, plain text, retained) @@ -289,9 +418,9 @@ QoS: 1 for all topics (consistent with existing MQTT API). --- -## 6. Tests +## 7. Tests -### 6.1 Logic Tests (`tests/batcontrol/logic/test_peak_shaving.py`) +### 7.1 Logic Tests (`tests/batcontrol/logic/test_peak_shaving.py`) **Algorithm tests (`_calculate_peak_shaving_charge_limit`):** - High PV surplus, small free capacity → low charge limit @@ -311,54 +440,77 @@ QoS: 1 for all topics (consistent with existing MQTT API). - Existing tighter limit from other logic → kept (more restrictive wins) - Peak shaving limit tighter than existing → peak shaving limit applied -### 6.2 Config Tests +### 7.2 EVCC Tests (`tests/batcontrol/test_evcc_mode.py`) +- Topic derivation: `evcc/loadpoints/1/charging` → mode: `evcc/loadpoints/1/mode`, connected: `evcc/loadpoints/1/connected` +- Non-standard topic (not ending in `/charging`) → warning, no mode/connected sub +- `handle_mode_message` parses mode string correctly +- `handle_connected_message` parses boolean correctly +- `evcc_ev_expects_pv_surplus`: connected=true + mode=pv → True +- `evcc_ev_expects_pv_surplus`: connected=true + mode=now → False +- `evcc_ev_expects_pv_surplus`: connected=false + mode=pv → False +- `evcc_ev_expects_pv_surplus`: no data received → False +- Multi-loadpoint: one connected+pv is enough to return True +- Mode change from pv to now → `evcc_ev_expects_pv_surplus` changes to False + +### 7.3 Config Tests + +- `type: next` → creates `NextLogic` instance +- `type: default` → creates `DefaultLogic` instance (unchanged) - With `peak_shaving` section → `CalculationParameters` fields set correctly - Without `peak_shaving` section → `peak_shaving_enabled = False` (default) -- Invalid `allow_full_battery_after` values (edge cases: 0, 23) --- -## 7. Implementation Order +## 8. Implementation Order -1. **Config** — Add `peak_shaving` section to dummy config +1. **Config** — Add `peak_shaving` section to dummy config, add `type: next` option 2. **Data model** — Extend `CalculationParameters` with peak shaving fields -3. **Logic** — `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` in `default.py` -4. **Core** — Wire EVCC state + peak shaving config into `CalculationParameters` -5. **MQTT** — Publish topics + settable topics + HA discovery -6. **Tests** +3. **EVCC** — Add mode and connected topic subscriptions, `evcc_ev_expects_pv_surplus` property +4. **NextLogic** — New file `next.py`: copy DefaultLogic, add `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` +5. **Logic factory** — Add `type: next` → `NextLogic` in `logic.py` +6. **Core** — Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard +7. **MQTT** — Publish topics + settable topics + HA discovery +8. **Tests** --- -## 8. Files Modified +## 9. Files Modified | File | Change | |------|--------| | `config/batcontrol_config_dummy.yaml` | Add `peak_shaving` section | | `src/batcontrol/logic/logic_interface.py` | Add peak shaving fields to `CalculationParameters` | -| `src/batcontrol/logic/default.py` | `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` | -| `src/batcontrol/core.py` | Wire peak shaving config into `CalculationParameters`, EVCC charging guard | +| `src/batcontrol/logic/next.py` | **New** — `NextLogic` class with peak shaving | +| `src/batcontrol/logic/logic.py` | Add `type: next` → `NextLogic` | +| `src/batcontrol/evcc_api.py` | Add mode + connected topic subscriptions, `evcc_ev_expects_pv_surplus` | +| `src/batcontrol/core.py` | Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard | | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | | `tests/batcontrol/logic/test_peak_shaving.py` | New — algorithm + decision tests | +| `tests/batcontrol/test_evcc_mode.py` | New — mode/connected topic tests | -**Not modified:** `evcc_api.py` (no changes needed), `logic.py` factory (config flows through `CalculationParameters`) +**Not modified:** `default.py` (untouched — peak shaving is in `next.py`) --- -## 9. Resolved Design Decisions +## 10. Resolved Design Decisions -1. **EVCC integration:** Use existing `evcc_is_charging` boolean only, checked in `core.py` (not in logic layer). No `chargePower` subscription — it was reported as unreliable and is not needed for the on/off peak shaving decision. EVCC is an external integration concern and stays in `core.py`, following the same pattern as `discharge_blocked`. +1. **New independent logic class:** Peak shaving lives in `NextLogic` (`type: next`), not as a modification to `DefaultLogic`. This keeps the stable default path untouched and allows the next logic to evolve independently. `NextLogic` is a full copy of `DefaultLogic` with peak shaving added. -2. **Grid charging interaction (MODE -1 vs MODE 8):** `force_charge` takes priority. If price logic triggers grid charging during peak shaving hours, a warning is logged but grid charging proceeds. This should rarely occur in practice because PV-heavy hours have low prices. +2. **EVCC integration:** Handled in `core.py` (not logic layer). Peak shaving is disabled when `evcc_is_charging` OR (`connected=true` AND `mode=pv`). The mode and connected topics are derived from the existing `loadpoint_topic` config by stripping `/charging`. No `chargePower` subscription — it was reported as unreliable. -3. **Interface consistency:** Peak shaving config is passed via `CalculationParameters` (extended with new fields), following the existing pattern. No separate `set_peak_shaving_config()` method. +3. **Grid charging interaction (MODE -1 vs MODE 8):** `force_charge` takes priority. If price logic triggers grid charging during peak shaving hours, a warning is logged but grid charging proceeds. -4. **High SOC handling:** When battery is in `always_allow_discharge` region, peak shaving is skipped entirely. This avoids toggling at near-full SOC and is consistent with the system's existing high-SOC behavior. +4. **Interface consistency:** Peak shaving config is passed via `CalculationParameters` (extended with new fields), following the existing pattern. -5. **Algorithm uses net PV surplus:** The charge limit calculation uses `production - consumption` (positive only), not raw production. This prevents over-throttling when household consumption absorbs most of the PV. +5. **High SOC handling:** When battery is in `always_allow_discharge` region, peak shaving is skipped entirely. This avoids toggling at near-full SOC. -## 10. Known Limitations (v1) +6. **Algorithm uses net PV surplus:** The charge limit calculation uses `production - consumption` (positive only), not raw production. This prevents over-throttling when household consumption absorbs most of the PV. + +## 11. Known Limitations (v1) 1. **Flat charge distribution:** The charge rate limit is uniform across all slots, but PV production peaks midday. The battery may not reach exactly 100% by the target hour. Acceptable for v1. 2. **No intra-day target adjustment:** If clouds reduce PV significantly, the limit stays as calculated until the next evaluation cycle (every 3 minutes). The system self-corrects because free capacity stays high, which increases the allowed charge rate. + +3. **Code duplication:** `NextLogic` is a copy of `DefaultLogic`. Changes to the default logic need to be mirrored manually. Once peak shaving is stable, the two could be merged (next becomes the new default) or refactored to use composition. From 99cf4f925346b81d5c7f1ebc088ef6bab3e465df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 11:43:47 +0000 Subject: [PATCH 05/35] Add nighttime skip and documentation step to plan - Skip peak shaving calculation when current production <= 0 (no PV at night) - Add docs/peak_shaving.md to implementation order and file list https://claude.ai/code/session_01T62gAfD8CBB9uSU4w3uq1y --- PLAN.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/PLAN.md b/PLAN.md index 34df7620..c0ddbb80 100644 --- a/PLAN.md +++ b/PLAN.md @@ -265,6 +265,10 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. """ + # No production right now: skip calculation (avoid unnecessary work at night) + if calc_input.production[0] <= 0: + return settings + # After target hour: no limit if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: return settings @@ -433,6 +437,7 @@ QoS: 1 for all topics (consistent with existing MQTT API). **Decision tests (`_apply_peak_shaving`):** - `peak_shaving_enabled = False` → no change to settings +- Current production = 0 (nighttime) → peak shaving skipped - `charge_from_grid = True` → peak shaving skipped, warning logged - Battery in always_allow_discharge region → peak shaving skipped - Before target hour, limit calculated → `limit_battery_charge_rate` set @@ -472,6 +477,7 @@ QoS: 1 for all topics (consistent with existing MQTT API). 6. **Core** — Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard 7. **MQTT** — Publish topics + settable topics + HA discovery 8. **Tests** +9. **Documentation** — Write `docs/peak_shaving.md` covering feature overview, configuration, EVCC interaction, algorithm explanation, and known limitations --- @@ -488,6 +494,7 @@ QoS: 1 for all topics (consistent with existing MQTT API). | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | | `tests/batcontrol/logic/test_peak_shaving.py` | New — algorithm + decision tests | | `tests/batcontrol/test_evcc_mode.py` | New — mode/connected topic tests | +| `docs/peak_shaving.md` | New — feature documentation | **Not modified:** `default.py` (untouched — peak shaving is in `next.py`) From f935027d7950c5204cc2e5df59d879400648de28 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 15:05:18 +0100 Subject: [PATCH 06/35] Iteration 1 --- config/batcontrol_config_dummy.yaml | 11 + docs/WIKI_peak_shaving.md | 110 ++++ src/batcontrol/core.py | 68 ++- src/batcontrol/evcc_api.py | 66 +++ src/batcontrol/logic/__init__.py | 1 + src/batcontrol/logic/logic.py | 40 +- src/batcontrol/logic/logic_interface.py | 10 + src/batcontrol/logic/next.py | 557 ++++++++++++++++++++ src/batcontrol/mqtt_api.py | 67 +++ tests/batcontrol/logic/test_peak_shaving.py | 464 ++++++++++++++++ tests/batcontrol/test_evcc_mode.py | 203 +++++++ 11 files changed, 1581 insertions(+), 16 deletions(-) create mode 100644 docs/WIKI_peak_shaving.md create mode 100644 src/batcontrol/logic/next.py create mode 100644 tests/batcontrol/logic/test_peak_shaving.py create mode 100644 tests/batcontrol/test_evcc_mode.py diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 6f32975f..cc48766e 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -10,6 +10,7 @@ log_everything: false # if false debug messages from fronius.auth and urllib3.co max_logfile_size: 200 #kB logfile_path: logs/batcontrol.log battery_control: + type: default # Logic type: 'default' (standard) or 'next' (includes peak shaving) # min_price_difference is the absolute minimum price difference in Euro to justify charging your battery # if min_price_difference_rel results in a higher price difference, that will be used min_price_difference: 0.05 # minimum price difference in Euro to justify charging your battery @@ -32,6 +33,16 @@ battery_control_expert: production_offset_percent: 1.0 # Adjust production forecast by a percentage (1.0 = 100%, 0.8 = 80%, etc.) # Useful for winter mode when solar panels are covered with snow +#-------------------------- +# Peak Shaving +# Manages PV battery charging rate so the battery fills up gradually, +# reaching full capacity by a target hour (allow_full_battery_after). +# Requires logic type 'next' in battery_control section. +#-------------------------- +peak_shaving: + enabled: false + allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour + #-------------------------- # Inverter # See more Details in: https://github.com/MaStr/batcontrol/wiki/Inverter-Configuration diff --git a/docs/WIKI_peak_shaving.md b/docs/WIKI_peak_shaving.md new file mode 100644 index 00000000..18483143 --- /dev/null +++ b/docs/WIKI_peak_shaving.md @@ -0,0 +1,110 @@ +# Peak Shaving + +## Overview + +Peak shaving manages PV battery charging rate so the battery fills up gradually, reaching full capacity by a configurable target hour (`allow_full_battery_after`). This prevents the battery from being full too early in the day. + +**Problem:** All PV systems produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest — and for newer installations, feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible. + +## Configuration + +### Enable Peak Shaving + +Peak shaving requires two configuration changes: + +1. Set the logic type to `next` in the `battery_control` section: + +```yaml +battery_control: + type: next # Use 'next' to enable peak shaving logic (default: 'default') +``` + +2. Configure the `peak_shaving` section: + +```yaml +peak_shaving: + enabled: false + allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour +``` + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `enabled` | bool | `false` | Enable/disable peak shaving | +| `allow_full_battery_after` | int | `14` | Target hour (0-23) for the battery to be full | + +**`allow_full_battery_after`** controls when the battery is allowed to be 100% full: +- **Before this hour:** PV charge rate is limited to spread charging evenly +- **At/after this hour:** No PV charge limit, battery is allowed to reach full charge + +## How It Works + +### Algorithm + +The algorithm calculates the expected PV surplus (production minus consumption) for all time slots until the target hour. If the expected surplus would fill the battery before the target hour, it calculates a charge rate limit: + +``` +slots_remaining = slots from now until allow_full_battery_after +free_capacity = battery free capacity in Wh +pv_surplus = sum of max(production - consumption, 0) for remaining slots + +if pv_surplus > free_capacity: + charge_limit = free_capacity / slots_remaining (Wh per slot, converted to W) +``` + +The charge limit is applied using **MODE 8** (`limit_battery_charge_rate`), which limits PV charging while still allowing battery discharge. + +### Skip Conditions + +Peak shaving is automatically skipped when: + +1. **No PV production** — nighttime, no action needed +2. **Past the target hour** — battery is allowed to be full +3. **Battery in always_allow_discharge region** — SOC is already high +4. **Grid charging active (MODE -1)** — force charge takes priority +5. **EVCC is actively charging** — EV consumes the excess PV +6. **EV connected in PV mode** — EVCC will absorb PV surplus + +### EVCC Interaction + +When an EV charger is managed by EVCC: + +- **EV actively charging** (`charging=true`): Peak shaving is disabled — the EV consumes the excess PV +- **EV connected in PV mode** (`connected=true` AND `mode=pv`): Peak shaving is disabled — EVCC will naturally absorb surplus PV when the threshold is reached +- **EV disconnects or mode changes**: Peak shaving is re-enabled + +The EVCC integration derives `mode` and `connected` topics automatically from the configured `loadpoint_topic` by replacing `/charging` with `/mode` and `/connected`. + +## MQTT API + +### Published Topics + +| Topic | Type | Retained | Description | +|-------|------|----------|-------------| +| `{base}/peak_shaving/enabled` | bool | Yes | Peak shaving enabled status | +| `{base}/peak_shaving/allow_full_battery_after` | int | Yes | Target hour (0-23) | +| `{base}/peak_shaving/charge_limit` | int | No | Current charge limit in W (-1 if inactive) | + +### Settable Topics + +| Topic | Accepts | Description | +|-------|---------|-------------| +| `{base}/peak_shaving/enabled/set` | `true`/`false` | Enable/disable peak shaving | +| `{base}/peak_shaving/allow_full_battery_after/set` | int 0-23 | Set target hour | + +### Home Assistant Auto-Discovery + +The following HA entities are automatically created: + +- **Peak Shaving Enabled** — switch entity +- **Peak Shaving Allow Full After** — number entity (0-23, step 1) +- **Peak Shaving Charge Limit** — sensor entity (unit: W) + +## Known Limitations + +1. **Flat charge distribution:** The charge rate limit is uniform across all time slots, but PV production peaks at midday. The battery may not reach exactly 100% by the target hour. + +2. **No intra-day adjustment:** If clouds reduce PV significantly, the limit stays as calculated until the next evaluation cycle (every 3 minutes). The system self-corrects because free capacity stays high, which increases the allowed charge rate. + +3. **Code duplication:** `NextLogic` is a copy of `DefaultLogic` with peak shaving added. Once stable, the two could be merged or refactored. diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index a21b0592..5a3012c0 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -270,6 +270,16 @@ def __init__(self, configdict: dict): self.api_set_production_offset, float ) + self.mqtt_api.register_set_callback( + 'peak_shaving/enabled', + self.api_set_peak_shaving_enabled, + str + ) + self.mqtt_api.register_set_callback( + 'peak_shaving/allow_full_battery_after', + self.api_set_peak_shaving_allow_full_after, + int + ) # Inverter Callbacks self.inverter.activate_mqtt(self.mqtt_api) @@ -508,11 +518,16 @@ def run(self): self.get_stored_usable_energy(), self.get_free_capacity() ) + peak_shaving_config = self.config.get('peak_shaving', {}) + calc_parameters = CalculationParameters( self.max_charging_from_grid_limit, self.min_price_difference, self.min_price_difference_rel, - self.get_max_capacity() + self.get_max_capacity(), + peak_shaving_enabled=peak_shaving_config.get('enabled', False), + peak_shaving_allow_full_after=peak_shaving_config.get( + 'allow_full_battery_after', 14), ) self.last_logic_instance = this_logic_run @@ -540,6 +555,24 @@ def run(self): logger.debug('Discharge blocked due to external lock') inverter_settings.allow_discharge = False + # EVCC disables peak shaving (handled in core, not logic) + if self.evcc_api is not None: + evcc_disable_peak_shaving = ( + self.evcc_api.evcc_is_charging or + self.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving and inverter_settings.limit_battery_charge_rate >= 0: + if self.evcc_api.evcc_is_charging: + logger.debug('[PeakShaving] Disabled: EVCC is actively charging') + else: + logger.debug('[PeakShaving] Disabled: EV connected in PV mode') + inverter_settings.limit_battery_charge_rate = -1 + + # Publish peak shaving charge limit (after EVCC guard may have cleared it) + if self.mqtt_api is not None: + self.mqtt_api.publish_peak_shaving_charge_limit( + inverter_settings.limit_battery_charge_rate) + if inverter_settings.allow_discharge: if inverter_settings.limit_battery_charge_rate >= 0: self.limit_battery_charge_rate(inverter_settings.limit_battery_charge_rate) @@ -797,6 +830,12 @@ def refresh_static_values(self) -> None: self.mqtt_api.publish_last_evaluation_time(self.last_run_time) # self.mqtt_api.publish_discharge_blocked(self.discharge_blocked) + # Peak shaving + peak_shaving_config = self.config.get('peak_shaving', {}) + self.mqtt_api.publish_peak_shaving_enabled( + peak_shaving_config.get('enabled', False)) + self.mqtt_api.publish_peak_shaving_allow_full_after( + peak_shaving_config.get('allow_full_battery_after', 14)) # Trigger Inverter self.inverter.refresh_api_values() @@ -926,3 +965,30 @@ def api_set_production_offset(self, production_offset: float): self.production_offset_percent = production_offset if self.mqtt_api is not None: self.mqtt_api.publish_production_offset(production_offset) + + def api_set_peak_shaving_enabled(self, enabled_str: str): + """ Set peak shaving enabled/disabled via external API request. + The change is temporary and will not be written to the config file. + """ + enabled = enabled_str.strip().lower() in ('true', 'on', '1') + logger.info('API: Setting peak shaving enabled to %s', enabled) + peak_shaving = self.config.setdefault('peak_shaving', {}) + peak_shaving['enabled'] = enabled + if self.mqtt_api is not None: + self.mqtt_api.publish_peak_shaving_enabled(enabled) + + def api_set_peak_shaving_allow_full_after(self, hour: int): + """ Set peak shaving target hour via external API request. + The change is temporary and will not be written to the config file. + """ + if hour < 0 or hour > 23: + logger.warning( + 'API: Invalid peak shaving allow_full_battery_after %d ' + '(must be 0-23)', hour) + return + logger.info( + 'API: Setting peak shaving allow_full_battery_after to %d', hour) + peak_shaving = self.config.setdefault('peak_shaving', {}) + peak_shaving['allow_full_battery_after'] = hour + if self.mqtt_api is not None: + self.mqtt_api.publish_peak_shaving_allow_full_after(hour) diff --git a/src/batcontrol/evcc_api.py b/src/batcontrol/evcc_api.py index 7ac6341d..483f19f9 100644 --- a/src/batcontrol/evcc_api.py +++ b/src/batcontrol/evcc_api.py @@ -77,6 +77,10 @@ def __init__(self, config: dict): self.evcc_is_charging = False self.evcc_loadpoint_status = {} + self.evcc_loadpoint_mode = {} # topic_root → mode string ("pv", "now", "minpv", "off") + self.evcc_loadpoint_connected = {} # topic_root → bool + self.list_topics_mode = [] # derived mode topics + self.list_topics_connected = [] # derived connected topics self.block_function = None self.set_always_allow_discharge_limit_function = None @@ -133,6 +137,23 @@ def __init__(self, config: dict): self.__store_loadpoint_status(topic, False) self.client.message_callback_add(topic, self._handle_message) + # Derive mode and connected topics from loadpoint charging topics + for topic in self.list_topics_loadpoint: + if topic.endswith('/charging'): + root = topic[:-len('/charging')] + mode_topic = root + '/mode' + connected_topic = root + '/connected' + self.list_topics_mode.append(mode_topic) + self.list_topics_connected.append(connected_topic) + self.evcc_loadpoint_mode[root] = None + self.evcc_loadpoint_connected[root] = False + self.client.message_callback_add(mode_topic, self._handle_message) + self.client.message_callback_add(connected_topic, self._handle_message) + else: + logger.warning( + 'Loadpoint topic %s does not end in /charging, ' + 'skipping mode/connected subscription', topic) + self.client.on_connect = self.on_connect def start(self): @@ -148,6 +169,10 @@ def shutdown(self): self.client.unsubscribe(self.topic_battery_halt_soc) for topic in self.list_topics_loadpoint: self.client.unsubscribe(topic) + for topic in self.list_topics_mode: + self.client.unsubscribe(topic) + for topic in self.list_topics_connected: + self.client.unsubscribe(topic) self.client.loop_stop() self.client.disconnect() @@ -161,6 +186,12 @@ def on_connect(self, client, userdata, flags, rc): # pylint: disable=unused-arg for topic in self.list_topics_loadpoint: logger.info('Subscribing to %s', topic) self.client.subscribe(topic) + for topic in self.list_topics_mode: + logger.info('Subscribing to %s', topic) + self.client.subscribe(topic) + for topic in self.list_topics_connected: + logger.info('Subscribing to %s', topic) + self.client.subscribe(topic) def wait_ready(self) -> bool: """ Wait until the MQTT client is connected to the broker """ @@ -236,6 +267,10 @@ def set_evcc_online(self, online: bool): self.block_function(False) self.__restore_old_limits() self.__reset_loadpoint_status() + # Reset mode/connected state to prevent stale values + for root in list(self.evcc_loadpoint_mode.keys()): + self.evcc_loadpoint_mode[root] = None + self.evcc_loadpoint_connected[root] = False else: logger.info('evcc is online') self.evcc_is_online = online @@ -326,6 +361,33 @@ def handle_charging_message(self, message): self.evaluate_charging_status() + def handle_mode_message(self, message): + """Handle incoming loadpoint mode messages.""" + root = message.topic[:-len('/mode')] + mode = message.payload.decode('utf-8').strip().lower() + old_mode = self.evcc_loadpoint_mode.get(root) + if old_mode != mode: + logger.info('Loadpoint %s mode changed: %s → %s', root, old_mode, mode) + self.evcc_loadpoint_mode[root] = mode + + def handle_connected_message(self, message): + """Handle incoming loadpoint connected messages.""" + root = message.topic[:-len('/connected')] + connected = re.match(b'true', message.payload, re.IGNORECASE) is not None + old_connected = self.evcc_loadpoint_connected.get(root, False) + if old_connected != connected: + logger.info('Loadpoint %s connected: %s', root, connected) + self.evcc_loadpoint_connected[root] = connected + + @property + def evcc_ev_expects_pv_surplus(self) -> bool: + """True if any loadpoint has an EV connected in PV mode.""" + for root in self.evcc_loadpoint_connected: + if self.evcc_loadpoint_connected.get(root, False) and \ + self.evcc_loadpoint_mode.get(root) == 'pv': + return True + return False + def evaluate_charging_status(self): """ Go through the loadpoints and check if one is charging """ for _, is_charging in self.evcc_loadpoint_status.items(): @@ -345,6 +407,10 @@ def _handle_message(self, client, userdata, message): # pylint: disable=unused- # Check if message.topic is in self.list_topics_loadpoint elif message.topic in self.list_topics_loadpoint: self.handle_charging_message(message) + elif message.topic in self.list_topics_mode: + self.handle_mode_message(message) + elif message.topic in self.list_topics_connected: + self.handle_connected_message(message) else: logger.warning( 'No callback registered for %s', message.topic) diff --git a/src/batcontrol/logic/__init__.py b/src/batcontrol/logic/__init__.py index df3111fe..1bcf2814 100644 --- a/src/batcontrol/logic/__init__.py +++ b/src/batcontrol/logic/__init__.py @@ -1,3 +1,4 @@ from .logic import Logic from .logic_interface import LogicInterface, CalculationParameters, CalculationInput, CalculationOutput, InverterControlSettings from .common import CommonLogic +from .next import NextLogic diff --git a/src/batcontrol/logic/logic.py b/src/batcontrol/logic/logic.py index 1e2fb068..3aa11571 100644 --- a/src/batcontrol/logic/logic.py +++ b/src/batcontrol/logic/logic.py @@ -3,6 +3,7 @@ from .logic_interface import LogicInterface from .default import DefaultLogic +from .next import NextLogic logger = logging.getLogger(__name__) @@ -12,7 +13,8 @@ class Logic: @staticmethod def create_logic(config: dict, timezone) -> LogicInterface: """ Select and configure a logic class based on the given configuration """ - request_type = config.get('type', 'default').lower() + battery_control = config.get('battery_control', {}) + request_type = battery_control.get('type', 'default').lower() interval_minutes = config.get('time_resolution_minutes', 60) logic = None if request_type == 'default': @@ -20,19 +22,27 @@ def create_logic(config: dict, timezone) -> LogicInterface: logger.info('Using default logic') Logic.print_class_message = False logic = DefaultLogic(timezone, interval_minutes=interval_minutes) - if config.get('battery_control_expert', None) is not None: - battery_control_expert = config.get( 'battery_control_expert', {}) - attribute_list = [ - 'soften_price_difference_on_charging', - 'soften_price_difference_on_charging_factor', - 'round_price_digits', - 'charge_rate_multiplier', - ] - for attribute in attribute_list: - if attribute in battery_control_expert: - logger.debug('Setting %s to %s', attribute , - battery_control_expert[attribute]) - setattr(logic, attribute, battery_control_expert[attribute]) + elif request_type == 'next': + if Logic.print_class_message: + logger.info('Using next logic (with peak shaving support)') + Logic.print_class_message = False + logic = NextLogic(timezone, interval_minutes=interval_minutes) else: - raise RuntimeError(f'[Logic] Unknown logic type {config["type"]}') + raise RuntimeError( + f'[Logic] Unknown logic type {request_type}') + + # Apply expert tuning attributes (shared between default and next) + if config.get('battery_control_expert', None) is not None: + battery_control_expert = config.get('battery_control_expert', {}) + attribute_list = [ + 'soften_price_difference_on_charging', + 'soften_price_difference_on_charging_factor', + 'round_price_digits', + 'charge_rate_multiplier', + ] + for attribute in attribute_list: + if attribute in battery_control_expert: + logger.debug('Setting %s to %s', attribute, + battery_control_expert[attribute]) + setattr(logic, attribute, battery_control_expert[attribute]) return logic diff --git a/src/batcontrol/logic/logic_interface.py b/src/batcontrol/logic/logic_interface.py index ff207b26..13b5e891 100644 --- a/src/batcontrol/logic/logic_interface.py +++ b/src/batcontrol/logic/logic_interface.py @@ -20,6 +20,16 @@ class CalculationParameters: min_price_difference: float min_price_difference_rel: float max_capacity: float # Maximum capacity of the battery in Wh (excludes MAX_SOC) + # Peak shaving parameters + peak_shaving_enabled: bool = False + peak_shaving_allow_full_after: int = 14 # Hour (0-23) + + def __post_init__(self): + if not 0 <= self.peak_shaving_allow_full_after <= 23: + raise ValueError( + f"peak_shaving_allow_full_after must be 0-23, " + f"got {self.peak_shaving_allow_full_after}" + ) @dataclass class CalculationOutput: diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py new file mode 100644 index 00000000..f5478a50 --- /dev/null +++ b/src/batcontrol/logic/next.py @@ -0,0 +1,557 @@ +"""NextLogic — Extended battery control logic with peak shaving. + +This module provides the NextLogic class, which extends the DefaultLogic +behavior with a peak shaving post-processing step. Peak shaving manages +PV battery charging rate so the battery fills up gradually, reaching full +capacity by a configurable target hour (allow_full_battery_after). + +This prevents the battery from being full too early in the day, +avoiding excessive feed-in during midday PV peak hours. + +Usage: + Select via ``type: next`` in the battery_control config section. +""" +import logging +import datetime +import numpy as np +from typing import Optional + +from .logic_interface import LogicInterface +from .logic_interface import CalculationParameters, CalculationInput +from .logic_interface import CalculationOutput, InverterControlSettings +from .common import CommonLogic + +# Minimum remaining time in hours to prevent division by very small numbers +# when calculating charge rates. This constant serves as a safety threshold: +# - Prevents extremely high charge rates at the end of intervals +# - Ensures charge rate calculations remain within reasonable bounds +# - 1 minute (1/60 hour) is chosen as it allows adequate time for the inverter +# to respond while preventing numerical instability in the calculation +MIN_REMAINING_TIME_HOURS = 1.0 / 60.0 # 1 minute expressed in hours + +logger = logging.getLogger(__name__) + + +class NextLogic(LogicInterface): + """Extended logic class for Batcontrol with peak shaving support. + + Contains all DefaultLogic behaviour plus a peak shaving post-processing + step that limits PV charge rate to spread battery charging over time. + """ + + def __init__(self, timezone: datetime.timezone = datetime.timezone.utc, + interval_minutes: int = 60): + self.calculation_parameters = None + self.calculation_output = None + self.inverter_control_settings = None + self.round_price_digits = 4 # Default rounding for prices + self.soften_price_difference_on_charging = False + self.soften_price_difference_on_charging_factor = 5.0 # Default factor + self.timezone = timezone + self.interval_minutes = interval_minutes + self.common = CommonLogic.get_instance() + + def set_round_price_digits(self, digits: int): + """ Set the number of digits to round prices to """ + self.round_price_digits = digits + + def set_soften_price_difference_on_charging(self, soften: bool, factor: float = 5): + """ Set if the price difference should be softened on charging """ + self.soften_price_difference_on_charging = soften + self.soften_price_difference_on_charging_factor = factor + + def set_calculation_parameters(self, parameters: CalculationParameters): + """ Set the calculation parameters for the logic """ + self.calculation_parameters = parameters + self.common.max_capacity = parameters.max_capacity + + def set_timezone(self, timezone: datetime.timezone): + """ Set the timezone for the logic calculations """ + self.timezone = timezone + + def calculate(self, input_data: CalculationInput, + calc_timestamp: Optional[datetime.datetime] = None) -> bool: + """ Calculate the inverter control settings based on the input data """ + + logger.debug("Calculating inverter control settings...") + + if calc_timestamp is None: + calc_timestamp = datetime.datetime.now().astimezone(self.timezone) + + self.calculation_output = CalculationOutput( + reserved_energy=0.0, + required_recharge_energy=0.0, + min_dynamic_price_difference=0.0 + ) + + self.inverter_control_settings = self.calculate_inverter_mode( + input_data, + calc_timestamp + ) + return True + + def get_calculation_output(self) -> CalculationOutput: + """ Get the calculation output from the last calculation """ + return self.calculation_output + + def get_inverter_control_settings(self) -> InverterControlSettings: + """ Get the inverter control settings from the last calculation """ + return self.inverter_control_settings + + # ------------------------------------------------------------------ # + # Main control logic (same as DefaultLogic) # + # ------------------------------------------------------------------ # + + def calculate_inverter_mode(self, calc_input: CalculationInput, + calc_timestamp: Optional[datetime.datetime] = None + ) -> InverterControlSettings: + """ Main control logic for battery control """ + # default settings + inverter_control_settings = InverterControlSettings( + allow_discharge=False, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=-1 + ) + + if self.calculation_output is None: + logger.error("Calculation output is not set. Please call calculate() first.") + raise ValueError("Calculation output is not set. Please call calculate() first.") + + net_consumption = calc_input.consumption - calc_input.production + prices = calc_input.prices + + if calc_timestamp is None: + calc_timestamp = datetime.datetime.now().astimezone(self.timezone) + + # ensure availability of data + max_slot = min(len(net_consumption), len(prices)) + + if self._is_discharge_allowed(calc_input, net_consumption, prices, calc_timestamp): + inverter_control_settings.allow_discharge = True + inverter_control_settings.limit_battery_charge_rate = -1 # no limit + + else: # discharge not allowed + logger.debug('Discharging is NOT allowed') + inverter_control_settings.allow_discharge = False + charging_limit_percent = self.calculation_parameters.max_charging_from_grid_limit * 100 + charge_limit_capacity = self.common.max_capacity * \ + self.calculation_parameters.max_charging_from_grid_limit + is_charging_possible = calc_input.stored_energy < charge_limit_capacity + + # Defaults to 0, only calculate if charging is possible + required_recharge_energy = 0 + + logger.debug('Charging allowed: %s', is_charging_possible) + if is_charging_possible: + logger.debug('Charging is allowed, because SOC is below %.0f%%', + charging_limit_percent) + required_recharge_energy = self._get_required_recharge_energy( + calc_input, + net_consumption[:max_slot], + prices + ) + else: + logger.debug('Charging is NOT allowed, because SOC is above %.0f%%', + charging_limit_percent) + + if required_recharge_energy > 0: + allowed_charging_energy = charge_limit_capacity - calc_input.stored_energy + if required_recharge_energy > allowed_charging_energy: + required_recharge_energy = allowed_charging_energy + logger.debug( + 'Required recharge energy limited by max. charging limit to %0.1f Wh', + required_recharge_energy + ) + logger.info( + 'Get additional energy via grid: %0.1f Wh', + required_recharge_energy + ) + elif required_recharge_energy == 0 and is_charging_possible: + logger.debug( + 'No additional energy required or possible price found.') + + # charge if battery capacity available and more stored energy is required + if is_charging_possible and required_recharge_energy > 0: + current_minute = calc_timestamp.minute + current_second = calc_timestamp.second + + if self.interval_minutes == 15: + current_interval_start = (current_minute // 15) * 15 + remaining_minutes = (current_interval_start + 15 + - current_minute - current_second / 60) + else: # 60 minutes + remaining_minutes = 60 - current_minute - current_second / 60 + + remaining_time = remaining_minutes / 60 + remaining_time = max(remaining_time, MIN_REMAINING_TIME_HOURS) + + charge_rate = required_recharge_energy / remaining_time + charge_rate = self.common.calculate_charge_rate(charge_rate) + + inverter_control_settings.charge_from_grid = True + inverter_control_settings.charge_rate = charge_rate + else: + # keep current charge level. recharge if solar surplus available + inverter_control_settings.allow_discharge = False + + # ----- Peak Shaving Post-Processing ----- # + if self.calculation_parameters.peak_shaving_enabled: + inverter_control_settings = self._apply_peak_shaving( + inverter_control_settings, calc_input, calc_timestamp) + + return inverter_control_settings + + # ------------------------------------------------------------------ # + # Peak Shaving # + # ------------------------------------------------------------------ # + + def _apply_peak_shaving(self, settings: InverterControlSettings, + calc_input: CalculationInput, + calc_timestamp: datetime.datetime + ) -> InverterControlSettings: + """Limit PV charge rate to spread battery charging until target hour. + + Skipped when: + - Past the target hour (allow_full_battery_after) + - Battery is in always_allow_discharge region (high SOC) + - Force charge from grid is active (MODE -1) + - No production right now (nighttime) + + Note: EVCC checks (charging, connected+pv mode) are handled in + core.py, not here. + """ + # No production right now: skip calculation (avoid unnecessary work at night) + if calc_input.production[0] <= 0: + return settings + + # After target hour: no limit + if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: + return settings + + # In always_allow_discharge region: skip peak shaving + if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): + logger.debug('[PeakShaving] Skipped: battery in always_allow_discharge region') + return settings + + # Force charge takes priority over peak shaving + if settings.charge_from_grid: + logger.warning('[PeakShaving] Skipped: force_charge (MODE -1) active, ' + 'grid charging takes priority') + return settings + + charge_limit = self._calculate_peak_shaving_charge_limit( + calc_input, calc_timestamp) + + if charge_limit < 0: + logger.debug('[PeakShaving] Evaluated: no limit needed (surplus within capacity)') + return settings + + if charge_limit >= 0: + # Apply PV charge rate limit + if settings.limit_battery_charge_rate < 0: + # No existing limit — apply peak shaving limit + settings.limit_battery_charge_rate = charge_limit + else: + # Keep the more restrictive limit + settings.limit_battery_charge_rate = min( + settings.limit_battery_charge_rate, charge_limit) + + # Ensure discharge is allowed alongside the charge limit (MODE 8) + settings.allow_discharge = True + + logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', + settings.limit_battery_charge_rate, + self.calculation_parameters.peak_shaving_allow_full_after) + + return settings + + def _calculate_peak_shaving_charge_limit(self, calc_input: CalculationInput, + calc_timestamp: datetime.datetime) -> int: + """Calculate PV charge rate limit to fill battery by target hour. + + Returns: + int: charge rate limit in W, or -1 if no limit needed. + """ + slot_start = calc_timestamp.replace( + minute=(calc_timestamp.minute // self.interval_minutes) * self.interval_minutes, + second=0, microsecond=0 + ) + target_time = calc_timestamp.replace( + hour=self.calculation_parameters.peak_shaving_allow_full_after, + minute=0, second=0, microsecond=0 + ) + + if target_time <= slot_start: + return -1 # Past target hour, no limit + + slots_remaining = int( + (target_time - slot_start).total_seconds() / (self.interval_minutes * 60) + ) + slots_remaining = min(slots_remaining, len(calc_input.production)) + + if slots_remaining <= 0: + return -1 + + # Calculate PV surplus per slot (only count positive surplus) + pv_surplus = (calc_input.production[:slots_remaining] + - calc_input.consumption[:slots_remaining]) + pv_surplus = np.clip(pv_surplus, 0, None) # Only positive surplus counts + + # Sum expected PV surplus energy (Wh) over remaining slots + interval_hours = self.interval_minutes / 60.0 + expected_surplus_wh = float(np.sum(pv_surplus)) * interval_hours + + free_capacity = calc_input.free_capacity + + if expected_surplus_wh <= free_capacity: + return -1 # PV surplus won't fill battery early, no limit needed + + if free_capacity <= 0: + return 0 # Battery is full, block PV charging + + # Spread charging evenly across remaining slots + wh_per_slot = free_capacity / slots_remaining + charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + + return int(charge_rate_w) + + # ------------------------------------------------------------------ # + # Discharge evaluation (same as DefaultLogic) # + # ------------------------------------------------------------------ # + + def _is_discharge_allowed(self, calc_input: CalculationInput, + net_consumption: np.ndarray, + prices: dict, + calc_timestamp: Optional[datetime.datetime] = None) -> bool: + """Evaluate if the battery is allowed to discharge. + + - Check if battery is above always_allow_discharge_limit + - Calculate required energy to shift toward high price hours + """ + if calc_timestamp is None: + calc_timestamp = datetime.datetime.now().astimezone(self.timezone) + + if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): + logger.info( + "[Rule] Discharge allowed due to always_allow_discharge_limit") + return True + + current_price = prices[0] + + min_dynamic_price_difference = self._calculate_min_dynamic_price_difference( + current_price) + + self.calculation_output.min_dynamic_price_difference = min_dynamic_price_difference + + max_slots = len(net_consumption) + # relevant time range : until next recharge possibility + for slot in range(1, max_slots): + future_price = prices[slot] + if future_price <= current_price - min_dynamic_price_difference: + max_slots = slot + logger.debug( + "[Rule] Recharge possible in %d slots, limiting evaluation window.", + slot) + logger.debug( + "[Rule] Future price: %.3f < Current price: %.3f - dyn_price_diff. %.3f ", + future_price, + current_price, + min_dynamic_price_difference + ) + break + + slot_start = calc_timestamp.replace( + minute=(calc_timestamp.minute // self.interval_minutes) * self.interval_minutes, + second=0, + microsecond=0 + ) + last_time = (slot_start + datetime.timedelta( + minutes=max_slots * self.interval_minutes + )).astimezone(self.timezone).strftime("%H:%M") + + logger.debug( + 'Evaluating next %d slots until %s', + max_slots, + last_time + ) + # distribute remaining energy + consumption = np.array(net_consumption) + consumption[consumption < 0] = 0 + + production = -np.array(net_consumption) + production[production < 0] = 0 + + # get slots with higher price + higher_price_slots = [] + for slot in range(max_slots): + future_price = prices[slot] + if future_price > current_price: + higher_price_slots.append(slot) + + higher_price_slots.sort() + higher_price_slots.reverse() + + reserved_storage = 0 + for higher_price_slot in higher_price_slots: + if consumption[higher_price_slot] == 0: + continue + required_energy = consumption[higher_price_slot] + + # correct reserved_storage with potential production + # start with latest slot + for slot in list(range(higher_price_slot))[::-1]: + if production[slot] == 0: + continue + if production[slot] >= required_energy: + production[slot] -= required_energy + required_energy = 0 + break + else: + required_energy -= production[slot] + production[slot] = 0 + # add_remaining required_energy to reserved_storage + reserved_storage += required_energy + + self.calculation_output.reserved_energy = reserved_storage + + if len(higher_price_slots) > 0: + logger.debug("[Rule] Reserved Energy will be used in the next slots: %s", + higher_price_slots[::-1]) + logger.debug( + "[Rule] Reserved Energy: %0.1f Wh. Usable in Battery: %0.1f Wh", + reserved_storage, + calc_input.stored_usable_energy + ) + else: + logger.debug("[Rule] No reserved energy required, because no " + "'high price' slots in evaluation window.") + + if calc_input.stored_usable_energy > reserved_storage: + logger.debug( + "[Rule] Discharge allowed. Stored usable energy %0.1f Wh >" + " Reserved energy %0.1f Wh", + calc_input.stored_usable_energy, + reserved_storage + ) + return True + + logger.debug( + "[Rule] Discharge forbidden. Stored usable energy %0.1f Wh <= Reserved energy %0.1f Wh", + calc_input.stored_usable_energy, + reserved_storage + ) + + return False + + # ------------------------------------------------------------------ # + # Recharge energy calculation (same as DefaultLogic) # + # ------------------------------------------------------------------ # + + def _get_required_recharge_energy(self, calc_input: CalculationInput, + net_consumption: list, + prices: dict) -> float: + """Calculate the required energy to shift toward high price slots. + + If a recharge price window is detected, the energy required to + recharge the battery to the next high price slots is calculated. + + Returns: + float: Energy in Wh + """ + current_price = prices[0] + max_slot = len(net_consumption) + consumption = np.array(net_consumption) + consumption[consumption < 0] = 0 + + production = -np.array(net_consumption) + production[production < 0] = 0 + min_price_difference = self.calculation_parameters.min_price_difference + min_dynamic_price_difference = self._calculate_min_dynamic_price_difference( + current_price) + + # evaluation period until price is first time lower then current price + for slot in range(1, max_slot): + future_price = prices[slot] + found_lower_price = False + # Soften the price difference to avoid too early charging + if self.soften_price_difference_on_charging: + modified_price = current_price - min_price_difference / \ + self.soften_price_difference_on_charging_factor + found_lower_price = future_price <= modified_price + else: + found_lower_price = future_price <= current_price + + if found_lower_price: + max_slot = slot + break + + logger.debug( + "[Rule] Evaluation window for recharge energy until slot %d with price %0.3f", + max_slot - 1, + prices[max_slot - 1] + ) + + # get high price slots + high_price_slots = [] + for slot in range(max_slot): + future_price = prices[slot] + if future_price > current_price + min_dynamic_price_difference: + high_price_slots.append(slot) + + # start with nearest slot + high_price_slots.sort() + required_energy = 0.0 + for high_price_slot in high_price_slots: + energy_to_shift = consumption[high_price_slot] + + # correct energy to shift with potential production + for slot in range(1, high_price_slot): + if production[slot] == 0: + continue + if production[slot] >= energy_to_shift: + production[slot] -= energy_to_shift + energy_to_shift = 0 + else: + energy_to_shift -= production[slot] + production[slot] = 0 + required_energy += energy_to_shift + + if required_energy > 0.0: + logger.debug("[Rule] Required Energy: %0.1f Wh is based on next 'high price' slots %s", + required_energy, + high_price_slots) + recharge_energy = required_energy - calc_input.stored_usable_energy + logger.debug("[Rule] Stored usable Energy: %0.1f , Recharge Energy: %0.1f Wh", + calc_input.stored_usable_energy, + recharge_energy) + else: + logger.debug( + "[Rule] No additional energy required, because stored energy is sufficient." + ) + recharge_energy = 0.0 + self.calculation_output.required_recharge_energy = recharge_energy + return recharge_energy + + free_capacity = calc_input.free_capacity + + if recharge_energy > free_capacity: + recharge_energy = free_capacity + logger.debug( + "[Rule] Recharge limited by free capacity: %0.1f Wh", recharge_energy) + + if not self.common.is_charging_above_minimum(recharge_energy): + recharge_energy = 0.0 + else: + recharge_energy = recharge_energy + self.common.min_charge_energy + + self.calculation_output.required_recharge_energy = recharge_energy + return recharge_energy + + def _calculate_min_dynamic_price_difference(self, price: float) -> float: + """ Calculate the dynamic limit for the current price """ + return round( + max(self.calculation_parameters.min_price_difference, + self.calculation_parameters.min_price_difference_rel * abs(price)), + self.round_price_digits + ) diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index 67e7749d..422f1bea 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -444,6 +444,38 @@ def publish_production_offset(self, production_offset: float) -> None: f'{production_offset:.3f}' ) + def publish_peak_shaving_enabled(self, enabled: bool) -> None: + """ Publish peak shaving enabled status to MQTT + /peak_shaving/enabled + """ + if self.client.is_connected(): + self.client.publish( + self.base_topic + '/peak_shaving/enabled', + str(enabled).lower(), + retain=True + ) + + def publish_peak_shaving_allow_full_after(self, hour: int) -> None: + """ Publish peak shaving target hour to MQTT + /peak_shaving/allow_full_battery_after + """ + if self.client.is_connected(): + self.client.publish( + self.base_topic + '/peak_shaving/allow_full_battery_after', + str(hour), + retain=True + ) + + def publish_peak_shaving_charge_limit(self, charge_limit: int) -> None: + """ Publish current peak shaving charge limit to MQTT + /peak_shaving/charge_limit + """ + if self.client.is_connected(): + self.client.publish( + self.base_topic + '/peak_shaving/charge_limit', + str(charge_limit) + ) + # For depended APIs like the Fronius Inverter classes, which is not # directly batcontrol. def generic_publish(self, topic: str, value: str) -> None: @@ -540,6 +572,41 @@ def send_mqtt_discovery_messages(self) -> None: step_value=0.01, initial_value=1.0) + # peak shaving + self.publish_mqtt_discovery_message( + "Peak Shaving Enabled", + "batcontrol_peak_shaving_enabled", + "switch", + None, + None, + self.base_topic + "/peak_shaving/enabled", + self.base_topic + "/peak_shaving/enabled/set", + entity_category="config", + value_template="{% if value == 'true' %}ON{% else %}OFF{% endif %}", + command_template="{% if value == 'ON' %}true{% else %}false{% endif %}") + + self.publish_mqtt_discovery_message( + "Peak Shaving Allow Full After", + "batcontrol_peak_shaving_allow_full_after", + "number", + None, + "h", + self.base_topic + "/peak_shaving/allow_full_battery_after", + self.base_topic + "/peak_shaving/allow_full_battery_after/set", + entity_category="config", + min_value=0, + max_value=23, + step_value=1, + initial_value=14) + + self.publish_mqtt_discovery_message( + "Peak Shaving Charge Limit", + "batcontrol_peak_shaving_charge_limit", + "sensor", + "power", + "W", + self.base_topic + "/peak_shaving/charge_limit") + # sensors self.publish_mqtt_discovery_message( "Discharge Blocked", diff --git a/tests/batcontrol/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py new file mode 100644 index 00000000..af0e3505 --- /dev/null +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -0,0 +1,464 @@ +"""Tests for the NextLogic peak shaving feature. + +Tests cover: +- _calculate_peak_shaving_charge_limit algorithm +- _apply_peak_shaving decision logic +- Full calculate() integration with peak shaving +- Logic factory type selection +""" +import logging +import unittest +import datetime +import numpy as np + +from batcontrol.logic.next import NextLogic +from batcontrol.logic.default import DefaultLogic +from batcontrol.logic.logic import Logic +from batcontrol.logic.logic_interface import ( + CalculationInput, + CalculationParameters, + InverterControlSettings, +) +from batcontrol.logic.common import CommonLogic + +logging.basicConfig(level=logging.DEBUG) + + +class TestPeakShavingAlgorithm(unittest.TestCase): + """Tests for _calculate_peak_shaving_charge_limit.""" + + def setUp(self): + self.max_capacity = 10000 # 10 kWh + self.logic = NextLogic(timezone=datetime.timezone.utc, + interval_minutes=60) + self.common = CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=self.max_capacity, + ) + self.params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + ) + self.logic.set_calculation_parameters(self.params) + + def _make_input(self, production, consumption, stored_energy, + free_capacity): + """Helper to build a CalculationInput.""" + prices = np.zeros(len(production)) + return CalculationInput( + production=np.array(production, dtype=float), + consumption=np.array(consumption, dtype=float), + prices=prices, + stored_energy=stored_energy, + stored_usable_energy=stored_energy - self.max_capacity * 0.05, + free_capacity=free_capacity, + ) + + def test_high_surplus_small_free_capacity(self): + """High PV surplus, small free capacity → low charge limit.""" + # 8 hours until 14:00 starting from 06:00 + # 5000 W PV per slot, 500 W consumption → 4500 W surplus per slot + # 8 slots * 4500 Wh = 36000 Wh surplus total + # free_capacity = 2000 Wh + # charge limit = 2000 / 8 = 250 Wh/slot → 250 W (60 min intervals) + production = [5000] * 8 + [0] * 4 + consumption = [500] * 8 + [0] * 4 + calc_input = self._make_input(production, consumption, + stored_energy=8000, + free_capacity=2000) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, 250) + + def test_low_surplus_large_free_capacity(self): + """Low PV surplus, large free capacity → no limit (-1).""" + # 1000 W PV, 800 W consumption → 200 W surplus + # 8 slots * 200 Wh = 1600 Wh surplus + # free_capacity = 5000 Wh → surplus < free → no limit + production = [1000] * 8 + [0] * 4 + consumption = [800] * 8 + [0] * 4 + calc_input = self._make_input(production, consumption, + stored_energy=5000, + free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, -1) + + def test_surplus_equals_free_capacity(self): + """PV surplus exactly matches free capacity → no limit (-1).""" + production = [3000] * 8 + [0] * 4 + consumption = [1000] * 8 + [0] * 4 + # surplus per slot = 2000 W, 8 slots = 16000 Wh + calc_input = self._make_input(production, consumption, + stored_energy=0, + free_capacity=16000) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, -1) + + def test_battery_full(self): + """Battery full (free_capacity = 0) → charge limit = 0.""" + production = [5000] * 8 + [0] * 4 + consumption = [500] * 8 + [0] * 4 + calc_input = self._make_input(production, consumption, + stored_energy=10000, + free_capacity=0) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, 0) + + def test_past_target_hour(self): + """Past target hour → no limit (-1).""" + production = [5000] * 8 + consumption = [500] * 8 + calc_input = self._make_input(production, consumption, + stored_energy=5000, + free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 15, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, -1) + + def test_one_slot_remaining(self): + """1 slot remaining → rate for that single slot.""" + # Target is 14:00, current time is 13:00 → 1 slot + # PV surplus: 5000 - 500 = 4500 W → 4500 Wh > free_cap 1000 + # limit = 1000 / 1 = 1000 Wh/slot → 1000 W + production = [5000] * 2 + consumption = [500] * 2 + calc_input = self._make_input(production, consumption, + stored_energy=9000, + free_capacity=1000) + ts = datetime.datetime(2025, 6, 20, 13, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, 1000) + + def test_consumption_reduces_surplus(self): + """High consumption reduces effective PV surplus.""" + # 3000 W PV, 2000 W consumption → 1000 W surplus + # 8 slots * 1000 Wh = 8000 Wh surplus + # free_capacity = 4000 Wh → surplus > free + # limit = 4000 / 8 = 500 Wh/slot → 500 W + production = [3000] * 8 + [0] * 4 + consumption = [2000] * 8 + [0] * 4 + calc_input = self._make_input(production, consumption, + stored_energy=6000, + free_capacity=4000) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, 500) + + def test_15min_intervals(self): + """Test with 15-minute intervals.""" + logic_15 = NextLogic(timezone=datetime.timezone.utc, + interval_minutes=15) + logic_15.set_calculation_parameters(self.params) + + # Target 14:00, current 13:00 → 4 slots of 15 min + # surplus = 4000 W per slot, interval_hours = 0.25 + # surplus Wh per slot = 4000 * 0.25 = 1000 Wh + # total surplus = 4 * 1000 = 4000 Wh + # free_capacity = 1000 Wh → surplus > free + # wh_per_slot = 1000 / 4 = 250 Wh + # charge_rate_w = 250 / 0.25 = 1000 W + production = [4500] * 4 + consumption = [500] * 4 + calc_input = self._make_input(production, consumption, + stored_energy=9000, + free_capacity=1000) + ts = datetime.datetime(2025, 6, 20, 13, 0, 0, + tzinfo=datetime.timezone.utc) + limit = logic_15._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, 1000) + + +class TestPeakShavingDecision(unittest.TestCase): + """Tests for _apply_peak_shaving decision logic.""" + + def setUp(self): + self.max_capacity = 10000 + self.logic = NextLogic(timezone=datetime.timezone.utc, + interval_minutes=60) + self.common = CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=self.max_capacity, + ) + self.params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + ) + self.logic.set_calculation_parameters(self.params) + + def _make_settings(self, allow_discharge=True, charge_from_grid=False, + charge_rate=0, limit_battery_charge_rate=-1): + return InverterControlSettings( + allow_discharge=allow_discharge, + charge_from_grid=charge_from_grid, + charge_rate=charge_rate, + limit_battery_charge_rate=limit_battery_charge_rate, + ) + + def _make_input(self, production, consumption, stored_energy, + free_capacity): + prices = np.zeros(len(production)) + return CalculationInput( + production=np.array(production, dtype=float), + consumption=np.array(consumption, dtype=float), + prices=prices, + stored_energy=stored_energy, + stored_usable_energy=stored_energy - self.max_capacity * 0.05, + free_capacity=free_capacity, + ) + + def test_nighttime_no_production(self): + """No production (nighttime) → peak shaving skipped.""" + settings = self._make_settings() + calc_input = self._make_input( + [0, 0, 0, 0], [500, 500, 500, 500], + stored_energy=5000, free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 2, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + + def test_after_target_hour(self): + """After target hour → no change.""" + settings = self._make_settings() + calc_input = self._make_input( + [5000, 5000], [500, 500], + stored_energy=5000, free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 15, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + + def test_force_charge_takes_priority(self): + """Force charge (MODE -1) → peak shaving skipped.""" + settings = self._make_settings( + allow_discharge=False, charge_from_grid=True, charge_rate=3000) + calc_input = self._make_input( + [5000] * 8, [500] * 8, + stored_energy=5000, free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertTrue(result.charge_from_grid) + self.assertEqual(result.limit_battery_charge_rate, -1) + + def test_always_allow_discharge_region(self): + """Battery in always_allow_discharge region → skip peak shaving.""" + settings = self._make_settings() + # stored_energy=9500 > 10000 * 0.9 = 9000 + calc_input = self._make_input( + [5000] * 8, [500] * 8, + stored_energy=9500, free_capacity=500) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + + def test_peak_shaving_applies_limit(self): + """Before target hour, limit calculated → limit set.""" + settings = self._make_settings() + # 6 slots (6..14), 5000W PV, 500W consumption → 4500W surplus + # surplus Wh = 6 * 4500 = 27000 > free 3000 + # limit = 3000 / 6 = 500 W + calc_input = self._make_input( + [5000] * 8, [500] * 8, + stored_energy=7000, free_capacity=3000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, 500) + self.assertTrue(result.allow_discharge) + + def test_existing_tighter_limit_kept(self): + """Existing limit is tighter → keep existing.""" + settings = self._make_settings(limit_battery_charge_rate=200) + calc_input = self._make_input( + [5000] * 8, [500] * 8, + stored_energy=7000, free_capacity=3000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, 200) + + def test_peak_shaving_limit_tighter(self): + """Peak shaving limit is tighter than existing → peak shaving limit applied.""" + settings = self._make_settings(limit_battery_charge_rate=5000) + calc_input = self._make_input( + [5000] * 8, [500] * 8, + stored_energy=7000, free_capacity=3000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, 500) + + +class TestPeakShavingDisabled(unittest.TestCase): + """Test that peak shaving does nothing when disabled.""" + + def setUp(self): + self.max_capacity = 10000 + self.logic = NextLogic(timezone=datetime.timezone.utc, + interval_minutes=60) + self.common = CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=self.max_capacity, + ) + self.params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=False, + peak_shaving_allow_full_after=14, + ) + self.logic.set_calculation_parameters(self.params) + + def test_disabled_no_limit(self): + """peak_shaving_enabled=False → no change to settings.""" + production = np.array([5000] * 8, dtype=float) + consumption = np.array([500] * 8, dtype=float) + prices = np.zeros(8) + calc_input = CalculationInput( + production=production, + consumption=consumption, + prices=prices, + stored_energy=5000, + stored_usable_energy=4500, + free_capacity=5000, + ) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + self.logic.calculate(calc_input, ts) + result = self.logic.get_inverter_control_settings() + # With disabled peak shaving, no limit should be applied + self.assertEqual(result.limit_battery_charge_rate, -1) + + +class TestLogicFactory(unittest.TestCase): + """Test logic factory type selection.""" + + def setUp(self): + CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=10000, + ) + + def test_default_type(self): + """type: default → DefaultLogic.""" + config = {'battery_control': {'type': 'default'}} + logic = Logic.create_logic(config, datetime.timezone.utc) + self.assertIsInstance(logic, DefaultLogic) + + def test_next_type(self): + """type: next → NextLogic.""" + config = {'battery_control': {'type': 'next'}} + logic = Logic.create_logic(config, datetime.timezone.utc) + self.assertIsInstance(logic, NextLogic) + + def test_missing_type_defaults_to_default(self): + """No type key → DefaultLogic.""" + config = {} + logic = Logic.create_logic(config, datetime.timezone.utc) + self.assertIsInstance(logic, DefaultLogic) + + def test_unknown_type_raises(self): + """Unknown type → RuntimeError.""" + config = {'battery_control': {'type': 'unknown'}} + with self.assertRaises(RuntimeError): + Logic.create_logic(config, datetime.timezone.utc) + + def test_expert_tuning_applied_to_next(self): + """Expert tuning attributes applied to NextLogic.""" + config = { + 'battery_control': {'type': 'next'}, + 'battery_control_expert': { + 'round_price_digits': 2, + 'charge_rate_multiplier': 1.5, + }, + } + logic = Logic.create_logic(config, datetime.timezone.utc) + self.assertIsInstance(logic, NextLogic) + self.assertEqual(logic.round_price_digits, 2) + + +class TestCalculationParametersPeakShaving(unittest.TestCase): + """Test CalculationParameters peak shaving fields.""" + + def test_defaults(self): + """Without peak shaving args → defaults.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + ) + self.assertFalse(params.peak_shaving_enabled) + self.assertEqual(params.peak_shaving_allow_full_after, 14) + + def test_explicit_values(self): + """With explicit peak shaving args → stored.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=16, + ) + self.assertTrue(params.peak_shaving_enabled) + self.assertEqual(params.peak_shaving_allow_full_after, 16) + + def test_invalid_allow_full_after_too_high(self): + """allow_full_battery_after > 23 raises ValueError.""" + with self.assertRaises(ValueError): + CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_allow_full_after=25, + ) + + def test_invalid_allow_full_after_negative(self): + """allow_full_battery_after < 0 raises ValueError.""" + with self.assertRaises(ValueError): + CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_allow_full_after=-1, + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/batcontrol/test_evcc_mode.py b/tests/batcontrol/test_evcc_mode.py new file mode 100644 index 00000000..3ea526bc --- /dev/null +++ b/tests/batcontrol/test_evcc_mode.py @@ -0,0 +1,203 @@ +"""Tests for EVCC mode and connected topic handling for peak shaving. + +Tests cover: +- Topic derivation from loadpoint /charging topics +- handle_mode_message parsing +- handle_connected_message parsing +- evcc_ev_expects_pv_surplus property logic +- Multi-loadpoint scenarios +""" +import logging +import unittest +from unittest.mock import MagicMock, patch + +logging.basicConfig(level=logging.DEBUG) + + +class MockMessage: + """Minimal MQTT message mock.""" + + def __init__(self, topic: str, payload: bytes): + self.topic = topic + self.payload = payload + + +class TestEvccModeConnected(unittest.TestCase): + """Tests for mode/connected EVCC topic handling.""" + + def _create_evcc_api(self, loadpoint_topics=None): + """Create an EvccApi instance with mocked MQTT client.""" + if loadpoint_topics is None: + loadpoint_topics = ['evcc/loadpoints/1/charging'] + + config = { + 'broker': 'localhost', + 'port': 1883, + 'status_topic': 'evcc/status', + 'loadpoint_topic': loadpoint_topics, + 'block_battery_while_charging': True, + 'tls': False, + } + + with patch('batcontrol.evcc_api.mqtt.Client') as mock_mqtt: + mock_client = MagicMock() + mock_mqtt.return_value = mock_client + from batcontrol.evcc_api import EvccApi + api = EvccApi(config) + + return api + + # ---- Topic derivation ---- + + def test_topic_derivation_single(self): + """charging topic → mode and connected topics derived.""" + api = self._create_evcc_api(['evcc/loadpoints/1/charging']) + self.assertIn('evcc/loadpoints/1/mode', api.list_topics_mode) + self.assertIn('evcc/loadpoints/1/connected', api.list_topics_connected) + + def test_topic_derivation_multiple(self): + """Multiple loadpoints → all mode/connected topics derived.""" + api = self._create_evcc_api([ + 'evcc/loadpoints/1/charging', + 'evcc/loadpoints/2/charging', + ]) + self.assertEqual(len(api.list_topics_mode), 2) + self.assertEqual(len(api.list_topics_connected), 2) + self.assertIn('evcc/loadpoints/2/mode', api.list_topics_mode) + self.assertIn('evcc/loadpoints/2/connected', + api.list_topics_connected) + + def test_non_standard_topic_warning(self): + """Topic not ending in /charging → warning, no mode/connected sub.""" + with self.assertLogs('batcontrol.evcc_api', level='WARNING') as cm: + api = self._create_evcc_api(['evcc/loadpoints/1/status']) + self.assertEqual(len(api.list_topics_mode), 0) + self.assertEqual(len(api.list_topics_connected), 0) + self.assertTrue(any('does not end in /charging' in msg + for msg in cm.output)) + + # ---- handle_mode_message ---- + + def test_handle_mode_message_pv(self): + """Parse mode 'pv' correctly.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/mode', b'pv') + api.handle_mode_message(msg) + self.assertEqual( + api.evcc_loadpoint_mode['evcc/loadpoints/1'], 'pv') + + def test_handle_mode_message_now(self): + """Parse mode 'now' correctly.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/mode', b'now') + api.handle_mode_message(msg) + self.assertEqual( + api.evcc_loadpoint_mode['evcc/loadpoints/1'], 'now') + + def test_handle_mode_message_case_insensitive(self): + """Mode is converted to lowercase.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/mode', b'PV') + api.handle_mode_message(msg) + self.assertEqual( + api.evcc_loadpoint_mode['evcc/loadpoints/1'], 'pv') + + # ---- handle_connected_message ---- + + def test_handle_connected_true(self): + """Parse connected=true correctly.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/connected', b'true') + api.handle_connected_message(msg) + self.assertTrue( + api.evcc_loadpoint_connected['evcc/loadpoints/1']) + + def test_handle_connected_false(self): + """Parse connected=false correctly.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/connected', b'false') + api.handle_connected_message(msg) + self.assertFalse( + api.evcc_loadpoint_connected['evcc/loadpoints/1']) + + def test_handle_connected_case_insensitive(self): + """Connected parsing is case-insensitive.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/connected', b'True') + api.handle_connected_message(msg) + self.assertTrue( + api.evcc_loadpoint_connected['evcc/loadpoints/1']) + + # ---- evcc_ev_expects_pv_surplus ---- + + def test_expects_pv_surplus_connected_pv_mode(self): + """connected=true + mode=pv → True.""" + api = self._create_evcc_api() + api.evcc_loadpoint_connected['evcc/loadpoints/1'] = True + api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'pv' + self.assertTrue(api.evcc_ev_expects_pv_surplus) + + def test_expects_pv_surplus_connected_now_mode(self): + """connected=true + mode=now → False.""" + api = self._create_evcc_api() + api.evcc_loadpoint_connected['evcc/loadpoints/1'] = True + api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'now' + self.assertFalse(api.evcc_ev_expects_pv_surplus) + + def test_expects_pv_surplus_disconnected_pv_mode(self): + """connected=false + mode=pv → False.""" + api = self._create_evcc_api() + api.evcc_loadpoint_connected['evcc/loadpoints/1'] = False + api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'pv' + self.assertFalse(api.evcc_ev_expects_pv_surplus) + + def test_expects_pv_surplus_no_data(self): + """No data received → False.""" + api = self._create_evcc_api() + self.assertFalse(api.evcc_ev_expects_pv_surplus) + + def test_multi_loadpoint_one_pv(self): + """Multi-loadpoint: one connected+pv is enough → True.""" + api = self._create_evcc_api([ + 'evcc/loadpoints/1/charging', + 'evcc/loadpoints/2/charging', + ]) + api.evcc_loadpoint_connected['evcc/loadpoints/1'] = False + api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'off' + api.evcc_loadpoint_connected['evcc/loadpoints/2'] = True + api.evcc_loadpoint_mode['evcc/loadpoints/2'] = 'pv' + self.assertTrue(api.evcc_ev_expects_pv_surplus) + + def test_mode_change_pv_to_now(self): + """Mode change from pv to now → evcc_ev_expects_pv_surplus changes to False.""" + api = self._create_evcc_api() + api.evcc_loadpoint_connected['evcc/loadpoints/1'] = True + api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'pv' + self.assertTrue(api.evcc_ev_expects_pv_surplus) + + # Mode changes + msg = MockMessage('evcc/loadpoints/1/mode', b'now') + api.handle_mode_message(msg) + self.assertFalse(api.evcc_ev_expects_pv_surplus) + + # ---- Message dispatching ---- + + def test_dispatch_mode_message(self): + """_handle_message dispatches mode topic correctly.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/mode', b'pv') + api._handle_message(None, None, msg) + self.assertEqual( + api.evcc_loadpoint_mode['evcc/loadpoints/1'], 'pv') + + def test_dispatch_connected_message(self): + """_handle_message dispatches connected topic correctly.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/connected', b'true') + api._handle_message(None, None, msg) + self.assertTrue( + api.evcc_loadpoint_connected['evcc/loadpoints/1']) + + +if __name__ == '__main__': + unittest.main() From 782b3794874cf6162ef657dd6d2fa715e6cccf8f Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 15:26:46 +0100 Subject: [PATCH 07/35] iteration 2 --- PLAN.md | 71 +++++++- docs/WIKI_peak_shaving.md | 7 +- src/batcontrol/core.py | 3 +- src/batcontrol/logic/next.py | 18 +- tests/batcontrol/logic/test_peak_shaving.py | 12 ++ tests/batcontrol/test_core.py | 189 ++++++++++++++++++++ 6 files changed, 286 insertions(+), 14 deletions(-) diff --git a/PLAN.md b/PLAN.md index c0ddbb80..6c74ae0d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -126,6 +126,13 @@ def evcc_ev_expects_pv_surplus(self) -> bool: **`shutdown`:** Unsubscribe from mode and connected topics. +**EVCC offline reset:** When EVCC goes offline (status message received), mode and connected state are reset to prevent stale values: +```python +for root in list(self.evcc_loadpoint_mode.keys()): + self.evcc_loadpoint_mode[root] = None + self.evcc_loadpoint_connected[root] = False +``` + ### 2.4 Backward Compatibility - Topics not ending in `/charging`: warning logged, no mode/connected sub, existing behavior unchanged @@ -254,14 +261,23 @@ return inverter_control_settings **`_apply_peak_shaving()`:** +Peak shaving uses MODE 8 (`limit_battery_charge_rate` with `allow_discharge=True`). It is only applied when the main logic already allows discharge — meaning no upcoming high-price slots require preserving battery energy. This is the **price-based skip condition**: when the main logic set `allow_discharge=False`, the battery is being held for profitable future slots, and PV charging should not be throttled. + ```python def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): """Limit PV charge rate to spread battery charging until target hour. + Peak shaving uses MODE 8 (limit_battery_charge_rate with + allow_discharge=True). It is only applied when the main logic + already allows discharge — meaning no upcoming high-price slots + require preserving battery energy. + Skipped when: + - No production right now (nighttime) - Past the target hour (allow_full_battery_after) - Battery is in always_allow_discharge region (high SOC) - Force charge from grid is active (MODE -1) + - Discharge not allowed (battery preserved for high-price hours) Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. """ @@ -284,6 +300,12 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): 'grid charging takes priority') return settings + # Battery preserved for high-price hours — don't limit PV charging + if not settings.allow_discharge: + logger.debug('[PeakShaving] Skipped: discharge not allowed, ' + 'battery preserved for high-price hours') + return settings + charge_limit = self._calculate_peak_shaving_charge_limit( calc_input, calc_timestamp) @@ -297,6 +319,9 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): settings.limit_battery_charge_rate = min( settings.limit_battery_charge_rate, charge_limit) + # Note: allow_discharge is already True here (checked above). + # MODE 8 requires allow_discharge=True to work correctly. + logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', settings.limit_battery_charge_rate, self.calculation_parameters.peak_shaving_allow_full_after) @@ -319,6 +344,13 @@ class CalculationParameters: # Peak shaving parameters peak_shaving_enabled: bool = False peak_shaving_allow_full_after: int = 14 # Hour (0-23) + + def __post_init__(self): + if not 0 <= self.peak_shaving_allow_full_after <= 23: + raise ValueError( + f"peak_shaving_allow_full_after must be 0-23, " + f"got {self.peak_shaving_allow_full_after}" + ) ``` **No changes needed to `CalculationInput`** — the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. @@ -332,17 +364,29 @@ The factory gains a new type `next`: ```python @staticmethod def create_logic(config: dict, timezone) -> LogicInterface: - request_type = config.get('type', 'default').lower() + battery_control = config.get('battery_control', {}) + request_type = battery_control.get('type', 'default').lower() interval_minutes = config.get('time_resolution_minutes', 60) if request_type == 'default': logic = DefaultLogic(timezone, interval_minutes=interval_minutes) - # ... existing expert tuning ... elif request_type == 'next': logic = NextLogic(timezone, interval_minutes=interval_minutes) - # ... same expert tuning as default ... else: - raise RuntimeError(f'[Logic] Unknown logic type {config["type"]}') + raise RuntimeError(f'[Logic] Unknown logic type {request_type}') + + # Apply expert tuning attributes (shared between default and next) + if config.get('battery_control_expert', None) is not None: + battery_control_expert = config.get('battery_control_expert', {}) + attribute_list = [ + 'soften_price_difference_on_charging', + 'soften_price_difference_on_charging_factor', + 'round_price_digits', + 'charge_rate_multiplier', + ] + for attribute in attribute_list: + if attribute in battery_control_expert: + setattr(logic, attribute, battery_control_expert[attribute]) return logic ``` @@ -418,7 +462,9 @@ Home Assistant discovery: - `peak_shaving/allow_full_battery_after` → number entity (min: 0, max: 23, step: 1) - `peak_shaving/charge_limit` → sensor entity (unit: W) -QoS: 1 for all topics (consistent with existing MQTT API). +QoS: default (0) for all topics (consistent with existing MQTT API). + +`charge_limit` is only published when peak shaving is enabled, to avoid unnecessary MQTT traffic. --- @@ -439,6 +485,7 @@ QoS: 1 for all topics (consistent with existing MQTT API). - `peak_shaving_enabled = False` → no change to settings - Current production = 0 (nighttime) → peak shaving skipped - `charge_from_grid = True` → peak shaving skipped, warning logged +- `allow_discharge = False` (battery preserved for high-price hours) → peak shaving skipped - Battery in always_allow_discharge region → peak shaving skipped - Before target hour, limit calculated → `limit_battery_charge_rate` set - After target hour → no change @@ -458,7 +505,14 @@ QoS: 1 for all topics (consistent with existing MQTT API). - Multi-loadpoint: one connected+pv is enough to return True - Mode change from pv to now → `evcc_ev_expects_pv_surplus` changes to False -### 7.3 Config Tests +### 7.3 Core EVCC Guard Tests (`tests/batcontrol/test_core.py`) + +- EVCC actively charging + charge limit active → limit cleared to -1 +- EV connected in PV mode + charge limit active → limit cleared to -1 +- EVCC not charging and no PV mode → charge limit preserved +- No charge limit active (-1) + EVCC charging → no change (stays -1) + +### 7.4 Config Tests - `type: next` → creates `NextLogic` instance - `type: default` → creates `DefaultLogic` instance (unchanged) @@ -494,7 +548,8 @@ QoS: 1 for all topics (consistent with existing MQTT API). | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | | `tests/batcontrol/logic/test_peak_shaving.py` | New — algorithm + decision tests | | `tests/batcontrol/test_evcc_mode.py` | New — mode/connected topic tests | -| `docs/peak_shaving.md` | New — feature documentation | +| `tests/batcontrol/test_core.py` | Add EVCC peak shaving guard tests | +| `docs/WIKI_peak_shaving.md` | New — feature documentation | **Not modified:** `default.py` (untouched — peak shaving is in `next.py`) @@ -514,6 +569,8 @@ QoS: 1 for all topics (consistent with existing MQTT API). 6. **Algorithm uses net PV surplus:** The charge limit calculation uses `production - consumption` (positive only), not raw production. This prevents over-throttling when household consumption absorbs most of the PV. +7. **Price-based skip (discharge not allowed):** When the main logic set `allow_discharge=False`, the battery is being preserved for upcoming high-price slots. In this case peak shaving is skipped — the battery needs to charge from PV as fast as possible. This also means `_apply_peak_shaving` does not need to force `allow_discharge=True`; it only applies when discharge is already allowed. + ## 11. Known Limitations (v1) 1. **Flat charge distribution:** The charge rate limit is uniform across all slots, but PV production peaks midday. The battery may not reach exactly 100% by the target hour. Acceptable for v1. diff --git a/docs/WIKI_peak_shaving.md b/docs/WIKI_peak_shaving.md index 18483143..ee7d696e 100644 --- a/docs/WIKI_peak_shaving.md +++ b/docs/WIKI_peak_shaving.md @@ -53,7 +53,7 @@ if pv_surplus > free_capacity: charge_limit = free_capacity / slots_remaining (Wh per slot, converted to W) ``` -The charge limit is applied using **MODE 8** (`limit_battery_charge_rate`), which limits PV charging while still allowing battery discharge. +The charge limit is applied using **MODE 8** (`limit_battery_charge_rate`), which limits PV charging while still allowing battery discharge. Peak shaving only applies when discharge is already allowed by the main price-based logic. ### Skip Conditions @@ -63,8 +63,9 @@ Peak shaving is automatically skipped when: 2. **Past the target hour** — battery is allowed to be full 3. **Battery in always_allow_discharge region** — SOC is already high 4. **Grid charging active (MODE -1)** — force charge takes priority -5. **EVCC is actively charging** — EV consumes the excess PV -6. **EV connected in PV mode** — EVCC will absorb PV surplus +5. **Discharge not allowed** — battery is being preserved for upcoming high-price hours; PV charging should not be throttled so the battery can charge as fast as possible +6. **EVCC is actively charging** — EV consumes the excess PV +7. **EV connected in PV mode** — EVCC will absorb PV surplus ### EVCC Interaction diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 5a3012c0..b87203f5 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -569,7 +569,8 @@ def run(self): inverter_settings.limit_battery_charge_rate = -1 # Publish peak shaving charge limit (after EVCC guard may have cleared it) - if self.mqtt_api is not None: + peak_shaving_enabled = peak_shaving_config.get('enabled', False) + if self.mqtt_api is not None and peak_shaving_enabled: self.mqtt_api.publish_peak_shaving_charge_limit( inverter_settings.limit_battery_charge_rate) diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index f5478a50..cb410cd6 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -212,11 +212,17 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, ) -> InverterControlSettings: """Limit PV charge rate to spread battery charging until target hour. + Peak shaving uses MODE 8 (limit_battery_charge_rate with + allow_discharge=True). It is only applied when the main logic + already allows discharge — meaning no upcoming high-price slots + require preserving battery energy. + Skipped when: + - No production right now (nighttime) - Past the target hour (allow_full_battery_after) - Battery is in always_allow_discharge region (high SOC) - Force charge from grid is active (MODE -1) - - No production right now (nighttime) + - Discharge not allowed (battery preserved for high-price hours) Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. @@ -240,6 +246,12 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, 'grid charging takes priority') return settings + # Battery preserved for high-price hours — don't limit PV charging + if not settings.allow_discharge: + logger.debug('[PeakShaving] Skipped: discharge not allowed, ' + 'battery preserved for high-price hours') + return settings + charge_limit = self._calculate_peak_shaving_charge_limit( calc_input, calc_timestamp) @@ -257,8 +269,8 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, settings.limit_battery_charge_rate = min( settings.limit_battery_charge_rate, charge_limit) - # Ensure discharge is allowed alongside the charge limit (MODE 8) - settings.allow_discharge = True + # Note: allow_discharge is already True here (checked above). + # MODE 8 requires allow_discharge=True to work correctly. logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', settings.limit_battery_charge_rate, diff --git a/tests/batcontrol/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py index af0e3505..c035562d 100644 --- a/tests/batcontrol/logic/test_peak_shaving.py +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -318,6 +318,18 @@ def test_peak_shaving_limit_tighter(self): result = self.logic._apply_peak_shaving(settings, calc_input, ts) self.assertEqual(result.limit_battery_charge_rate, 500) + def test_discharge_not_allowed_skips_peak_shaving(self): + """Discharge not allowed (battery preserved for high-price hours) → skip.""" + settings = self._make_settings(allow_discharge=False) + calc_input = self._make_input( + [5000] * 8, [500] * 8, + stored_energy=5000, free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + self.assertFalse(result.allow_discharge) + class TestPeakShavingDisabled(unittest.TestCase): """Test that peak shaving does nothing when disabled.""" diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 4482b8b4..32db4b5d 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -287,5 +287,194 @@ def test_api_set_limit_applies_immediately_in_mode_8( mock_inverter.set_mode_limit_battery_charge.assert_called_once_with(2000) +class TestEvccPeakShavingGuard: + """Test EVCC peak shaving guard in core.py run loop.""" + + @pytest.fixture + def mock_config(self): + """Provide a minimal config for testing.""" + return { + 'timezone': 'Europe/Berlin', + 'time_resolution_minutes': 60, + 'inverter': { + 'type': 'dummy', + 'max_grid_charge_rate': 5000, + 'max_pv_charge_rate': 3000, + 'min_pv_charge_rate': 100 + }, + 'utility': { + 'type': 'tibber', + 'token': 'test_token' + }, + 'pvinstallations': [], + 'consumption_forecast': { + 'type': 'simple', + 'value': 500 + }, + 'battery_control': { + 'max_charging_from_grid_limit': 0.8, + 'min_price_difference': 0.05 + }, + 'peak_shaving': { + 'enabled': True, + 'allow_full_battery_after': 14 + }, + 'mqtt': { + 'enabled': False + } + } + + def _create_bc(self, mock_config, mock_inverter_factory, mock_tariff, + mock_solar, mock_consumption): + """Helper to create a Batcontrol with mocked dependencies.""" + mock_inverter = MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.set_mode_limit_battery_charge = MagicMock() + mock_inverter.get_max_capacity = MagicMock(return_value=10000) + mock_inverter_factory.return_value = mock_inverter + + mock_tariff.return_value = MagicMock() + mock_solar.return_value = MagicMock() + mock_consumption.return_value = MagicMock() + + bc = Batcontrol(mock_config) + return bc + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_evcc_charging_clears_charge_limit( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """When EVCC is actively charging, peak shaving charge limit is cleared.""" + bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, + mock_solar, mock_consumption) + + # Simulate EVCC API + mock_evcc = MagicMock() + mock_evcc.evcc_is_charging = True + mock_evcc.evcc_ev_expects_pv_surplus = False + bc.evcc_api = mock_evcc + + # Simulate inverter_settings with peak shaving limit active + from batcontrol.logic.logic_interface import InverterControlSettings + settings = InverterControlSettings( + allow_discharge=True, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=500 + ) + + # Apply the EVCC guard logic (same block as in core.py run loop) + evcc_disable_peak_shaving = ( + bc.evcc_api.evcc_is_charging or + bc.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: + settings.limit_battery_charge_rate = -1 + + assert settings.limit_battery_charge_rate == -1 + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_evcc_pv_mode_clears_charge_limit( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """When EV connected in PV mode, peak shaving charge limit is cleared.""" + bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, + mock_solar, mock_consumption) + + mock_evcc = MagicMock() + mock_evcc.evcc_is_charging = False + mock_evcc.evcc_ev_expects_pv_surplus = True + bc.evcc_api = mock_evcc + + from batcontrol.logic.logic_interface import InverterControlSettings + settings = InverterControlSettings( + allow_discharge=True, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=500 + ) + + evcc_disable_peak_shaving = ( + bc.evcc_api.evcc_is_charging or + bc.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: + settings.limit_battery_charge_rate = -1 + + assert settings.limit_battery_charge_rate == -1 + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_evcc_not_charging_preserves_charge_limit( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """When EVCC is not charging and no PV mode, charge limit is preserved.""" + bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, + mock_solar, mock_consumption) + + mock_evcc = MagicMock() + mock_evcc.evcc_is_charging = False + mock_evcc.evcc_ev_expects_pv_surplus = False + bc.evcc_api = mock_evcc + + from batcontrol.logic.logic_interface import InverterControlSettings + settings = InverterControlSettings( + allow_discharge=True, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=500 + ) + + evcc_disable_peak_shaving = ( + bc.evcc_api.evcc_is_charging or + bc.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: + settings.limit_battery_charge_rate = -1 + + assert settings.limit_battery_charge_rate == 500 + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_evcc_no_limit_active_no_change( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """When no charge limit is active (=-1), EVCC guard doesn't modify it.""" + bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, + mock_solar, mock_consumption) + + mock_evcc = MagicMock() + mock_evcc.evcc_is_charging = True + mock_evcc.evcc_ev_expects_pv_surplus = False + bc.evcc_api = mock_evcc + + from batcontrol.logic.logic_interface import InverterControlSettings + settings = InverterControlSettings( + allow_discharge=True, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=-1 + ) + + evcc_disable_peak_shaving = ( + bc.evcc_api.evcc_is_charging or + bc.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: + settings.limit_battery_charge_rate = -1 + + assert settings.limit_battery_charge_rate == -1 + + if __name__ == '__main__': pytest.main([__file__, '-v']) From b5f798db7db5d814c2b2814097fce7ac369244a8 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 15:55:23 +0100 Subject: [PATCH 08/35] 3rd iteration --- PLAN.md | 178 ++++++++++++--- config/batcontrol_config_dummy.yaml | 6 + docs/WIKI_peak_shaving.md | 53 +++-- src/batcontrol/core.py | 1 + src/batcontrol/logic/logic_interface.py | 13 +- src/batcontrol/logic/next.py | 138 +++++++++-- tests/batcontrol/logic/test_peak_shaving.py | 239 +++++++++++++++++++- 7 files changed, 557 insertions(+), 71 deletions(-) diff --git a/PLAN.md b/PLAN.md index 6c74ae0d..81f2005c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -25,6 +25,7 @@ Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging peak_shaving: enabled: false allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour + price_limit: 0.05 # Euro/kWh — keep free capacity for slots at or below this price ``` **`allow_full_battery_after`** — Target hour for the battery to be full: @@ -32,6 +33,12 @@ peak_shaving: - **At/after this hour:** No PV charge limit. Battery is allowed to be 100% full. PV overflow to grid is acceptable (e.g., EV arrives home and the charger absorbs excess). - **During EV charging or EV connected in PV mode:** Peak shaving disabled entirely. +**`price_limit`** (optional) — Keep free battery capacity reserved for upcoming cheap-price time slots: +- When set, the algorithm identifies upcoming slots where `price <= price_limit` and reserves enough free capacity to absorb the full PV surplus during those cheap hours. +- **During a cheap slot** (`price[0] <= price_limit`): No charge limit — absorb as much PV as possible. +- **When `price_limit` is not set (default):** Peak shaving is **disabled entirely**. This means peak shaving only activates when a `price_limit` is explicitly configured, making the price-based algorithm the primary enabler. +- When both `price_limit` and `allow_full_battery_after` are configured, the stricter limit ( lower W) wins. + ### 1.2 Logic Type Selection ```yaml @@ -156,29 +163,51 @@ This keeps `DefaultLogic` completely untouched and allows the `next` logic to ev ### 3.2 Core Algorithm -The algorithm spreads battery charging over time so the battery reaches full at the target hour: +The algorithm has two components that both compute a PV charge rate limit in W. The stricter (lower non-negative) limit wins. + +#### Component 1: Price-Based (Primary) + +The primary driver. The idea: before cheap-price slots arrive, keep the battery partially empty so those slots' PV surplus fills the battery completely rather than spilling to the grid. ``` -slots_remaining = slots from now until allow_full_battery_after -free_capacity = battery free capacity in Wh -expected_pv_surplus = sum of (production - consumption) for those slots, only positive values (Wh) +cheap_slots = upcoming slots where price <= price_limit +target_reserve_wh = min(sum of PV surplus in cheap slots, max_capacity) +additional_charging_allowed_wh = free_capacity - target_reserve_wh + +if additional_charging_allowed <= 0: + block PV charging (rate = 0) +else: + spread additional_charging_allowed over slots_before_cheap_window + charge_rate = additional_charging_allowed / slots_before_cheap / interval_hours ``` -If expected **PV surplus** (production minus consumption) exceeds free capacity, PV would fill the battery too early. We calculate the **maximum PV charge rate** that fills the battery evenly: +When `price_limit` is not configured: this component returns -1 (no limit), effectively **disabling peak shaving entirely** since both components must be configured for any limit to apply. + +When the current slot is cheap (`prices[0] <= price_limit`): no limit — absorb as much PV as possible. + +#### Component 2: Time-Based (Secondary) + +Spreads remaining battery free capacity evenly until `allow_full_battery_after`. Only triggers if the expected PV surplus would fill the battery before the target hour: ``` -ideal_charge_rate_wh = free_capacity / slots_remaining # Wh per slot -ideal_charge_rate_w = ideal_charge_rate_wh * (60 / interval_minutes) # Convert to W +slots_remaining = slots from now until allow_full_battery_after +free_capacity = battery free capacity in Wh +pv_surplus = sum of max(production - consumption, 0) for remaining slots (Wh) + +if pv_surplus > free_capacity: + charge_limit = free_capacity / slots_remaining (Wh per slot → W) ``` -Set `limit_battery_charge_rate = ideal_charge_rate_w` → MODE 8. +#### Combining Both Limits -If expected PV surplus is less than free capacity, no limit needed (battery won't fill early). +Both limits are computed independently. The final limit is `min(price_limit_w, time_limit_w)` where only non-negative values are considered. If only one component produces a limit, that limit is used. -**Note:** The charge limit is distributed evenly across slots. This is a simplification — PV production peaks midday while the limit is flat. This means the limit may have no effect in low-PV morning slots and may clip excess in high-PV midday slots. The battery may not reach exactly 100% by the target hour. This is acceptable for v1; a PV-weighted distribution could be added later. +**Note:** When `price_limit` is not set, the price-based component returns -1 (no limit) and the time-based component is also bypassed — peak shaving is fully disabled. This design ensures peak shaving only activates when `price_limit` is explicitly configured, giving operators control over when the feature is active. ### 3.3 Algorithm Implementation +#### Time-Based: `_calculate_peak_shaving_charge_limit()` + ```python def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): """Calculate PV charge rate limit to fill battery by target hour. @@ -228,6 +257,54 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): return int(charge_rate_w) ``` +#### Price-Based: `_calculate_peak_shaving_charge_limit_price_based()` + +```python +def _calculate_peak_shaving_charge_limit_price_based(self, calc_input): + """Reserve free capacity for upcoming cheap-price PV slots. + + Returns: int — charge rate limit in W, or -1 if no limit needed. + """ + price_limit = self.calculation_parameters.peak_shaving_price_limit + prices = calc_input.prices + interval_hours = self.interval_minutes / 60.0 + + # Find cheap slots + cheap_slots = [i for i, p in enumerate(prices) + if p is not None and p <= price_limit] + if not cheap_slots: + return -1 # No cheap slots ahead + + first_cheap_slot = cheap_slots[0] + if first_cheap_slot == 0: + return -1 # Already in cheap slot + + # Sum expected PV surplus during cheap slots + total_cheap_surplus_wh = 0.0 + for i in cheap_slots: + if i < len(calc_input.production) and i < len(calc_input.consumption): + surplus = float(calc_input.production[i]) - float(calc_input.consumption[i]) + if surplus > 0: + total_cheap_surplus_wh += surplus * interval_hours + + if total_cheap_surplus_wh <= 0: + return -1 # No PV surplus expected during cheap slots + + # Reserve capacity (capped at full battery capacity) + target_reserve_wh = min(total_cheap_surplus_wh, self.common.max_capacity) + + additional_charging_allowed = calc_input.free_capacity - target_reserve_wh + + if additional_charging_allowed <= 0: + return 0 # Block PV charging — battery already too full + + # Spread allowed charging evenly over slots before cheap window + wh_per_slot = additional_charging_allowed / first_cheap_slot + charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + + return int(charge_rate_w) +``` + ### 3.4 Always-Allow-Discharge Region Skips Peak Shaving When `stored_energy >= max_capacity * always_allow_discharge_limit`, the battery is in the "always allow discharge" region. In this region, peak shaving is **not applied** — the system is already at high SOC and the normal discharge logic takes over. This also avoids toggling issues when SOC fluctuates near 100%. @@ -265,67 +342,67 @@ Peak shaving uses MODE 8 (`limit_battery_charge_rate` with `allow_discharge=True ```python def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): - """Limit PV charge rate to spread battery charging until target hour. - - Peak shaving uses MODE 8 (limit_battery_charge_rate with - allow_discharge=True). It is only applied when the main logic - already allows discharge — meaning no upcoming high-price slots - require preserving battery energy. + """Limit PV charge rate using price-based and time-based algorithms. Skipped when: + - price_limit is None (primary disable condition) - No production right now (nighttime) + - Currently in a cheap slot (prices[0] <= price_limit) — charge freely - Past the target hour (allow_full_battery_after) - Battery is in always_allow_discharge region (high SOC) - Force charge from grid is active (MODE -1) - Discharge not allowed (battery preserved for high-price hours) - Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. + Note: EVCC checks (charging, connected+pv mode) are handled in core.py. """ - # No production right now: skip calculation (avoid unnecessary work at night) + price_limit = self.calculation_parameters.peak_shaving_price_limit + + # price_limit not configured → peak shaving disabled entirely + if price_limit is None: + return settings + + # No production right now: skip calculation if calc_input.production[0] <= 0: return settings + # Currently in cheap slot: charge freely to absorb PV surplus + if calc_input.prices[0] <= price_limit: + logger.debug('[PeakShaving] Skipped: currently in cheap slot (price=%.4f <= limit=%.4f)', + calc_input.prices[0], price_limit) + return settings + # After target hour: no limit if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: return settings # In always_allow_discharge region: skip peak shaving if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): - logger.debug('[PeakShaving] Skipped: battery in always_allow_discharge region') return settings # Force charge takes priority over peak shaving if settings.charge_from_grid: - logger.warning('[PeakShaving] Skipped: force_charge (MODE -1) active, ' - 'grid charging takes priority') + logger.warning('[PeakShaving] Skipped: force_charge (MODE -1) active') return settings # Battery preserved for high-price hours — don't limit PV charging if not settings.allow_discharge: - logger.debug('[PeakShaving] Skipped: discharge not allowed, ' - 'battery preserved for high-price hours') + logger.debug('[PeakShaving] Skipped: discharge not allowed') return settings - charge_limit = self._calculate_peak_shaving_charge_limit( - calc_input, calc_timestamp) + # Calculate both limits; take the stricter (lower non-negative) one + price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) + time_limit_w = self._calculate_peak_shaving_charge_limit(calc_input, calc_timestamp) + + candidates = [v for v in (price_limit_w, time_limit_w) if v >= 0] + charge_limit = min(candidates) if candidates else -1 if charge_limit >= 0: - # Apply PV charge rate limit if settings.limit_battery_charge_rate < 0: - # No existing limit — apply peak shaving limit settings.limit_battery_charge_rate = charge_limit else: - # Keep the more restrictive limit settings.limit_battery_charge_rate = min( settings.limit_battery_charge_rate, charge_limit) - # Note: allow_discharge is already True here (checked above). - # MODE 8 requires allow_discharge=True to work correctly. - - logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', - settings.limit_battery_charge_rate, - self.calculation_parameters.peak_shaving_allow_full_after) - return settings ``` @@ -344,6 +421,7 @@ class CalculationParameters: # Peak shaving parameters peak_shaving_enabled: bool = False peak_shaving_allow_full_after: int = 14 # Hour (0-23) + peak_shaving_price_limit: Optional[float] = None # Euro/kWh; None → peak shaving disabled def __post_init__(self): if not 0 <= self.peak_shaving_allow_full_after <= 23: @@ -351,6 +429,11 @@ class CalculationParameters: f"peak_shaving_allow_full_after must be 0-23, " f"got {self.peak_shaving_allow_full_after}" ) + if self.peak_shaving_price_limit is not None and self.peak_shaving_price_limit < 0: + raise ValueError( + f"peak_shaving_price_limit must be >= 0, " + f"got {self.peak_shaving_price_limit}" + ) ``` **No changes needed to `CalculationInput`** — the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. @@ -414,6 +497,7 @@ calc_parameters = CalculationParameters( self.get_max_capacity(), peak_shaving_enabled=peak_shaving_config.get('enabled', False), peak_shaving_allow_full_after=peak_shaving_config.get('allow_full_battery_after', 14), + peak_shaving_price_limit=peak_shaving_config.get('price_limit', None), ) ``` @@ -483,7 +567,9 @@ QoS: default (0) for all topics (consistent with existing MQTT API). **Decision tests (`_apply_peak_shaving`):** - `peak_shaving_enabled = False` → no change to settings +- `price_limit = None` → peak shaving disabled entirely - Current production = 0 (nighttime) → peak shaving skipped +- Currently in cheap slot (`prices[0] <= price_limit`) → no charge limit applied - `charge_from_grid = True` → peak shaving skipped, warning logged - `allow_discharge = False` (battery preserved for high-price hours) → peak shaving skipped - Battery in always_allow_discharge region → peak shaving skipped @@ -492,6 +578,22 @@ QoS: default (0) for all topics (consistent with existing MQTT API). - Existing tighter limit from other logic → kept (more restrictive wins) - Peak shaving limit tighter than existing → peak shaving limit applied +**Price-based algorithm tests (`_calculate_peak_shaving_charge_limit_price_based`):** +- No cheap slots → -1 +- Currently in cheap slot → -1 +- PV surplus in cheap slots ≤ 0 → -1 +- Cheap-slot surplus exceeds free capacity → block (0) +- Partial reserve spreads over remaining slots → rate calculation +- Free capacity well above reserve → charge rate returned +- Consumption reduces cheap-slot surplus +- Both price-based and time-based configured → stricter limit wins + +**`CalculationParameters` tests:** +- `peak_shaving_price_limit` defaults to `None` +- Explicit float value stored correctly +- Zero allowed (free price slots) +- Negative value raises `ValueError` + ### 7.2 EVCC Tests (`tests/batcontrol/test_evcc_mode.py`) - Topic derivation: `evcc/loadpoints/1/charging` → mode: `evcc/loadpoints/1/mode`, connected: `evcc/loadpoints/1/connected` @@ -569,7 +671,11 @@ QoS: default (0) for all topics (consistent with existing MQTT API). 6. **Algorithm uses net PV surplus:** The charge limit calculation uses `production - consumption` (positive only), not raw production. This prevents over-throttling when household consumption absorbs most of the PV. -7. **Price-based skip (discharge not allowed):** When the main logic set `allow_discharge=False`, the battery is being preserved for upcoming high-price slots. In this case peak shaving is skipped — the battery needs to charge from PV as fast as possible. This also means `_apply_peak_shaving` does not need to force `allow_discharge=True`; it only applies when discharge is already allowed. +7. **Price-based algorithm is the primary driver:** Peak shaving is **disabled** when `price_limit` is `None`. This makes the operator opt in explicitly by setting a price threshold. The price-based algorithm identifies upcoming cheap-price slots and reserves enough free capacity to absorb their full PV surplus. This is the economically motivated core of peak shaving: buy cheap energy via full PV absorption, not by throttling charging arbitrarily. + +8. **Currently-in-cheap-slot skip:** When the current slot is cheap (`prices[0] <= price_limit`), no charge limit is applied — the battery should absorb as much PV as possible during this window. This is checked in `_apply_peak_shaving` before calling either sub-algorithm. + +9. **Two-limit combination (stricter wins):** The price-based and time-based components are independent. When both are configured, the final limit is `min(price_limit_w, time_limit_w)` over non-negative values. This ensures neither algorithm can inadvertently allow more charging than the other intends. ## 11. Known Limitations (v1) diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index cc48766e..f83f4cdf 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -38,10 +38,16 @@ battery_control_expert: # Manages PV battery charging rate so the battery fills up gradually, # reaching full capacity by a target hour (allow_full_battery_after). # Requires logic type 'next' in battery_control section. +# +# price_limit: slots where price (Euro/kWh) is at or below this value are +# treated as cheap PV windows. Battery capacity is reserved so the PV +# surplus during those cheap slots can be fully absorbed. When price_limit +# is not set (commented out), peak shaving is disabled. #-------------------------- peak_shaving: enabled: false allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour + price_limit: 0.05 # Euro/kWh — keep battery empty for slots at or below this price #-------------------------- # Inverter diff --git a/docs/WIKI_peak_shaving.md b/docs/WIKI_peak_shaving.md index ee7d696e..526a8564 100644 --- a/docs/WIKI_peak_shaving.md +++ b/docs/WIKI_peak_shaving.md @@ -25,6 +25,7 @@ battery_control: peak_shaving: enabled: false allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour + price_limit: 0.05 # Euro/kWh — keep free capacity for cheap-price slots ``` ### Parameters @@ -33,39 +34,65 @@ peak_shaving: |-----------|------|---------|-------------| | `enabled` | bool | `false` | Enable/disable peak shaving | | `allow_full_battery_after` | int | `14` | Target hour (0-23) for the battery to be full | +| `price_limit` | float | `null` | Price threshold (€/kWh); slots at or below this price are "cheap". **Required** — peak shaving is disabled when not set | **`allow_full_battery_after`** controls when the battery is allowed to be 100% full: -- **Before this hour:** PV charge rate is limited to spread charging evenly +- **Before this hour:** PV charge rate may be limited to prevent early fill - **At/after this hour:** No PV charge limit, battery is allowed to reach full charge +**`price_limit`** controls when cheap slots are recognised: +- **Not set (default):** Peak shaving is completely disabled — no charge limit is ever applied +- **During a cheap slot** (`current price <= price_limit`): No limit is applied — absorb as much PV as possible during these valuable hours +- **Before cheap slots:** PV charging is throttled so the battery has free capacity ready to absorb the cheap-slot PV surplus + ## How It Works ### Algorithm -The algorithm calculates the expected PV surplus (production minus consumption) for all time slots until the target hour. If the expected surplus would fill the battery before the target hour, it calculates a charge rate limit: +Peak shaving uses two independent components that each compute a PV charge rate limit. The **stricter (lower non-negative)** limit wins. + +**Component 1: Price-Based (primary)** + +This is the main driver. Before cheap-price hours arrive, the battery is kept partially empty so the cheap slots' PV surplus fills it completely: + +``` +cheap_slots = slots where price <= price_limit +target_reserve = min(sum of PV surplus in cheap slots, battery max capacity) +additional_allowed = free_capacity - target_reserve + +if additional_allowed <= 0: → block PV charging (rate = 0) +else: → spread additional_allowed over slots before cheap window +``` + +Example: prices = [10, 10, 5, 3, 0, 0], production peak at slots 4 and 5 → reserve free capacity now so slots 4 and 5 can fill the battery completely from PV. + +**Component 2: Time-Based (secondary)** + +Ensures the battery does not fill before `allow_full_battery_after`, independent of pricing: ``` -slots_remaining = slots from now until allow_full_battery_after -free_capacity = battery free capacity in Wh +slots_remaining = slots until allow_full_battery_after pv_surplus = sum of max(production - consumption, 0) for remaining slots if pv_surplus > free_capacity: - charge_limit = free_capacity / slots_remaining (Wh per slot, converted to W) + charge_limit = free_capacity / slots_remaining (Wh/slot → W) ``` -The charge limit is applied using **MODE 8** (`limit_battery_charge_rate`), which limits PV charging while still allowing battery discharge. Peak shaving only applies when discharge is already allowed by the main price-based logic. +Both limits are computed and the stricter one is applied using **MODE 8** (`limit_battery_charge_rate`). Peak shaving only applies when discharge is already allowed by the main price-based logic. ### Skip Conditions Peak shaving is automatically skipped when: -1. **No PV production** — nighttime, no action needed -2. **Past the target hour** — battery is allowed to be full -3. **Battery in always_allow_discharge region** — SOC is already high -4. **Grid charging active (MODE -1)** — force charge takes priority -5. **Discharge not allowed** — battery is being preserved for upcoming high-price hours; PV charging should not be throttled so the battery can charge as fast as possible -6. **EVCC is actively charging** — EV consumes the excess PV -7. **EV connected in PV mode** — EVCC will absorb PV surplus +1. **`price_limit` not configured** — peak shaving is disabled entirely (primary disable condition) +2. **Currently in a cheap slot** (`prices[0] <= price_limit`) — battery should absorb PV freely +3. **No PV production** — nighttime, no action needed +4. **Past the target hour** — battery is allowed to be full +5. **Battery in always_allow_discharge region** — SOC is already high +6. **Grid charging active (MODE -1)** — force charge takes priority +7. **Discharge not allowed** — battery is being preserved for upcoming high-price hours; PV charging should not be throttled so the battery can charge as fast as possible +8. **EVCC is actively charging** — EV consumes the excess PV +9. **EV connected in PV mode** — EVCC will absorb PV surplus ### EVCC Interaction diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index b87203f5..50d4a7ae 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -528,6 +528,7 @@ def run(self): peak_shaving_enabled=peak_shaving_config.get('enabled', False), peak_shaving_allow_full_after=peak_shaving_config.get( 'allow_full_battery_after', 14), + peak_shaving_price_limit=peak_shaving_config.get('price_limit', None), ) self.last_logic_instance = this_logic_run diff --git a/src/batcontrol/logic/logic_interface.py b/src/batcontrol/logic/logic_interface.py index 13b5e891..4ef56b60 100644 --- a/src/batcontrol/logic/logic_interface.py +++ b/src/batcontrol/logic/logic_interface.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Optional import datetime import numpy as np @@ -23,6 +24,10 @@ class CalculationParameters: # Peak shaving parameters peak_shaving_enabled: bool = False peak_shaving_allow_full_after: int = 14 # Hour (0-23) + # Slots where price <= this limit (€/kWh) are treated as cheap PV windows. + # Battery capacity is reserved so those slots can be absorbed fully. + # When None, peak shaving is disabled regardless of the enabled flag. + peak_shaving_price_limit: Optional[float] = None def __post_init__(self): if not 0 <= self.peak_shaving_allow_full_after <= 23: @@ -30,6 +35,12 @@ def __post_init__(self): f"peak_shaving_allow_full_after must be 0-23, " f"got {self.peak_shaving_allow_full_after}" ) + if (self.peak_shaving_price_limit is not None + and self.peak_shaving_price_limit < 0): + raise ValueError( + f"peak_shaving_price_limit must be >= 0, " + f"got {self.peak_shaving_price_limit}" + ) @dataclass class CalculationOutput: diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index cb410cd6..e0fe38d0 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -217,8 +217,17 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, already allows discharge — meaning no upcoming high-price slots require preserving battery energy. + The algorithm has two components that are combined (stricter wins): + - Price-based: reserve battery capacity for upcoming cheap-price PV + slots so they can be absorbed fully (primary driver). Requires + peak_shaving_price_limit to be configured; disabled when None. + - Time-based: spread remaining capacity over slots until + allow_full_battery_after (secondary constraint). + Skipped when: + - price_limit is not configured (peak shaving disabled) - No production right now (nighttime) + - Currently in a cheap slot (price <= price_limit) — charge freely - Past the target hour (allow_full_battery_after) - Battery is in always_allow_discharge region (high SOC) - Force charge from grid is active (MODE -1) @@ -227,10 +236,24 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. """ + price_limit = self.calculation_parameters.peak_shaving_price_limit + + # price_limit not configured → peak shaving is disabled + if price_limit is None: + logger.debug('[PeakShaving] Skipped: price_limit not configured') + return settings + # No production right now: skip calculation (avoid unnecessary work at night) if calc_input.production[0] <= 0: return settings + # Currently in a cheap slot — charge battery freely + if calc_input.prices[0] <= price_limit: + logger.debug('[PeakShaving] Skipped: currently in cheap-price slot ' + '(price %.3f <= limit %.3f)', + calc_input.prices[0], price_limit) + return settings + # After target hour: no limit if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: return settings @@ -252,32 +275,111 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, 'battery preserved for high-price hours') return settings - charge_limit = self._calculate_peak_shaving_charge_limit( - calc_input, calc_timestamp) + # Compute both limits, take stricter (lower non-negative value) + price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) + time_limit_w = self._calculate_peak_shaving_charge_limit(calc_input, calc_timestamp) - if charge_limit < 0: - logger.debug('[PeakShaving] Evaluated: no limit needed (surplus within capacity)') + if price_limit_w < 0 and time_limit_w < 0: + logger.debug('[PeakShaving] Evaluated: no limit needed') return settings - if charge_limit >= 0: - # Apply PV charge rate limit - if settings.limit_battery_charge_rate < 0: - # No existing limit — apply peak shaving limit - settings.limit_battery_charge_rate = charge_limit - else: - # Keep the more restrictive limit - settings.limit_battery_charge_rate = min( - settings.limit_battery_charge_rate, charge_limit) + if price_limit_w >= 0 and time_limit_w >= 0: + charge_limit = min(price_limit_w, time_limit_w) + elif price_limit_w >= 0: + charge_limit = price_limit_w + else: + charge_limit = time_limit_w - # Note: allow_discharge is already True here (checked above). - # MODE 8 requires allow_discharge=True to work correctly. + # Apply PV charge rate limit + if settings.limit_battery_charge_rate < 0: + # No existing limit — apply peak shaving limit + settings.limit_battery_charge_rate = charge_limit + else: + # Keep the more restrictive limit + settings.limit_battery_charge_rate = min( + settings.limit_battery_charge_rate, charge_limit) - logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', - settings.limit_battery_charge_rate, - self.calculation_parameters.peak_shaving_allow_full_after) + # Note: allow_discharge is already True here (checked above). + # MODE 8 requires allow_discharge=True to work correctly. + + logger.info('[PeakShaving] PV charge limit: %d W ' + '(price-based=%s W, time-based=%s W, full by %d:00)', + settings.limit_battery_charge_rate, + price_limit_w if price_limit_w >= 0 else 'off', + time_limit_w if time_limit_w >= 0 else 'off', + self.calculation_parameters.peak_shaving_allow_full_after) return settings + def _calculate_peak_shaving_charge_limit_price_based( + self, calc_input: CalculationInput) -> int: + """Reserve battery free capacity for upcoming cheap-price PV slots. + + Finds upcoming slots where price <= peak_shaving_price_limit and + calculates a charge rate limit that keeps enough free capacity to + absorb the expected PV surplus during those cheap slots fully. + + Algorithm: + 1. Find upcoming cheap slots (price <= price_limit). + 2. Sum PV surplus during cheap slots → target_reserve_wh. + 3. additional_charging_allowed = free_capacity - target_reserve_wh + 4. If additional_charging_allowed <= 0: block PV charging (return 0). + 5. Spread additional_charging_allowed evenly over slots before + the cheap window starts. + + Returns: + int: charge rate limit in W, or -1 if no limit needed. + """ + price_limit = self.calculation_parameters.peak_shaving_price_limit + prices = calc_input.prices + interval_hours = self.interval_minutes / 60.0 + + # Find all cheap slots + cheap_slots = [i for i, p in enumerate(prices) + if p is not None and p <= price_limit] + if not cheap_slots: + return -1 # No cheap slots ahead + + first_cheap_slot = cheap_slots[0] + if first_cheap_slot == 0: + return -1 # Already in cheap slot (caller checks this too) + + # Calculate expected PV surplus during cheap slots + total_cheap_surplus_wh = 0.0 + for i in cheap_slots: + if i < len(calc_input.production) and i < len(calc_input.consumption): + surplus = float(calc_input.production[i]) - float(calc_input.consumption[i]) + if surplus > 0: + total_cheap_surplus_wh += surplus * interval_hours + + if total_cheap_surplus_wh <= 0: + return -1 # No PV surplus expected during cheap slots + + # Reserve capacity (capped at full battery capacity) + target_reserve_wh = min(total_cheap_surplus_wh, self.common.max_capacity) + + # How much more can we charge before the cheap window starts? + additional_charging_allowed = calc_input.free_capacity - target_reserve_wh + + if additional_charging_allowed <= 0: + logger.debug( + '[PeakShaving] Price-based: battery full relative to cheap-window ' + 'reserve (free=%.0f Wh, reserve=%.0f Wh), blocking PV charge', + calc_input.free_capacity, target_reserve_wh) + return 0 + + # Spread allowed charging evenly over slots before cheap window + wh_per_slot = additional_charging_allowed / first_cheap_slot + charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + + logger.debug( + '[PeakShaving] Price-based: cheap window at slot %d, ' + 'reserve=%.0f Wh, allowed=%.0f Wh → limit=%d W', + first_cheap_slot, target_reserve_wh, additional_charging_allowed, + int(charge_rate_w)) + + return int(charge_rate_w) + def _calculate_peak_shaving_charge_limit(self, calc_input: CalculationInput, calc_timestamp: datetime.datetime) -> int: """Calculate PV charge rate limit to fill battery by target hour. diff --git a/tests/batcontrol/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py index c035562d..95e06a7a 100644 --- a/tests/batcontrol/logic/test_peak_shaving.py +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -210,6 +210,7 @@ def setUp(self): max_capacity=self.max_capacity, peak_shaving_enabled=True, peak_shaving_allow_full_after=14, + peak_shaving_price_limit=0.05, # required; tests use high prices so no cheap slots ) self.logic.set_calculation_parameters(self.params) @@ -224,7 +225,8 @@ def _make_settings(self, allow_discharge=True, charge_from_grid=False, def _make_input(self, production, consumption, stored_energy, free_capacity): - prices = np.zeros(len(production)) + # Use high prices (10.0) so no slot is "cheap" — only time-based limit applies. + prices = np.ones(len(production)) * 10.0 return CalculationInput( production=np.array(production, dtype=float), consumption=np.array(consumption, dtype=float), @@ -330,6 +332,43 @@ def test_discharge_not_allowed_skips_peak_shaving(self): self.assertEqual(result.limit_battery_charge_rate, -1) self.assertFalse(result.allow_discharge) + def test_price_limit_none_disables_peak_shaving(self): + """price_limit=None → peak shaving disabled entirely.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + peak_shaving_price_limit=None, + ) + self.logic.set_calculation_parameters(params) + settings = self._make_settings() + calc_input = self._make_input([5000] * 8, [500] * 8, + stored_energy=5000, free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + + def test_currently_in_cheap_slot_no_limit(self): + """Current slot is cheap (price <= price_limit) → charge freely.""" + settings = self._make_settings() + prices = np.zeros(8) # all slots cheap (price=0 <= 0.05) + calc_input = CalculationInput( + production=np.array([5000] * 8, dtype=float), + consumption=np.array([500] * 8, dtype=float), + prices=prices, + stored_energy=5000, + stored_usable_energy=4500, + free_capacity=5000, + ) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + class TestPeakShavingDisabled(unittest.TestCase): """Test that peak shaving does nothing when disabled.""" @@ -471,6 +510,200 @@ def test_invalid_allow_full_after_negative(self): peak_shaving_allow_full_after=-1, ) + def test_price_limit_default_is_none(self): + """peak_shaving_price_limit defaults to None.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + ) + self.assertIsNone(params.peak_shaving_price_limit) -if __name__ == '__main__': - unittest.main() + def test_price_limit_explicit_value(self): + """Explicit price_limit is stored.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_price_limit=0.05, + ) + self.assertEqual(params.peak_shaving_price_limit, 0.05) + + def test_price_limit_zero_allowed(self): + """price_limit=0 is valid (only free/negative prices count as cheap).""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_price_limit=0.0, + ) + self.assertEqual(params.peak_shaving_price_limit, 0.0) + + def test_price_limit_negative_raises(self): + """Negative price_limit raises ValueError.""" + with self.assertRaises(ValueError): + CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_price_limit=-0.01, + ) + + +class TestPeakShavingPriceBased(unittest.TestCase): + """Tests for _calculate_peak_shaving_charge_limit_price_based.""" + + def setUp(self): + self.max_capacity = 10000 + self.interval_minutes = 60 + self.logic = NextLogic(timezone=datetime.timezone.utc, + interval_minutes=self.interval_minutes) + self.common = CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=self.max_capacity, + ) + self.params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + peak_shaving_price_limit=0.05, + ) + self.logic.set_calculation_parameters(self.params) + + def _make_input(self, production, prices, free_capacity, consumption=None): + """Helper to build CalculationInput for price-based tests.""" + n = len(production) + if consumption is None: + consumption = [0.0] * n + return CalculationInput( + production=np.array(production, dtype=float), + consumption=np.array(consumption, dtype=float), + prices=np.array(prices, dtype=float), + stored_energy=self.max_capacity - free_capacity, + stored_usable_energy=(self.max_capacity - free_capacity) * 0.95, + free_capacity=free_capacity, + ) + + def test_surplus_exceeds_free_capacity_blocks_charging(self): + """ + Cheap slots at index 5 and 6 (price=0 <= 0.05). + Surplus in cheap slots: 3000+3000 = 6000 Wh (interval=1h, consumption=0). + target_reserve = min(6000, 10000) = 6000 Wh. + free_capacity = 4000 Wh. + additional_allowed = 4000 - 6000 = -2000 → block charging (return 0). + """ + prices = [10, 10, 10, 8, 3, 0, 0, 1] + production = [500, 500, 500, 500, 500, 3000, 3000, 500] + calc_input = self._make_input(production, prices, free_capacity=4000) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, 0) + + def test_partial_reserve_spread_over_slots(self): + """ + 2 cheap slots with 3000 Wh PV surplus each = 6000 Wh total. + Battery has 8000 Wh free, target_reserve = min(6000, 10000) = 6000 Wh. + additional_allowed = 8000 - 6000 = 2000 Wh. + first_cheap_slot = 4 (4 slots before cheap window). + wh_per_slot = 2000 / 4 = 500 Wh → rate = 500 W (60 min intervals). + """ + prices = [10, 10, 10, 10, 0, 0, 1, 2] + # cheap surplus slots 4,5: 3000W each, interval=1h → 3000 Wh each + production = [500, 500, 500, 500, 3000, 3000, 500, 500] + calc_input = self._make_input(production, prices, free_capacity=8000) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, 500) + + def test_no_cheap_slots_returns_minus_one(self): + """No cheap slots in prices → -1.""" + prices = [10, 10, 10, 10, 10, 10] + production = [3000] * 6 + calc_input = self._make_input(production, prices, free_capacity=5000) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, -1) + + def test_currently_in_cheap_slot_returns_minus_one(self): + """first_cheap_slot = 0 (current slot is cheap) → -1.""" + prices = [0, 0, 10, 10] + production = [5000, 5000, 500, 500] + calc_input = self._make_input(production, prices, free_capacity=5000) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, -1) + + def test_zero_pv_surplus_in_cheap_slots_returns_minus_one(self): + """Cheap slots have no PV surplus (consumption >= production) → -1.""" + prices = [10, 10, 0, 0] + production = [500, 500, 200, 200] + consumption = [500, 500, 300, 300] # net = 0 or negative in cheap slots + calc_input = self._make_input(production, prices, free_capacity=5000, + consumption=consumption) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, -1) + + def test_free_capacity_well_above_reserve_gives_rate(self): + """ + cheap surplus = 1000 Wh, target_reserve = 1000 Wh. + free_capacity = 6000 Wh → additional_allowed = 5000 Wh. + first_cheap_slot = 5 → wh_per_slot = 1000 W. + """ + prices = [10, 10, 10, 10, 10, 0, 10] + production = [500, 500, 500, 500, 500, 1000, 500] + calc_input = self._make_input(production, prices, free_capacity=6000) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, 1000) + + def test_consumption_reduces_cheap_surplus(self): + """ + Cheap slot: production=5000W, consumption=3000W → surplus=2000 Wh. + target_reserve = 2000, free=5000 → additional=3000 Wh. + first_cheap_slot=2 → rate = 3000/2 = 1500 W. + """ + prices = [10, 10, 0, 10] + production = [500, 500, 5000, 500] + consumption = [200, 200, 3000, 200] + calc_input = self._make_input(production, prices, free_capacity=5000, + consumption=consumption) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, 1500) + + def test_combine_price_and_time_limits_stricter_wins(self): + """ + Both limits active: time-based and price-based give different rates. + The stricter (lower) limit must be applied. + Setup at 08:00, target 14:00 (6 slots remaining). + High prices except slot 4 (cheap). + price-based: cheap surplus=4000Wh at 4, free=3000 → allowed=reserved-capped + Just check final result <= both individual limits. + """ + logic = NextLogic(timezone=datetime.timezone.utc, interval_minutes=60) + logic.set_calculation_parameters(self.params) + + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, tzinfo=datetime.timezone.utc) + # prices: slots 0-3 high, slot 4 cheap, slots 5-7 high again + prices = np.array([10, 10, 10, 10, 0, 10, 10, 10], dtype=float) + production = np.array([500, 500, 500, 500, 5000, 5000, 500, 500], dtype=float) + calc_input = CalculationInput( + production=production, + consumption=np.ones(8) * 500, + prices=prices, + stored_energy=7000, + stored_usable_energy=6500, + free_capacity=3000, + ) + settings = InverterControlSettings( + allow_discharge=True, charge_from_grid=False, + charge_rate=0, limit_battery_charge_rate=-1) + result = logic._apply_peak_shaving(settings, calc_input, ts) + + # Both limits should be considered; combined must be <= each individually + price_lim = logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + time_lim = logic._calculate_peak_shaving_charge_limit(calc_input, ts) + expected = min(x for x in [price_lim, time_lim] if x >= 0) + self.assertEqual(result.limit_battery_charge_rate, expected) From 70b658087bb13aaea3ba9a6ccd6db328bc4b3ac6 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 15:59:43 +0100 Subject: [PATCH 09/35] Add instruction about ascii characters --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fb62280a..29ccd03f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,6 +4,7 @@ This is a Python based repository providing an application for controlling batte ### Required Before Each Commit +- Only use ASCII characters in code, log messages, and documentation. Avoid non-ASCII characters to ensure compatibility and readability across different environments. - Remove excessive whitespaces. - Follow PEP8 standards. Use autopep8 for that. - Check against pylint. Target score is like 9.0-9.5, if you can achieve 10, do it. From 43d77b4a4940e538a11f8eaff9cdee2e5d5c0c9d Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 16:15:54 +0100 Subject: [PATCH 10/35] Iteration 4 --- PLAN.md | 361 ++++++++++---------- config/batcontrol_config_dummy.yaml | 19 +- docs/WIKI_peak_shaving.md | 94 ++--- src/batcontrol/core.py | 1 + src/batcontrol/logic/logic_interface.py | 23 +- src/batcontrol/logic/next.py | 139 ++++---- tests/batcontrol/logic/test_peak_shaving.py | 263 ++++++++++---- 7 files changed, 544 insertions(+), 356 deletions(-) diff --git a/PLAN.md b/PLAN.md index 81f2005c..abf76995 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,14 +1,14 @@ -# Peak Shaving Feature — Implementation Plan +# Peak Shaving Feature - Implementation Plan ## Overview Add peak shaving to batcontrol: manage PV battery charging rate so the battery fills up gradually, reaching full capacity by a target hour (`allow_full_battery_after`). This prevents the battery from being full too early in the day. -**Problem:** All PV systems in the country produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest and — for newer installations — feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible into the battery, rather than feeding it into the grid during peak hours. +**Problem:** All PV systems in the country produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest and - for newer installations - feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible into the battery, rather than feeding it into the grid during peak hours. **EVCC interaction:** -- When an EV is actively charging (`charging=true`), peak shaving is disabled — the EV consumes the excess PV. -- When an EV is connected and the loadpoint mode is `pv`, peak shaving is also disabled — EVCC+EV will naturally absorb excessive PV energy once the surplus threshold is reached. +- When an EV is actively charging (`charging=true`), peak shaving is disabled - the EV consumes the excess PV. +- When an EV is connected and the loadpoint mode is `pv`, peak shaving is also disabled - EVCC+EV will naturally absorb excessive PV energy once the surplus threshold is reached. - If the EV disconnects or the mode changes away from `pv`, peak shaving is re-enabled. Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging. @@ -24,20 +24,27 @@ Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging ```yaml peak_shaving: enabled: false - allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour - price_limit: 0.05 # Euro/kWh — keep free capacity for slots at or below this price + mode: combined # 'time' | 'price' | 'combined' + allow_full_battery_after: 14 # Hour (0-23) - battery should be full by this hour + price_limit: 0.05 # Euro/kWh - keep free capacity for slots at or below this price ``` -**`allow_full_battery_after`** — Target hour for the battery to be full: +**`mode`** - selects which algorithm components are active: +- **`time`** - time-based only: spread free capacity evenly until `allow_full_battery_after`. `price_limit` is not required and is ignored. +- **`price`** - price-based only: reserve capacity for cheap-price PV slots. Requires `price_limit`. +- **`combined`** (default) - both components active; stricter limit wins. Requires `price_limit`. + +**`allow_full_battery_after`** - Target hour for the battery to be full: - **Before this hour:** PV charge rate is limited to spread charging evenly. The battery fills gradually instead of reaching 100% early and overflowing PV to grid. - **At/after this hour:** No PV charge limit. Battery is allowed to be 100% full. PV overflow to grid is acceptable (e.g., EV arrives home and the charger absorbs excess). - **During EV charging or EV connected in PV mode:** Peak shaving disabled entirely. -**`price_limit`** (optional) — Keep free battery capacity reserved for upcoming cheap-price time slots: +**`price_limit`** (optional for `mode: time`, required for `price`/`combined`) - Keep free battery capacity reserved for upcoming cheap-price time slots: - When set, the algorithm identifies upcoming slots where `price <= price_limit` and reserves enough free capacity to absorb the full PV surplus during those cheap hours. -- **During a cheap slot** (`price[0] <= price_limit`): No charge limit — absorb as much PV as possible. -- **When `price_limit` is not set (default):** Peak shaving is **disabled entirely**. This means peak shaving only activates when a `price_limit` is explicitly configured, making the price-based algorithm the primary enabler. -- When both `price_limit` and `allow_full_battery_after` are configured, the stricter limit ( lower W) wins. +- **During a cheap slot** and surplus > free capacity: charging is spread evenly over the remaining cheap slots. +- **During a cheap slot** and surplus <= free capacity: no limit - absorb freely. +- Accepts any numeric value including -1 (which disables cheap-slot detection since no price <= -1); `None` disables the price component. +- When both `price_limit` and `allow_full_battery_after` are configured (mode `combined`), the stricter limit wins. ### 1.2 Logic Type Selection @@ -50,23 +57,23 @@ The `type: next` logic includes all existing `DefaultLogic` behavior plus peak s --- -## 2. EVCC Integration — Loadpoint Mode & Connected State +## 2. EVCC Integration - Loadpoint Mode & Connected State ### 2.1 Approach Peak shaving is disabled when **any** of the following EVCC conditions are true: -1. **`charging = true`** — EV is actively charging (already tracked) -2. **`connected = true` AND `mode = pv`** — EV is plugged in and waiting for PV surplus +1. **`charging = true`** - EV is actively charging (already tracked) +2. **`connected = true` AND `mode = pv`** - EV is plugged in and waiting for PV surplus The EVCC check is handled in `core.py`, **not** in the logic layer. EVCC is an external integration concern, same pattern as `discharge_blocked`. -### 2.2 New EVCC Topics — Derived from `loadpoint_topic` +### 2.2 New EVCC Topics - Derived from `loadpoint_topic` The `mode` and `connected` topics are derived from the existing `loadpoint_topic` config by stripping `/charging` and appending the relevant suffix: ``` -evcc/loadpoints/1/charging → evcc/loadpoints/1/mode - → evcc/loadpoints/1/connected +evcc/loadpoints/1/charging -> evcc/loadpoints/1/mode + -> evcc/loadpoints/1/connected ``` Topics not ending in `/charging`: log warning, skip mode/connected subscription. @@ -75,8 +82,8 @@ Topics not ending in `/charging`: log warning, skip mode/connected subscription. **New state:** ```python -self.evcc_loadpoint_mode = {} # topic_root → mode string ("pv", "now", "minpv", "off") -self.evcc_loadpoint_connected = {} # topic_root → bool +self.evcc_loadpoint_mode = {} # topic_root -> mode string ("pv", "now", "minpv", "off") +self.evcc_loadpoint_connected = {} # topic_root -> bool self.list_topics_mode = [] # derived mode topics self.list_topics_connected = [] # derived connected topics ``` @@ -106,7 +113,7 @@ def handle_mode_message(self, message): mode = message.payload.decode('utf-8').strip().lower() old_mode = self.evcc_loadpoint_mode.get(root) if old_mode != mode: - logger.info('Loadpoint %s mode changed: %s → %s', root, old_mode, mode) + logger.info('Loadpoint %s mode changed: %s -> %s', root, old_mode, mode) self.evcc_loadpoint_mode[root] = mode def handle_connected_message(self, message): @@ -148,7 +155,7 @@ for root in list(self.evcc_loadpoint_mode.keys()): --- -## 3. New Logic Class — `NextLogic` +## 3. New Logic Class - `NextLogic` ### 3.1 Architecture @@ -183,7 +190,7 @@ else: When `price_limit` is not configured: this component returns -1 (no limit), effectively **disabling peak shaving entirely** since both components must be configured for any limit to apply. -When the current slot is cheap (`prices[0] <= price_limit`): no limit — absorb as much PV as possible. +When the current slot is cheap (`prices[0] <= price_limit`): no limit - absorb as much PV as possible. #### Component 2: Time-Based (Secondary) @@ -195,14 +202,14 @@ free_capacity = battery free capacity in Wh pv_surplus = sum of max(production - consumption, 0) for remaining slots (Wh) if pv_surplus > free_capacity: - charge_limit = free_capacity / slots_remaining (Wh per slot → W) + charge_limit = free_capacity / slots_remaining (Wh per slot -> W) ``` #### Combining Both Limits Both limits are computed independently. The final limit is `min(price_limit_w, time_limit_w)` where only non-negative values are considered. If only one component produces a limit, that limit is used. -**Note:** When `price_limit` is not set, the price-based component returns -1 (no limit) and the time-based component is also bypassed — peak shaving is fully disabled. This design ensures peak shaving only activates when `price_limit` is explicitly configured, giving operators control over when the feature is active. +**Note:** When `price_limit` is not set, the price-based component returns -1 (no limit) and the time-based component is also bypassed - peak shaving is fully disabled. This design ensures peak shaving only activates when `price_limit` is explicitly configured, giving operators control over when the feature is active. ### 3.3 Algorithm Implementation @@ -212,7 +219,7 @@ Both limits are computed independently. The final limit is `min(price_limit_w, t def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): """Calculate PV charge rate limit to fill battery by target hour. - Returns: int — charge rate limit in W, or -1 if no limit needed + Returns: int - charge rate limit in W, or -1 if no limit needed """ slot_start = calc_timestamp.replace( minute=(calc_timestamp.minute // self.interval_minutes) * self.interval_minutes, @@ -234,7 +241,7 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): if slots_remaining <= 0: return -1 - # Calculate PV surplus per slot (only count positive surplus — when PV > consumption) + # Calculate PV surplus per slot (only count positive surplus - when PV > consumption) pv_surplus = calc_input.production[:slots_remaining] - calc_input.consumption[:slots_remaining] pv_surplus = np.clip(pv_surplus, 0, None) # Only positive surplus counts @@ -252,7 +259,7 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): # Spread charging evenly across remaining slots wh_per_slot = free_capacity / slots_remaining - charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot -> W return int(charge_rate_w) ``` @@ -263,55 +270,51 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): def _calculate_peak_shaving_charge_limit_price_based(self, calc_input): """Reserve free capacity for upcoming cheap-price PV slots. - Returns: int — charge rate limit in W, or -1 if no limit needed. + When inside cheap window (first_cheap_slot == 0): + If surplus > free capacity: spread free_capacity over cheap slots. + If surplus <= free capacity: return -1 (no limit needed). + + When before cheap window: + Reserve capacity so the window can be absorbed fully. + additional_allowed = free_capacity - target_reserve_wh. + Spread additional_allowed evenly over slots before the window. + If additional_allowed <= 0: block charging (return 0). + + Returns: int - charge rate limit in W, or -1 if no limit needed. """ price_limit = self.calculation_parameters.peak_shaving_price_limit - prices = calc_input.prices - interval_hours = self.interval_minutes / 60.0 - - # Find cheap slots - cheap_slots = [i for i, p in enumerate(prices) - if p is not None and p <= price_limit] + ... + cheap_slots = [i for i, p in enumerate(prices) if p is not None and p <= price_limit] if not cheap_slots: - return -1 # No cheap slots ahead + return -1 first_cheap_slot = cheap_slots[0] - if first_cheap_slot == 0: - return -1 # Already in cheap slot - - # Sum expected PV surplus during cheap slots - total_cheap_surplus_wh = 0.0 - for i in cheap_slots: - if i < len(calc_input.production) and i < len(calc_input.consumption): - surplus = float(calc_input.production[i]) - float(calc_input.consumption[i]) - if surplus > 0: - total_cheap_surplus_wh += surplus * interval_hours - - if total_cheap_surplus_wh <= 0: - return -1 # No PV surplus expected during cheap slots - - # Reserve capacity (capped at full battery capacity) - target_reserve_wh = min(total_cheap_surplus_wh, self.common.max_capacity) - - additional_charging_allowed = calc_input.free_capacity - target_reserve_wh - - if additional_charging_allowed <= 0: - return 0 # Block PV charging — battery already too full - # Spread allowed charging evenly over slots before cheap window - wh_per_slot = additional_charging_allowed / first_cheap_slot - charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + if first_cheap_slot == 0: # Inside cheap window + total_surplus = sum_pv_surplus(cheap_slots) + if total_surplus <= calc_input.free_capacity: + return -1 # Battery can absorb everything + # Spread free capacity evenly over cheap slots + return int(calc_input.free_capacity / len(cheap_slots) / interval_hours) - return int(charge_rate_w) + # Before cheap window + total_surplus = sum_pv_surplus(cheap_slots) + if total_surplus <= 0: + return -1 + target_reserve = min(total_surplus, max_capacity) + additional_allowed = calc_input.free_capacity - target_reserve + if additional_allowed <= 0: + return 0 # Block charging + return int(additional_allowed / first_cheap_slot / interval_hours) ``` ### 3.4 Always-Allow-Discharge Region Skips Peak Shaving -When `stored_energy >= max_capacity * always_allow_discharge_limit`, the battery is in the "always allow discharge" region. In this region, peak shaving is **not applied** — the system is already at high SOC and the normal discharge logic takes over. This also avoids toggling issues when SOC fluctuates near 100%. +When `stored_energy >= max_capacity * always_allow_discharge_limit`, the battery is in the "always allow discharge" region. In this region, peak shaving is **not applied** - the system is already at high SOC and the normal discharge logic takes over. This also avoids toggling issues when SOC fluctuates near 100%. ### 3.5 Force Charge (MODE -1) Takes Priority Over Peak Shaving -If the price-based logic decides to force charge from grid (`charge_from_grid=True`), this overrides peak shaving. The force charge decision means energy is cheap enough to justify grid charging — peak shaving should not interfere. +If the price-based logic decides to force charge from grid (`charge_from_grid=True`), this overrides peak shaving. The force charge decision means energy is cheap enough to justify grid charging - peak shaving should not interfere. When this occurs, a warning is logged: ``` @@ -338,75 +341,82 @@ return inverter_control_settings **`_apply_peak_shaving()`:** -Peak shaving uses MODE 8 (`limit_battery_charge_rate` with `allow_discharge=True`). It is only applied when the main logic already allows discharge — meaning no upcoming high-price slots require preserving battery energy. This is the **price-based skip condition**: when the main logic set `allow_discharge=False`, the battery is being held for profitable future slots, and PV charging should not be throttled. +Peak shaving uses MODE 8 (`limit_battery_charge_rate` with `allow_discharge=True`). It is only applied when the main logic already allows discharge - meaning no upcoming high-price slots require preserving battery energy. + +The method dispatches to the appropriate sub-algorithms based on `peak_shaving_mode`. The **target-hour check** (`allow_full_battery_after`) applies to all modes and is checked early so no computation occurs past that hour. ```python def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): - """Limit PV charge rate using price-based and time-based algorithms. + """Limit PV charge rate based on the configured peak shaving mode. + + Mode behaviour (peak_shaving_mode): + 'time' - spread remaining capacity until allow_full_battery_after + 'price' - reserve capacity for upcoming cheap-price PV slots; + inside cheap window, spread if surplus > free capacity + 'combined' - both limits active, stricter one wins Skipped when: - - price_limit is None (primary disable condition) - - No production right now (nighttime) - - Currently in a cheap slot (prices[0] <= price_limit) — charge freely - - Past the target hour (allow_full_battery_after) - - Battery is in always_allow_discharge region (high SOC) - - Force charge from grid is active (MODE -1) + - 'price'/'combined' mode and price_limit is not configured + - No PV production right now (nighttime) + - Past allow_full_battery_after hour (all modes) + - Battery in always_allow_discharge region (high SOC) + - Force-charge from grid active (MODE -1) - Discharge not allowed (battery preserved for high-price hours) Note: EVCC checks (charging, connected+pv mode) are handled in core.py. """ + mode = self.calculation_parameters.peak_shaving_mode price_limit = self.calculation_parameters.peak_shaving_price_limit - # price_limit not configured → peak shaving disabled entirely - if price_limit is None: + # Price component needs price_limit configured + if mode in ('price', 'combined') and price_limit is None: return settings - # No production right now: skip calculation + # No production right now: skip if calc_input.production[0] <= 0: return settings - # Currently in cheap slot: charge freely to absorb PV surplus - if calc_input.prices[0] <= price_limit: - logger.debug('[PeakShaving] Skipped: currently in cheap slot (price=%.4f <= limit=%.4f)', - calc_input.prices[0], price_limit) - return settings - - # After target hour: no limit + # Past target hour: skip (applies to all modes) if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: return settings - # In always_allow_discharge region: skip peak shaving + # In always_allow_discharge region: skip if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): return settings - # Force charge takes priority over peak shaving + # Force charge takes priority if settings.charge_from_grid: - logger.warning('[PeakShaving] Skipped: force_charge (MODE -1) active') return settings - # Battery preserved for high-price hours — don't limit PV charging + # Battery preserved for high-price hours if not settings.allow_discharge: - logger.debug('[PeakShaving] Skipped: discharge not allowed') return settings - # Calculate both limits; take the stricter (lower non-negative) one - price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) - time_limit_w = self._calculate_peak_shaving_charge_limit(calc_input, calc_timestamp) + # Compute limits according to mode; price-based handles both before-cheap + # and in-cheap-window-overflow cases + price_limit_w = -1 + time_limit_w = -1 + + if mode in ('price', 'combined'): + price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) + if mode in ('time', 'combined'): + time_limit_w = self._calculate_peak_shaving_charge_limit(calc_input, calc_timestamp) candidates = [v for v in (price_limit_w, time_limit_w) if v >= 0] - charge_limit = min(candidates) if candidates else -1 + if not candidates: + return settings - if charge_limit >= 0: - if settings.limit_battery_charge_rate < 0: - settings.limit_battery_charge_rate = charge_limit - else: - settings.limit_battery_charge_rate = min( - settings.limit_battery_charge_rate, charge_limit) + charge_limit = min(candidates) + if settings.limit_battery_charge_rate < 0: + settings.limit_battery_charge_rate = charge_limit + else: + settings.limit_battery_charge_rate = min( + settings.limit_battery_charge_rate, charge_limit) return settings ``` -### 3.7 Data Flow — Extended `CalculationParameters` +### 3.7 Data Flow - Extended `CalculationParameters` Peak shaving configuration is passed via the existing `CalculationParameters` dataclass (consistent with existing interface pattern): @@ -421,26 +431,26 @@ class CalculationParameters: # Peak shaving parameters peak_shaving_enabled: bool = False peak_shaving_allow_full_after: int = 14 # Hour (0-23) - peak_shaving_price_limit: Optional[float] = None # Euro/kWh; None → peak shaving disabled + # 'time': target hour only | 'price': cheap-slot reservation | 'combined': both + peak_shaving_mode: str = 'combined' + peak_shaving_price_limit: Optional[float] = None # Euro/kWh; any numeric or None def __post_init__(self): if not 0 <= self.peak_shaving_allow_full_after <= 23: - raise ValueError( - f"peak_shaving_allow_full_after must be 0-23, " - f"got {self.peak_shaving_allow_full_after}" - ) - if self.peak_shaving_price_limit is not None and self.peak_shaving_price_limit < 0: - raise ValueError( - f"peak_shaving_price_limit must be >= 0, " - f"got {self.peak_shaving_price_limit}" - ) + raise ValueError(...) + valid_modes = ('time', 'price', 'combined') + if self.peak_shaving_mode not in valid_modes: + raise ValueError(...) + if (self.peak_shaving_price_limit is not None + and not isinstance(self.peak_shaving_price_limit, (int, float))): + raise ValueError(...) # must be numeric or None ``` -**No changes needed to `CalculationInput`** — the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. +**No changes needed to `CalculationInput`** - the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. --- -## 4. Logic Factory — `logic.py` +## 4. Logic Factory - `logic.py` The factory gains a new type `next`: @@ -477,7 +487,7 @@ The `NextLogic` supports the same expert tuning attributes as `DefaultLogic`. --- -## 5. Core Integration — `core.py` +## 5. Core Integration - `core.py` ### 5.1 Init @@ -497,6 +507,7 @@ calc_parameters = CalculationParameters( self.get_max_capacity(), peak_shaving_enabled=peak_shaving_config.get('enabled', False), peak_shaving_allow_full_after=peak_shaving_config.get('allow_full_battery_after', 14), + peak_shaving_mode=peak_shaving_config.get('mode', 'combined'), peak_shaving_price_limit=peak_shaving_config.get('price_limit', None), ) ``` @@ -526,25 +537,25 @@ if self.evcc_api is not None: ### 5.4 Mode Selection -In the mode selection block (after `logic.calculate()`), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed — peak shaving sets the limit in `InverterControlSettings` and the existing dispatch handles it. +In the mode selection block (after `logic.calculate()`), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed - peak shaving sets the limit in `InverterControlSettings` and the existing dispatch handles it. --- -## 6. MQTT API — Publish Peak Shaving State +## 6. MQTT API - Publish Peak Shaving State Publish topics: -- `{base}/peak_shaving/enabled` — boolean (`true`/`false`, plain text, retained) -- `{base}/peak_shaving/allow_full_battery_after` — integer hour 0-23 (plain text, retained) -- `{base}/peak_shaving/charge_limit` — current calculated charge limit in W (plain text, not retained, -1 if inactive) +- `{base}/peak_shaving/enabled` - boolean (`true`/`false`, plain text, retained) +- `{base}/peak_shaving/allow_full_battery_after` - integer hour 0-23 (plain text, retained) +- `{base}/peak_shaving/charge_limit` - current calculated charge limit in W (plain text, not retained, -1 if inactive) Settable topics: -- `{base}/peak_shaving/enabled/set` — accepts `true`/`false` -- `{base}/peak_shaving/allow_full_battery_after/set` — accepts integer 0-23 +- `{base}/peak_shaving/enabled/set` - accepts `true`/`false` +- `{base}/peak_shaving/allow_full_battery_after/set` - accepts integer 0-23 Home Assistant discovery: -- `peak_shaving/enabled` → switch entity -- `peak_shaving/allow_full_battery_after` → number entity (min: 0, max: 23, step: 1) -- `peak_shaving/charge_limit` → sensor entity (unit: W) +- `peak_shaving/enabled` -> switch entity +- `peak_shaving/allow_full_battery_after` -> number entity (min: 0, max: 23, step: 1) +- `peak_shaving/charge_limit` -> sensor entity (unit: W) QoS: default (0) for all topics (consistent with existing MQTT API). @@ -557,36 +568,36 @@ QoS: default (0) for all topics (consistent with existing MQTT API). ### 7.1 Logic Tests (`tests/batcontrol/logic/test_peak_shaving.py`) **Algorithm tests (`_calculate_peak_shaving_charge_limit`):** -- High PV surplus, small free capacity → low charge limit -- Low PV surplus, large free capacity → no limit (-1) -- PV surplus exactly matches free capacity → no limit (-1) -- Battery full (`free_capacity = 0`) → charge limit = 0 -- Past target hour → no limit (-1) -- 1 slot remaining → rate for that single slot -- Consumption reduces effective PV — e.g., 3kW PV, 2kW consumption = 1kW surplus +- High PV surplus, small free capacity -> low charge limit +- Low PV surplus, large free capacity -> no limit (-1) +- PV surplus exactly matches free capacity -> no limit (-1) +- Battery full (`free_capacity = 0`) -> charge limit = 0 +- Past target hour -> no limit (-1) +- 1 slot remaining -> rate for that single slot +- Consumption reduces effective PV - e.g., 3kW PV, 2kW consumption = 1kW surplus **Decision tests (`_apply_peak_shaving`):** -- `peak_shaving_enabled = False` → no change to settings -- `price_limit = None` → peak shaving disabled entirely -- Current production = 0 (nighttime) → peak shaving skipped -- Currently in cheap slot (`prices[0] <= price_limit`) → no charge limit applied -- `charge_from_grid = True` → peak shaving skipped, warning logged -- `allow_discharge = False` (battery preserved for high-price hours) → peak shaving skipped -- Battery in always_allow_discharge region → peak shaving skipped -- Before target hour, limit calculated → `limit_battery_charge_rate` set -- After target hour → no change -- Existing tighter limit from other logic → kept (more restrictive wins) -- Peak shaving limit tighter than existing → peak shaving limit applied +- `peak_shaving_enabled = False` -> no change to settings +- `price_limit = None` -> peak shaving disabled entirely +- Current production = 0 (nighttime) -> peak shaving skipped +- Currently in cheap slot (`prices[0] <= price_limit`) -> no charge limit applied +- `charge_from_grid = True` -> peak shaving skipped, warning logged +- `allow_discharge = False` (battery preserved for high-price hours) -> peak shaving skipped +- Battery in always_allow_discharge region -> peak shaving skipped +- Before target hour, limit calculated -> `limit_battery_charge_rate` set +- After target hour -> no change +- Existing tighter limit from other logic -> kept (more restrictive wins) +- Peak shaving limit tighter than existing -> peak shaving limit applied **Price-based algorithm tests (`_calculate_peak_shaving_charge_limit_price_based`):** -- No cheap slots → -1 -- Currently in cheap slot → -1 -- PV surplus in cheap slots ≤ 0 → -1 -- Cheap-slot surplus exceeds free capacity → block (0) -- Partial reserve spreads over remaining slots → rate calculation -- Free capacity well above reserve → charge rate returned +- No cheap slots -> -1 +- Currently in cheap slot -> -1 +- PV surplus in cheap slots <= 0 -> -1 +- Cheap-slot surplus exceeds free capacity -> block (0) +- Partial reserve spreads over remaining slots -> rate calculation +- Free capacity well above reserve -> charge rate returned - Consumption reduces cheap-slot surplus -- Both price-based and time-based configured → stricter limit wins +- Both price-based and time-based configured -> stricter limit wins **`CalculationParameters` tests:** - `peak_shaving_price_limit` defaults to `None` @@ -596,44 +607,44 @@ QoS: default (0) for all topics (consistent with existing MQTT API). ### 7.2 EVCC Tests (`tests/batcontrol/test_evcc_mode.py`) -- Topic derivation: `evcc/loadpoints/1/charging` → mode: `evcc/loadpoints/1/mode`, connected: `evcc/loadpoints/1/connected` -- Non-standard topic (not ending in `/charging`) → warning, no mode/connected sub +- Topic derivation: `evcc/loadpoints/1/charging` -> mode: `evcc/loadpoints/1/mode`, connected: `evcc/loadpoints/1/connected` +- Non-standard topic (not ending in `/charging`) -> warning, no mode/connected sub - `handle_mode_message` parses mode string correctly - `handle_connected_message` parses boolean correctly -- `evcc_ev_expects_pv_surplus`: connected=true + mode=pv → True -- `evcc_ev_expects_pv_surplus`: connected=true + mode=now → False -- `evcc_ev_expects_pv_surplus`: connected=false + mode=pv → False -- `evcc_ev_expects_pv_surplus`: no data received → False +- `evcc_ev_expects_pv_surplus`: connected=true + mode=pv -> True +- `evcc_ev_expects_pv_surplus`: connected=true + mode=now -> False +- `evcc_ev_expects_pv_surplus`: connected=false + mode=pv -> False +- `evcc_ev_expects_pv_surplus`: no data received -> False - Multi-loadpoint: one connected+pv is enough to return True -- Mode change from pv to now → `evcc_ev_expects_pv_surplus` changes to False +- Mode change from pv to now -> `evcc_ev_expects_pv_surplus` changes to False ### 7.3 Core EVCC Guard Tests (`tests/batcontrol/test_core.py`) -- EVCC actively charging + charge limit active → limit cleared to -1 -- EV connected in PV mode + charge limit active → limit cleared to -1 -- EVCC not charging and no PV mode → charge limit preserved -- No charge limit active (-1) + EVCC charging → no change (stays -1) +- EVCC actively charging + charge limit active -> limit cleared to -1 +- EV connected in PV mode + charge limit active -> limit cleared to -1 +- EVCC not charging and no PV mode -> charge limit preserved +- No charge limit active (-1) + EVCC charging -> no change (stays -1) ### 7.4 Config Tests -- `type: next` → creates `NextLogic` instance -- `type: default` → creates `DefaultLogic` instance (unchanged) -- With `peak_shaving` section → `CalculationParameters` fields set correctly -- Without `peak_shaving` section → `peak_shaving_enabled = False` (default) +- `type: next` -> creates `NextLogic` instance +- `type: default` -> creates `DefaultLogic` instance (unchanged) +- With `peak_shaving` section -> `CalculationParameters` fields set correctly +- Without `peak_shaving` section -> `peak_shaving_enabled = False` (default) --- ## 8. Implementation Order -1. **Config** — Add `peak_shaving` section to dummy config, add `type: next` option -2. **Data model** — Extend `CalculationParameters` with peak shaving fields -3. **EVCC** — Add mode and connected topic subscriptions, `evcc_ev_expects_pv_surplus` property -4. **NextLogic** — New file `next.py`: copy DefaultLogic, add `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` -5. **Logic factory** — Add `type: next` → `NextLogic` in `logic.py` -6. **Core** — Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard -7. **MQTT** — Publish topics + settable topics + HA discovery +1. **Config** - Add `peak_shaving` section to dummy config, add `type: next` option +2. **Data model** - Extend `CalculationParameters` with peak shaving fields +3. **EVCC** - Add mode and connected topic subscriptions, `evcc_ev_expects_pv_surplus` property +4. **NextLogic** - New file `next.py`: copy DefaultLogic, add `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` +5. **Logic factory** - Add `type: next` -> `NextLogic` in `logic.py` +6. **Core** - Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard +7. **MQTT** - Publish topics + settable topics + HA discovery 8. **Tests** -9. **Documentation** — Write `docs/peak_shaving.md` covering feature overview, configuration, EVCC interaction, algorithm explanation, and known limitations +9. **Documentation** - Write `docs/peak_shaving.md` covering feature overview, configuration, EVCC interaction, algorithm explanation, and known limitations --- @@ -643,17 +654,17 @@ QoS: default (0) for all topics (consistent with existing MQTT API). |------|--------| | `config/batcontrol_config_dummy.yaml` | Add `peak_shaving` section | | `src/batcontrol/logic/logic_interface.py` | Add peak shaving fields to `CalculationParameters` | -| `src/batcontrol/logic/next.py` | **New** — `NextLogic` class with peak shaving | -| `src/batcontrol/logic/logic.py` | Add `type: next` → `NextLogic` | +| `src/batcontrol/logic/next.py` | **New** - `NextLogic` class with peak shaving | +| `src/batcontrol/logic/logic.py` | Add `type: next` -> `NextLogic` | | `src/batcontrol/evcc_api.py` | Add mode + connected topic subscriptions, `evcc_ev_expects_pv_surplus` | | `src/batcontrol/core.py` | Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard | | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | -| `tests/batcontrol/logic/test_peak_shaving.py` | New — algorithm + decision tests | -| `tests/batcontrol/test_evcc_mode.py` | New — mode/connected topic tests | +| `tests/batcontrol/logic/test_peak_shaving.py` | New - algorithm + decision tests | +| `tests/batcontrol/test_evcc_mode.py` | New - mode/connected topic tests | | `tests/batcontrol/test_core.py` | Add EVCC peak shaving guard tests | -| `docs/WIKI_peak_shaving.md` | New — feature documentation | +| `docs/WIKI_peak_shaving.md` | New - feature documentation | -**Not modified:** `default.py` (untouched — peak shaving is in `next.py`) +**Not modified:** `default.py` (untouched - peak shaving is in `next.py`) --- @@ -661,7 +672,7 @@ QoS: default (0) for all topics (consistent with existing MQTT API). 1. **New independent logic class:** Peak shaving lives in `NextLogic` (`type: next`), not as a modification to `DefaultLogic`. This keeps the stable default path untouched and allows the next logic to evolve independently. `NextLogic` is a full copy of `DefaultLogic` with peak shaving added. -2. **EVCC integration:** Handled in `core.py` (not logic layer). Peak shaving is disabled when `evcc_is_charging` OR (`connected=true` AND `mode=pv`). The mode and connected topics are derived from the existing `loadpoint_topic` config by stripping `/charging`. No `chargePower` subscription — it was reported as unreliable. +2. **EVCC integration:** Handled in `core.py` (not logic layer). Peak shaving is disabled when `evcc_is_charging` OR (`connected=true` AND `mode=pv`). The mode and connected topics are derived from the existing `loadpoint_topic` config by stripping `/charging`. No `chargePower` subscription - it was reported as unreliable. 3. **Grid charging interaction (MODE -1 vs MODE 8):** `force_charge` takes priority. If price logic triggers grid charging during peak shaving hours, a warning is logged but grid charging proceeds. @@ -673,7 +684,7 @@ QoS: default (0) for all topics (consistent with existing MQTT API). 7. **Price-based algorithm is the primary driver:** Peak shaving is **disabled** when `price_limit` is `None`. This makes the operator opt in explicitly by setting a price threshold. The price-based algorithm identifies upcoming cheap-price slots and reserves enough free capacity to absorb their full PV surplus. This is the economically motivated core of peak shaving: buy cheap energy via full PV absorption, not by throttling charging arbitrarily. -8. **Currently-in-cheap-slot skip:** When the current slot is cheap (`prices[0] <= price_limit`), no charge limit is applied — the battery should absorb as much PV as possible during this window. This is checked in `_apply_peak_shaving` before calling either sub-algorithm. +8. **Currently-in-cheap-slot skip:** When the current slot is cheap (`prices[0] <= price_limit`), no charge limit is applied - the battery should absorb as much PV as possible during this window. This is checked in `_apply_peak_shaving` before calling either sub-algorithm. 9. **Two-limit combination (stricter wins):** The price-based and time-based components are independent. When both are configured, the final limit is `min(price_limit_w, time_limit_w)` over non-negative values. This ensures neither algorithm can inadvertently allow more charging than the other intends. diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index f83f4cdf..f83a60c7 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -35,19 +35,26 @@ battery_control_expert: #-------------------------- # Peak Shaving -# Manages PV battery charging rate so the battery fills up gradually, -# reaching full capacity by a target hour (allow_full_battery_after). +# Manages PV battery charging rate to limit PV charging before cheap-price +# or high-production hours so the battery can absorb as much PV as possible. # Requires logic type 'next' in battery_control section. # +# mode: +# 'time' - limit by target hour only (allow_full_battery_after) +# 'price' - reserve capacity for cheap-price slots (price_limit required) +# 'combined' - both active, stricter limit wins [default] +# # price_limit: slots where price (Euro/kWh) is at or below this value are # treated as cheap PV windows. Battery capacity is reserved so the PV -# surplus during those cheap slots can be fully absorbed. When price_limit -# is not set (commented out), peak shaving is disabled. +# surplus during those cheap slots can be fully absorbed. +# Use -1 to disable the price component without changing mode. +# Required for mode 'price' and 'combined'; ignored for mode 'time'. #-------------------------- peak_shaving: enabled: false - allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour - price_limit: 0.05 # Euro/kWh — keep battery empty for slots at or below this price + mode: combined # 'time' | 'price' | 'combined' + allow_full_battery_after: 14 # Hour (0-23) - battery should be full by this hour + price_limit: 0.05 # Euro/kWh - keep battery empty for slots at or below this price #-------------------------- # Inverter diff --git a/docs/WIKI_peak_shaving.md b/docs/WIKI_peak_shaving.md index 526a8564..9182ed46 100644 --- a/docs/WIKI_peak_shaving.md +++ b/docs/WIKI_peak_shaving.md @@ -4,7 +4,7 @@ Peak shaving manages PV battery charging rate so the battery fills up gradually, reaching full capacity by a configurable target hour (`allow_full_battery_after`). This prevents the battery from being full too early in the day. -**Problem:** All PV systems produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest — and for newer installations, feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible. +**Problem:** All PV systems produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest - and for newer installations, feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible. ## Configuration @@ -24,8 +24,9 @@ battery_control: ```yaml peak_shaving: enabled: false - allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour - price_limit: 0.05 # Euro/kWh — keep free capacity for cheap-price slots + mode: combined # 'time' | 'price' | 'combined' + allow_full_battery_after: 14 # Hour (0-23) - battery should be full by this hour + price_limit: 0.05 # Euro/kWh - keep battery empty for slots at or below this price ``` ### Parameters @@ -33,73 +34,84 @@ peak_shaving: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `enabled` | bool | `false` | Enable/disable peak shaving | +| `mode` | string | `combined` | Algorithm mode: `time`, `price`, or `combined` | | `allow_full_battery_after` | int | `14` | Target hour (0-23) for the battery to be full | -| `price_limit` | float | `null` | Price threshold (€/kWh); slots at or below this price are "cheap". **Required** — peak shaving is disabled when not set | +| `price_limit` | float | `null` | Price threshold (Euro/kWh); required for modes `price` and `combined` | -**`allow_full_battery_after`** controls when the battery is allowed to be 100% full: -- **Before this hour:** PV charge rate may be limited to prevent early fill -- **At/after this hour:** No PV charge limit, battery is allowed to reach full charge +**`mode`** selects which algorithm components are active: +- **`time`** - time-based only: spread free capacity evenly until `allow_full_battery_after`. `price_limit` not required. +- **`price`** - price-based only: reserve capacity for cheap-price slots (in-window surplus overflow handled). Requires `price_limit`. +- **`combined`** (default) - both components; stricter limit wins. Requires `price_limit`. -**`price_limit`** controls when cheap slots are recognised: -- **Not set (default):** Peak shaving is completely disabled — no charge limit is ever applied -- **During a cheap slot** (`current price <= price_limit`): No limit is applied — absorb as much PV as possible during these valuable hours -- **Before cheap slots:** PV charging is throttled so the battery has free capacity ready to absorb the cheap-slot PV surplus +**`allow_full_battery_after`** controls when the battery is allowed to be 100% full: +- **Before this hour:** PV charge rate may be limited +- **At/after this hour:** No limit for all modes (target-hour check applies globally) ## How It Works ### Algorithm -Peak shaving uses two independent components that each compute a PV charge rate limit. The **stricter (lower non-negative)** limit wins. +Peak shaving uses one or two components depending on `mode`. The stricter (lower non-negative) limit wins when both are active. -**Component 1: Price-Based (primary)** +**Component 1: Time-Based** (modes `time` and `combined`) -This is the main driver. Before cheap-price hours arrive, the battery is kept partially empty so the cheap slots' PV surplus fills it completely: +Spreads remaining free capacity evenly until `allow_full_battery_after`: ``` -cheap_slots = slots where price <= price_limit -target_reserve = min(sum of PV surplus in cheap slots, battery max capacity) -additional_allowed = free_capacity - target_reserve +slots_remaining = slots until allow_full_battery_after +pv_surplus = sum of max(production - consumption, 0) for remaining slots -if additional_allowed <= 0: → block PV charging (rate = 0) -else: → spread additional_allowed over slots before cheap window +if pv_surplus > free_capacity: + charge_limit = free_capacity / slots_remaining (Wh/slot -> W) ``` -Example: prices = [10, 10, 5, 3, 0, 0], production peak at slots 4 and 5 → reserve free capacity now so slots 4 and 5 can fill the battery completely from PV. +**Component 2: Price-Based** (modes `price` and `combined`) -**Component 2: Time-Based (secondary)** +Before cheap window - reserves free capacity so cheap-slot PV surplus fills battery completely: -Ensures the battery does not fill before `allow_full_battery_after`, independent of pricing: +``` +cheap_slots = slots where price <= price_limit +target_reserve = min(sum of PV surplus in cheap slots, max_capacity) +additional_allowed = free_capacity - target_reserve +if additional_allowed <= 0: -> block charging (rate = 0) +else: -> spread additional_allowed over slots before window ``` -slots_remaining = slots until allow_full_battery_after -pv_surplus = sum of max(production - consumption, 0) for remaining slots -if pv_surplus > free_capacity: - charge_limit = free_capacity / slots_remaining (Wh/slot → W) +Inside cheap window - if total PV surplus in the window exceeds free capacity, the battery cannot fully absorb everything. Charging is spread evenly over the cheap slots so the battery fills gradually instead of hitting 100% in the first slot: + +``` +if total_cheap_surplus > free_capacity: + charge_limit = free_capacity / num_cheap_slots (Wh/slot -> W) +else: + no limit (-1) ``` -Both limits are computed and the stricter one is applied using **MODE 8** (`limit_battery_charge_rate`). Peak shaving only applies when discharge is already allowed by the main price-based logic. +The charge limit is applied using **MODE 8** (`limit_battery_charge_rate`). Peak shaving only applies when discharge is already allowed by the main price-based logic. ### Skip Conditions Peak shaving is automatically skipped when: -1. **`price_limit` not configured** — peak shaving is disabled entirely (primary disable condition) -2. **Currently in a cheap slot** (`prices[0] <= price_limit`) — battery should absorb PV freely -3. **No PV production** — nighttime, no action needed -4. **Past the target hour** — battery is allowed to be full -5. **Battery in always_allow_discharge region** — SOC is already high -6. **Grid charging active (MODE -1)** — force charge takes priority -7. **Discharge not allowed** — battery is being preserved for upcoming high-price hours; PV charging should not be throttled so the battery can charge as fast as possible -8. **EVCC is actively charging** — EV consumes the excess PV -9. **EV connected in PV mode** — EVCC will absorb PV surplus +1. **`price_limit` not configured** for mode `price` or `combined` - price component disabled +2. **No PV production** - nighttime, no action needed +3. **Past the target hour** (`allow_full_battery_after`) - applies to all modes; no limit +4. **Battery in always_allow_discharge region** - SOC is already high +5. **Grid charging active (MODE -1)** - force charge takes priority +6. **Discharge not allowed** - battery is being preserved for upcoming high-price hours +7. **EVCC is actively charging** - EV consumes the excess PV +8. **EV connected in PV mode** - EVCC will absorb PV surplus + +The price-based component also returns no limit when: +- No cheap slots exist in the forecast +- Inside cheap window and total surplus fits in free capacity (absorb freely) ### EVCC Interaction When an EV charger is managed by EVCC: -- **EV actively charging** (`charging=true`): Peak shaving is disabled — the EV consumes the excess PV -- **EV connected in PV mode** (`connected=true` AND `mode=pv`): Peak shaving is disabled — EVCC will naturally absorb surplus PV when the threshold is reached +- **EV actively charging** (`charging=true`): Peak shaving is disabled - the EV consumes the excess PV +- **EV connected in PV mode** (`connected=true` AND `mode=pv`): Peak shaving is disabled - EVCC will naturally absorb surplus PV when the threshold is reached - **EV disconnects or mode changes**: Peak shaving is re-enabled The EVCC integration derives `mode` and `connected` topics automatically from the configured `loadpoint_topic` by replacing `/charging` with `/mode` and `/connected`. @@ -125,9 +137,9 @@ The EVCC integration derives `mode` and `connected` topics automatically from th The following HA entities are automatically created: -- **Peak Shaving Enabled** — switch entity -- **Peak Shaving Allow Full After** — number entity (0-23, step 1) -- **Peak Shaving Charge Limit** — sensor entity (unit: W) +- **Peak Shaving Enabled** - switch entity +- **Peak Shaving Allow Full After** - number entity (0-23, step 1) +- **Peak Shaving Charge Limit** - sensor entity (unit: W) ## Known Limitations diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 50d4a7ae..6f0e4f3b 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -528,6 +528,7 @@ def run(self): peak_shaving_enabled=peak_shaving_config.get('enabled', False), peak_shaving_allow_full_after=peak_shaving_config.get( 'allow_full_battery_after', 14), + peak_shaving_mode=peak_shaving_config.get('mode', 'combined'), peak_shaving_price_limit=peak_shaving_config.get('price_limit', None), ) diff --git a/src/batcontrol/logic/logic_interface.py b/src/batcontrol/logic/logic_interface.py index 4ef56b60..9febeca2 100644 --- a/src/batcontrol/logic/logic_interface.py +++ b/src/batcontrol/logic/logic_interface.py @@ -24,9 +24,14 @@ class CalculationParameters: # Peak shaving parameters peak_shaving_enabled: bool = False peak_shaving_allow_full_after: int = 14 # Hour (0-23) - # Slots where price <= this limit (€/kWh) are treated as cheap PV windows. - # Battery capacity is reserved so those slots can be absorbed fully. - # When None, peak shaving is disabled regardless of the enabled flag. + # Operating mode: + # 'time' - limit by target hour only (allow_full_battery_after) + # 'price' - limit by cheap-slot reservation only (price_limit required) + # 'combined' - both limits active, stricter one wins + peak_shaving_mode: str = 'combined' + # Slots where price (Euro/kWh) is at or below this value are treated as + # cheap PV windows. -1 or any numeric value accepted; None disables + # price-based component. peak_shaving_price_limit: Optional[float] = None def __post_init__(self): @@ -35,11 +40,17 @@ def __post_init__(self): f"peak_shaving_allow_full_after must be 0-23, " f"got {self.peak_shaving_allow_full_after}" ) + valid_modes = ('time', 'price', 'combined') + if self.peak_shaving_mode not in valid_modes: + raise ValueError( + f"peak_shaving_mode must be one of {valid_modes}, " + f"got '{self.peak_shaving_mode}'" + ) if (self.peak_shaving_price_limit is not None - and self.peak_shaving_price_limit < 0): + and not isinstance(self.peak_shaving_price_limit, (int, float))): raise ValueError( - f"peak_shaving_price_limit must be >= 0, " - f"got {self.peak_shaving_price_limit}" + f"peak_shaving_price_limit must be numeric or None, " + f"got {type(self.peak_shaving_price_limit).__name__}" ) @dataclass diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index e0fe38d0..a103907b 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -1,4 +1,4 @@ -"""NextLogic — Extended battery control logic with peak shaving. +"""NextLogic - Extended battery control logic with peak shaving. This module provides the NextLogic class, which extends the DefaultLogic behavior with a peak shaving post-processing step. Peak shaving manages @@ -210,55 +210,42 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, calc_input: CalculationInput, calc_timestamp: datetime.datetime ) -> InverterControlSettings: - """Limit PV charge rate to spread battery charging until target hour. + """Limit PV charge rate based on the configured peak shaving mode. - Peak shaving uses MODE 8 (limit_battery_charge_rate with - allow_discharge=True). It is only applied when the main logic - already allows discharge — meaning no upcoming high-price slots - require preserving battery energy. - - The algorithm has two components that are combined (stricter wins): - - Price-based: reserve battery capacity for upcoming cheap-price PV - slots so they can be absorbed fully (primary driver). Requires - peak_shaving_price_limit to be configured; disabled when None. - - Time-based: spread remaining capacity over slots until - allow_full_battery_after (secondary constraint). + Mode behaviour (peak_shaving_mode): + 'time' - spread remaining capacity until allow_full_battery_after + 'price' - reserve capacity for upcoming cheap-price PV slots; + inside cheap window, spread if surplus > free capacity + 'combined' - both limits active, stricter one wins Skipped when: - - price_limit is not configured (peak shaving disabled) - - No production right now (nighttime) - - Currently in a cheap slot (price <= price_limit) — charge freely - - Past the target hour (allow_full_battery_after) - - Battery is in always_allow_discharge region (high SOC) - - Force charge from grid is active (MODE -1) + - 'price'/'combined' mode and price_limit is not configured + - No PV production right now (nighttime) + - Past allow_full_battery_after hour (all modes) + - Battery in always_allow_discharge region (high SOC) + - Force-charge from grid active (MODE -1) - Discharge not allowed (battery preserved for high-price hours) Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. """ + mode = self.calculation_parameters.peak_shaving_mode price_limit = self.calculation_parameters.peak_shaving_price_limit - # price_limit not configured → peak shaving is disabled - if price_limit is None: - logger.debug('[PeakShaving] Skipped: price_limit not configured') + # Price component needs price_limit configured + if mode in ('price', 'combined') and price_limit is None: + logger.debug('[PeakShaving] Skipped: price_limit not configured for mode %s', mode) return settings - # No production right now: skip calculation (avoid unnecessary work at night) + # No production right now: skip if calc_input.production[0] <= 0: return settings - # Currently in a cheap slot — charge battery freely - if calc_input.prices[0] <= price_limit: - logger.debug('[PeakShaving] Skipped: currently in cheap-price slot ' - '(price %.3f <= limit %.3f)', - calc_input.prices[0], price_limit) - return settings - - # After target hour: no limit + # Past target hour: skip (applies to all modes) if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: return settings - # In always_allow_discharge region: skip peak shaving + # In always_allow_discharge region: skip if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): logger.debug('[PeakShaving] Skipped: battery in always_allow_discharge region') return settings @@ -269,42 +256,41 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, 'grid charging takes priority') return settings - # Battery preserved for high-price hours — don't limit PV charging + # Battery preserved for high-price hours -- don't limit PV charging if not settings.allow_discharge: logger.debug('[PeakShaving] Skipped: discharge not allowed, ' 'battery preserved for high-price hours') return settings - # Compute both limits, take stricter (lower non-negative value) - price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) - time_limit_w = self._calculate_peak_shaving_charge_limit(calc_input, calc_timestamp) + # Compute limits according to mode + price_limit_w = -1 + time_limit_w = -1 + + if mode in ('price', 'combined'): + price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) + if mode in ('time', 'combined'): + time_limit_w = self._calculate_peak_shaving_charge_limit(calc_input, calc_timestamp) - if price_limit_w < 0 and time_limit_w < 0: + candidates = [v for v in (price_limit_w, time_limit_w) if v >= 0] + if not candidates: logger.debug('[PeakShaving] Evaluated: no limit needed') return settings - if price_limit_w >= 0 and time_limit_w >= 0: - charge_limit = min(price_limit_w, time_limit_w) - elif price_limit_w >= 0: - charge_limit = price_limit_w - else: - charge_limit = time_limit_w + charge_limit = min(candidates) - # Apply PV charge rate limit + # Apply charge rate limit (keep more restrictive if one already exists) if settings.limit_battery_charge_rate < 0: - # No existing limit — apply peak shaving limit settings.limit_battery_charge_rate = charge_limit else: - # Keep the more restrictive limit settings.limit_battery_charge_rate = min( settings.limit_battery_charge_rate, charge_limit) # Note: allow_discharge is already True here (checked above). # MODE 8 requires allow_discharge=True to work correctly. - logger.info('[PeakShaving] PV charge limit: %d W ' + logger.info('[PeakShaving] mode=%s, PV limit: %d W ' '(price-based=%s W, time-based=%s W, full by %d:00)', - settings.limit_battery_charge_rate, + mode, settings.limit_battery_charge_rate, price_limit_w if price_limit_w >= 0 else 'off', time_limit_w if time_limit_w >= 0 else 'off', self.calculation_parameters.peak_shaving_allow_full_after) @@ -315,17 +301,17 @@ def _calculate_peak_shaving_charge_limit_price_based( self, calc_input: CalculationInput) -> int: """Reserve battery free capacity for upcoming cheap-price PV slots. - Finds upcoming slots where price <= peak_shaving_price_limit and - calculates a charge rate limit that keeps enough free capacity to - absorb the expected PV surplus during those cheap slots fully. + When currently inside a cheap window (first cheap slot == 0): + If total PV surplus in the window exceeds free capacity, spread + the free capacity evenly over all remaining cheap slots so the + battery fills gradually rather than hitting 100% in the first slot. + If surplus <= free capacity no limit is needed. - Algorithm: - 1. Find upcoming cheap slots (price <= price_limit). - 2. Sum PV surplus during cheap slots → target_reserve_wh. - 3. additional_charging_allowed = free_capacity - target_reserve_wh - 4. If additional_charging_allowed <= 0: block PV charging (return 0). - 5. Spread additional_charging_allowed evenly over slots before - the cheap window starts. + When before the cheap window: + 1. Sum PV surplus during cheap slots -> target_reserve_wh. + 2. additional_allowed = free_capacity - target_reserve_wh. + 3. If additional_allowed <= 0: block PV charging (return 0). + 4. Spread additional_allowed evenly over slots before the window. Returns: int: charge rate limit in W, or -1 if no limit needed. @@ -334,17 +320,37 @@ def _calculate_peak_shaving_charge_limit_price_based( prices = calc_input.prices interval_hours = self.interval_minutes / 60.0 - # Find all cheap slots cheap_slots = [i for i, p in enumerate(prices) if p is not None and p <= price_limit] if not cheap_slots: - return -1 # No cheap slots ahead + return -1 # No cheap slots in the forecast first_cheap_slot = cheap_slots[0] + + # -- Currently inside cheap window -------------------------------- # if first_cheap_slot == 0: - return -1 # Already in cheap slot (caller checks this too) + total_cheap_surplus_wh = 0.0 + for i in cheap_slots: + if i < len(calc_input.production) and i < len(calc_input.consumption): + surplus = (float(calc_input.production[i]) + - float(calc_input.consumption[i])) + if surplus > 0: + total_cheap_surplus_wh += surplus * interval_hours + + if total_cheap_surplus_wh <= calc_input.free_capacity: + return -1 # Battery can absorb everything, no limit needed + + # Surplus exceeds free capacity: spread evenly over cheap slots + charge_rate_w = (calc_input.free_capacity + / len(cheap_slots) / interval_hours) + logger.debug( + '[PeakShaving] In cheap window: surplus %.0f Wh > free %.0f Wh, ' + 'spreading over %d slots -> %d W', + total_cheap_surplus_wh, calc_input.free_capacity, + len(cheap_slots), int(charge_rate_w)) + return int(charge_rate_w) - # Calculate expected PV surplus during cheap slots + # -- Before cheap window: reserve capacity for it ----------------- # total_cheap_surplus_wh = 0.0 for i in cheap_slots: if i < len(calc_input.production) and i < len(calc_input.consumption): @@ -358,23 +364,22 @@ def _calculate_peak_shaving_charge_limit_price_based( # Reserve capacity (capped at full battery capacity) target_reserve_wh = min(total_cheap_surplus_wh, self.common.max_capacity) - # How much more can we charge before the cheap window starts? additional_charging_allowed = calc_input.free_capacity - target_reserve_wh if additional_charging_allowed <= 0: logger.debug( - '[PeakShaving] Price-based: battery full relative to cheap-window ' + '[PeakShaving] Price-based: battery too full for cheap-window ' 'reserve (free=%.0f Wh, reserve=%.0f Wh), blocking PV charge', calc_input.free_capacity, target_reserve_wh) return 0 # Spread allowed charging evenly over slots before cheap window wh_per_slot = additional_charging_allowed / first_cheap_slot - charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + charge_rate_w = wh_per_slot / interval_hours # Wh/slot -> W logger.debug( '[PeakShaving] Price-based: cheap window at slot %d, ' - 'reserve=%.0f Wh, allowed=%.0f Wh → limit=%d W', + 'reserve=%.0f Wh, allowed=%.0f Wh -> %d W', first_cheap_slot, target_reserve_wh, additional_charging_allowed, int(charge_rate_w)) @@ -426,7 +431,7 @@ def _calculate_peak_shaving_charge_limit(self, calc_input: CalculationInput, # Spread charging evenly across remaining slots wh_per_slot = free_capacity / slots_remaining - charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + charge_rate_w = wh_per_slot / interval_hours # Wh/slot -> W return int(charge_rate_w) diff --git a/tests/batcontrol/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py index 95e06a7a..80d7dbb6 100644 --- a/tests/batcontrol/logic/test_peak_shaving.py +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -60,12 +60,12 @@ def _make_input(self, production, consumption, stored_energy, ) def test_high_surplus_small_free_capacity(self): - """High PV surplus, small free capacity → low charge limit.""" + """High PV surplus, small free capacity -> low charge limit.""" # 8 hours until 14:00 starting from 06:00 - # 5000 W PV per slot, 500 W consumption → 4500 W surplus per slot + # 5000 W PV per slot, 500 W consumption -> 4500 W surplus per slot # 8 slots * 4500 Wh = 36000 Wh surplus total # free_capacity = 2000 Wh - # charge limit = 2000 / 8 = 250 Wh/slot → 250 W (60 min intervals) + # charge limit = 2000 / 8 = 250 Wh/slot -> 250 W (60 min intervals) production = [5000] * 8 + [0] * 4 consumption = [500] * 8 + [0] * 4 calc_input = self._make_input(production, consumption, @@ -78,10 +78,10 @@ def test_high_surplus_small_free_capacity(self): self.assertEqual(limit, 250) def test_low_surplus_large_free_capacity(self): - """Low PV surplus, large free capacity → no limit (-1).""" - # 1000 W PV, 800 W consumption → 200 W surplus + """Low PV surplus, large free capacity -> no limit (-1).""" + # 1000 W PV, 800 W consumption -> 200 W surplus # 8 slots * 200 Wh = 1600 Wh surplus - # free_capacity = 5000 Wh → surplus < free → no limit + # free_capacity = 5000 Wh -> surplus < free -> no limit production = [1000] * 8 + [0] * 4 consumption = [800] * 8 + [0] * 4 calc_input = self._make_input(production, consumption, @@ -94,7 +94,7 @@ def test_low_surplus_large_free_capacity(self): self.assertEqual(limit, -1) def test_surplus_equals_free_capacity(self): - """PV surplus exactly matches free capacity → no limit (-1).""" + """PV surplus exactly matches free capacity -> no limit (-1).""" production = [3000] * 8 + [0] * 4 consumption = [1000] * 8 + [0] * 4 # surplus per slot = 2000 W, 8 slots = 16000 Wh @@ -108,7 +108,7 @@ def test_surplus_equals_free_capacity(self): self.assertEqual(limit, -1) def test_battery_full(self): - """Battery full (free_capacity = 0) → charge limit = 0.""" + """Battery full (free_capacity = 0) -> charge limit = 0.""" production = [5000] * 8 + [0] * 4 consumption = [500] * 8 + [0] * 4 calc_input = self._make_input(production, consumption, @@ -121,7 +121,7 @@ def test_battery_full(self): self.assertEqual(limit, 0) def test_past_target_hour(self): - """Past target hour → no limit (-1).""" + """Past target hour -> no limit (-1).""" production = [5000] * 8 consumption = [500] * 8 calc_input = self._make_input(production, consumption, @@ -134,10 +134,10 @@ def test_past_target_hour(self): self.assertEqual(limit, -1) def test_one_slot_remaining(self): - """1 slot remaining → rate for that single slot.""" - # Target is 14:00, current time is 13:00 → 1 slot - # PV surplus: 5000 - 500 = 4500 W → 4500 Wh > free_cap 1000 - # limit = 1000 / 1 = 1000 Wh/slot → 1000 W + """1 slot remaining -> rate for that single slot.""" + # Target is 14:00, current time is 13:00 -> 1 slot + # PV surplus: 5000 - 500 = 4500 W -> 4500 Wh > free_cap 1000 + # limit = 1000 / 1 = 1000 Wh/slot -> 1000 W production = [5000] * 2 consumption = [500] * 2 calc_input = self._make_input(production, consumption, @@ -151,10 +151,10 @@ def test_one_slot_remaining(self): def test_consumption_reduces_surplus(self): """High consumption reduces effective PV surplus.""" - # 3000 W PV, 2000 W consumption → 1000 W surplus + # 3000 W PV, 2000 W consumption -> 1000 W surplus # 8 slots * 1000 Wh = 8000 Wh surplus - # free_capacity = 4000 Wh → surplus > free - # limit = 4000 / 8 = 500 Wh/slot → 500 W + # free_capacity = 4000 Wh -> surplus > free + # limit = 4000 / 8 = 500 Wh/slot -> 500 W production = [3000] * 8 + [0] * 4 consumption = [2000] * 8 + [0] * 4 calc_input = self._make_input(production, consumption, @@ -172,11 +172,11 @@ def test_15min_intervals(self): interval_minutes=15) logic_15.set_calculation_parameters(self.params) - # Target 14:00, current 13:00 → 4 slots of 15 min + # Target 14:00, current 13:00 -> 4 slots of 15 min # surplus = 4000 W per slot, interval_hours = 0.25 # surplus Wh per slot = 4000 * 0.25 = 1000 Wh # total surplus = 4 * 1000 = 4000 Wh - # free_capacity = 1000 Wh → surplus > free + # free_capacity = 1000 Wh -> surplus > free # wh_per_slot = 1000 / 4 = 250 Wh # charge_rate_w = 250 / 0.25 = 1000 W production = [4500] * 4 @@ -210,6 +210,7 @@ def setUp(self): max_capacity=self.max_capacity, peak_shaving_enabled=True, peak_shaving_allow_full_after=14, + peak_shaving_mode='combined', peak_shaving_price_limit=0.05, # required; tests use high prices so no cheap slots ) self.logic.set_calculation_parameters(self.params) @@ -225,7 +226,7 @@ def _make_settings(self, allow_discharge=True, charge_from_grid=False, def _make_input(self, production, consumption, stored_energy, free_capacity): - # Use high prices (10.0) so no slot is "cheap" — only time-based limit applies. + # Use high prices (10.0) so no slot is "cheap" - only time-based limit applies. prices = np.ones(len(production)) * 10.0 return CalculationInput( production=np.array(production, dtype=float), @@ -237,7 +238,7 @@ def _make_input(self, production, consumption, stored_energy, ) def test_nighttime_no_production(self): - """No production (nighttime) → peak shaving skipped.""" + """No production (nighttime) -> peak shaving skipped.""" settings = self._make_settings() calc_input = self._make_input( [0, 0, 0, 0], [500, 500, 500, 500], @@ -248,7 +249,7 @@ def test_nighttime_no_production(self): self.assertEqual(result.limit_battery_charge_rate, -1) def test_after_target_hour(self): - """After target hour → no change.""" + """After target hour -> no change.""" settings = self._make_settings() calc_input = self._make_input( [5000, 5000], [500, 500], @@ -259,7 +260,7 @@ def test_after_target_hour(self): self.assertEqual(result.limit_battery_charge_rate, -1) def test_force_charge_takes_priority(self): - """Force charge (MODE -1) → peak shaving skipped.""" + """Force charge (MODE -1) -> peak shaving skipped.""" settings = self._make_settings( allow_discharge=False, charge_from_grid=True, charge_rate=3000) calc_input = self._make_input( @@ -272,7 +273,7 @@ def test_force_charge_takes_priority(self): self.assertEqual(result.limit_battery_charge_rate, -1) def test_always_allow_discharge_region(self): - """Battery in always_allow_discharge region → skip peak shaving.""" + """Battery in always_allow_discharge region -> skip peak shaving.""" settings = self._make_settings() # stored_energy=9500 > 10000 * 0.9 = 9000 calc_input = self._make_input( @@ -284,9 +285,9 @@ def test_always_allow_discharge_region(self): self.assertEqual(result.limit_battery_charge_rate, -1) def test_peak_shaving_applies_limit(self): - """Before target hour, limit calculated → limit set.""" + """Before target hour, limit calculated -> limit set.""" settings = self._make_settings() - # 6 slots (6..14), 5000W PV, 500W consumption → 4500W surplus + # 6 slots (6..14), 5000W PV, 500W consumption -> 4500W surplus # surplus Wh = 6 * 4500 = 27000 > free 3000 # limit = 3000 / 6 = 500 W calc_input = self._make_input( @@ -299,7 +300,7 @@ def test_peak_shaving_applies_limit(self): self.assertTrue(result.allow_discharge) def test_existing_tighter_limit_kept(self): - """Existing limit is tighter → keep existing.""" + """Existing limit is tighter -> keep existing.""" settings = self._make_settings(limit_battery_charge_rate=200) calc_input = self._make_input( [5000] * 8, [500] * 8, @@ -310,7 +311,7 @@ def test_existing_tighter_limit_kept(self): self.assertEqual(result.limit_battery_charge_rate, 200) def test_peak_shaving_limit_tighter(self): - """Peak shaving limit is tighter than existing → peak shaving limit applied.""" + """Peak shaving limit is tighter than existing -> peak shaving limit applied.""" settings = self._make_settings(limit_battery_charge_rate=5000) calc_input = self._make_input( [5000] * 8, [500] * 8, @@ -321,7 +322,7 @@ def test_peak_shaving_limit_tighter(self): self.assertEqual(result.limit_battery_charge_rate, 500) def test_discharge_not_allowed_skips_peak_shaving(self): - """Discharge not allowed (battery preserved for high-price hours) → skip.""" + """Discharge not allowed (battery preserved for high-price hours) -> skip.""" settings = self._make_settings(allow_discharge=False) calc_input = self._make_input( [5000] * 8, [500] * 8, @@ -333,7 +334,7 @@ def test_discharge_not_allowed_skips_peak_shaving(self): self.assertFalse(result.allow_discharge) def test_price_limit_none_disables_peak_shaving(self): - """price_limit=None → peak shaving disabled entirely.""" + """price_limit=None with mode='combined' -> peak shaving disabled entirely.""" params = CalculationParameters( max_charging_from_grid_limit=0.79, min_price_difference=0.05, @@ -341,6 +342,7 @@ def test_price_limit_none_disables_peak_shaving(self): max_capacity=self.max_capacity, peak_shaving_enabled=True, peak_shaving_allow_full_after=14, + peak_shaving_mode='combined', peak_shaving_price_limit=None, ) self.logic.set_calculation_parameters(params) @@ -353,12 +355,13 @@ def test_price_limit_none_disables_peak_shaving(self): self.assertEqual(result.limit_battery_charge_rate, -1) def test_currently_in_cheap_slot_no_limit(self): - """Current slot is cheap (price <= price_limit) → charge freely.""" + """In cheap slot, surplus fits in battery -> no limit applied.""" settings = self._make_settings() prices = np.zeros(8) # all slots cheap (price=0 <= 0.05) + # production=200W, surplus=1600 Wh total < free=5000 Wh -> no limit calc_input = CalculationInput( - production=np.array([5000] * 8, dtype=float), - consumption=np.array([500] * 8, dtype=float), + production=np.array([200] * 8, dtype=float), + consumption=np.zeros(8, dtype=float), prices=prices, stored_energy=5000, stored_usable_energy=4500, @@ -369,6 +372,78 @@ def test_currently_in_cheap_slot_no_limit(self): result = self.logic._apply_peak_shaving(settings, calc_input, ts) self.assertEqual(result.limit_battery_charge_rate, -1) + def test_currently_in_cheap_slot_surplus_overflow(self): + """In cheap slot, surplus > free capacity -> spread evenly over cheap slots. + + prices all 0 (cheap), production=3000W, consumption=0, 8 slots. + Total surplus = 8 * 3000 = 24000 Wh > free=5000 Wh. + Price-based: spread 5000 / 8 slots = 625 W. + Time-based (mode=combined): 6 slots to target, 6*3000=18000>5000 -> 5000/6=833 W. + min(625, 833) = 625. + """ + settings = self._make_settings() + prices = np.zeros(8) + calc_input = CalculationInput( + production=np.array([3000] * 8, dtype=float), + consumption=np.zeros(8, dtype=float), + prices=prices, + stored_energy=5000, + stored_usable_energy=4500, + free_capacity=5000, + ) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, 625) + + def test_mode_time_only_ignores_price_limit(self): + """Mode 'time': price_limit=None does not disable peak shaving.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + peak_shaving_mode='time', + peak_shaving_price_limit=None, # not needed for 'time' mode + ) + self.logic.set_calculation_parameters(params) + settings = self._make_settings() + calc_input = self._make_input([5000] * 8, [500] * 8, + stored_energy=7000, free_capacity=3000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + # time-based: 6 slots, surplus=6*4500=27000>3000 -> limit=3000/6=500 W + self.assertEqual(result.limit_battery_charge_rate, 500) + + def test_mode_price_only_no_time_limit(self): + """Mode 'price': only price-based component fires. + + With no cheap slots ahead (prices all 10 > 0.05), price-based + returns -1 and no limit is applied even if time-based would fire. + """ + params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + peak_shaving_mode='price', + peak_shaving_price_limit=0.05, + ) + self.logic.set_calculation_parameters(params) + settings = self._make_settings() + # High prices -> no cheap slots -> price-based returns -1 -> no limit + calc_input = self._make_input([5000] * 8, [500] * 8, + stored_energy=7000, free_capacity=3000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + class TestPeakShavingDisabled(unittest.TestCase): """Test that peak shaving does nothing when disabled.""" @@ -393,7 +468,7 @@ def setUp(self): self.logic.set_calculation_parameters(self.params) def test_disabled_no_limit(self): - """peak_shaving_enabled=False → no change to settings.""" + """peak_shaving_enabled=False -> no change to settings.""" production = np.array([5000] * 8, dtype=float) consumption = np.array([500] * 8, dtype=float) prices = np.zeros(8) @@ -424,25 +499,25 @@ def setUp(self): ) def test_default_type(self): - """type: default → DefaultLogic.""" + """type: default -> DefaultLogic.""" config = {'battery_control': {'type': 'default'}} logic = Logic.create_logic(config, datetime.timezone.utc) self.assertIsInstance(logic, DefaultLogic) def test_next_type(self): - """type: next → NextLogic.""" + """type: next -> NextLogic.""" config = {'battery_control': {'type': 'next'}} logic = Logic.create_logic(config, datetime.timezone.utc) self.assertIsInstance(logic, NextLogic) def test_missing_type_defaults_to_default(self): - """No type key → DefaultLogic.""" + """No type key -> DefaultLogic.""" config = {} logic = Logic.create_logic(config, datetime.timezone.utc) self.assertIsInstance(logic, DefaultLogic) def test_unknown_type_raises(self): - """Unknown type → RuntimeError.""" + """Unknown type -> RuntimeError.""" config = {'battery_control': {'type': 'unknown'}} with self.assertRaises(RuntimeError): Logic.create_logic(config, datetime.timezone.utc) @@ -465,7 +540,7 @@ class TestCalculationParametersPeakShaving(unittest.TestCase): """Test CalculationParameters peak shaving fields.""" def test_defaults(self): - """Without peak shaving args → defaults.""" + """Without peak shaving args -> defaults.""" params = CalculationParameters( max_charging_from_grid_limit=0.8, min_price_difference=0.05, @@ -474,9 +549,10 @@ def test_defaults(self): ) self.assertFalse(params.peak_shaving_enabled) self.assertEqual(params.peak_shaving_allow_full_after, 14) + self.assertEqual(params.peak_shaving_mode, 'combined') def test_explicit_values(self): - """With explicit peak shaving args → stored.""" + """With explicit peak shaving args -> stored.""" params = CalculationParameters( max_charging_from_grid_limit=0.8, min_price_difference=0.05, @@ -542,15 +618,59 @@ def test_price_limit_zero_allowed(self): ) self.assertEqual(params.peak_shaving_price_limit, 0.0) - def test_price_limit_negative_raises(self): - """Negative price_limit raises ValueError.""" + def test_price_limit_negative_one_allowed(self): + """price_limit=-1 is valid (effectively disables cheap-slot detection).""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_price_limit=-1, + ) + self.assertEqual(params.peak_shaving_price_limit, -1) + + def test_price_limit_arbitrary_negative_allowed(self): + """Arbitrary negative price_limit is accepted (only numeric check).""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_price_limit=-0.5, + ) + self.assertEqual(params.peak_shaving_price_limit, -0.5) + + def test_mode_default_is_combined(self): + """peak_shaving_mode defaults to 'combined'.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + ) + self.assertEqual(params.peak_shaving_mode, 'combined') + + def test_mode_valid_values(self): + """'time', 'price', 'combined' are all accepted.""" + for mode in ('time', 'price', 'combined'): + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_mode=mode, + ) + self.assertEqual(params.peak_shaving_mode, mode) + + def test_mode_invalid_raises(self): + """Unknown mode string raises ValueError.""" with self.assertRaises(ValueError): CalculationParameters( max_charging_from_grid_limit=0.8, min_price_difference=0.05, min_price_difference_rel=0.1, max_capacity=10000, - peak_shaving_price_limit=-0.01, + peak_shaving_mode='invalid', ) @@ -574,6 +694,7 @@ def setUp(self): max_capacity=self.max_capacity, peak_shaving_enabled=True, peak_shaving_allow_full_after=14, + peak_shaving_mode='price', peak_shaving_price_limit=0.05, ) self.logic.set_calculation_parameters(self.params) @@ -598,7 +719,7 @@ def test_surplus_exceeds_free_capacity_blocks_charging(self): Surplus in cheap slots: 3000+3000 = 6000 Wh (interval=1h, consumption=0). target_reserve = min(6000, 10000) = 6000 Wh. free_capacity = 4000 Wh. - additional_allowed = 4000 - 6000 = -2000 → block charging (return 0). + additional_allowed = 4000 - 6000 = -2000 -> block charging (return 0). """ prices = [10, 10, 10, 8, 3, 0, 0, 1] production = [500, 500, 500, 500, 500, 3000, 3000, 500] @@ -612,33 +733,47 @@ def test_partial_reserve_spread_over_slots(self): Battery has 8000 Wh free, target_reserve = min(6000, 10000) = 6000 Wh. additional_allowed = 8000 - 6000 = 2000 Wh. first_cheap_slot = 4 (4 slots before cheap window). - wh_per_slot = 2000 / 4 = 500 Wh → rate = 500 W (60 min intervals). + wh_per_slot = 2000 / 4 = 500 Wh -> rate = 500 W (60 min intervals). """ prices = [10, 10, 10, 10, 0, 0, 1, 2] - # cheap surplus slots 4,5: 3000W each, interval=1h → 3000 Wh each + # cheap surplus slots 4,5: 3000W each, interval=1h -> 3000 Wh each production = [500, 500, 500, 500, 3000, 3000, 500, 500] calc_input = self._make_input(production, prices, free_capacity=8000) result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) self.assertEqual(result, 500) def test_no_cheap_slots_returns_minus_one(self): - """No cheap slots in prices → -1.""" + """No cheap slots in prices -> -1.""" prices = [10, 10, 10, 10, 10, 10] production = [3000] * 6 calc_input = self._make_input(production, prices, free_capacity=5000) result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) self.assertEqual(result, -1) - def test_currently_in_cheap_slot_returns_minus_one(self): - """first_cheap_slot = 0 (current slot is cheap) → -1.""" + def test_currently_in_cheap_slot_fits_no_limit(self): + """first_cheap_slot = 0, surplus fits in battery -> -1 (no limit).""" prices = [0, 0, 10, 10] - production = [5000, 5000, 500, 500] + # surplus per cheap slot = 200W * 1h = 200 Wh each; total = 400 Wh < free 5000 + production = [200, 200, 500, 500] calc_input = self._make_input(production, prices, free_capacity=5000) result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) self.assertEqual(result, -1) + def test_currently_in_cheap_slot_surplus_overflow_spreads(self): + """first_cheap_slot = 0, surplus > free capacity -> spread over cheap slots. + + cheap slots: [0, 1], production=4000W each, consumption=0, interval=1h. + total_surplus = 2 * 4000 = 8000 Wh > free = 5000 Wh. + charge_rate = 5000 / 2 / 1h = 2500 W. + """ + prices = [0, 0, 10, 10] + production = [4000, 4000, 500, 500] + calc_input = self._make_input(production, prices, free_capacity=5000) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, 2500) + def test_zero_pv_surplus_in_cheap_slots_returns_minus_one(self): - """Cheap slots have no PV surplus (consumption >= production) → -1.""" + """Cheap slots have no PV surplus (consumption >= production) -> -1.""" prices = [10, 10, 0, 0] production = [500, 500, 200, 200] consumption = [500, 500, 300, 300] # net = 0 or negative in cheap slots @@ -650,8 +785,8 @@ def test_zero_pv_surplus_in_cheap_slots_returns_minus_one(self): def test_free_capacity_well_above_reserve_gives_rate(self): """ cheap surplus = 1000 Wh, target_reserve = 1000 Wh. - free_capacity = 6000 Wh → additional_allowed = 5000 Wh. - first_cheap_slot = 5 → wh_per_slot = 1000 W. + free_capacity = 6000 Wh -> additional_allowed = 5000 Wh. + first_cheap_slot = 5 -> wh_per_slot = 1000 W. """ prices = [10, 10, 10, 10, 10, 0, 10] production = [500, 500, 500, 500, 500, 1000, 500] @@ -661,9 +796,9 @@ def test_free_capacity_well_above_reserve_gives_rate(self): def test_consumption_reduces_cheap_surplus(self): """ - Cheap slot: production=5000W, consumption=3000W → surplus=2000 Wh. - target_reserve = 2000, free=5000 → additional=3000 Wh. - first_cheap_slot=2 → rate = 3000/2 = 1500 W. + Cheap slot: production=5000W, consumption=3000W -> surplus=2000 Wh. + target_reserve = 2000, free=5000 -> additional=3000 Wh. + first_cheap_slot=2 -> rate = 3000/2 = 1500 W. """ prices = [10, 10, 0, 10] production = [500, 500, 5000, 500] @@ -675,18 +810,24 @@ def test_consumption_reduces_cheap_surplus(self): def test_combine_price_and_time_limits_stricter_wins(self): """ - Both limits active: time-based and price-based give different rates. - The stricter (lower) limit must be applied. + Both limits active (mode='combined'): stricter limit wins. Setup at 08:00, target 14:00 (6 slots remaining). High prices except slot 4 (cheap). - price-based: cheap surplus=4000Wh at 4, free=3000 → allowed=reserved-capped - Just check final result <= both individual limits. """ + params_combined = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + peak_shaving_mode='combined', + peak_shaving_price_limit=0.05, + ) logic = NextLogic(timezone=datetime.timezone.utc, interval_minutes=60) - logic.set_calculation_parameters(self.params) + logic.set_calculation_parameters(params_combined) ts = datetime.datetime(2025, 6, 20, 8, 0, 0, tzinfo=datetime.timezone.utc) - # prices: slots 0-3 high, slot 4 cheap, slots 5-7 high again prices = np.array([10, 10, 10, 10, 0, 10, 10, 10], dtype=float) production = np.array([500, 500, 500, 500, 5000, 5000, 500, 500], dtype=float) calc_input = CalculationInput( From fb5a58521eaa474e61c879f8a81334b1cf93a15b Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 16:25:58 +0100 Subject: [PATCH 11/35] Add more logging and fix EVCC / evcc spelling --- PLAN.md | 48 +++++++++---------- docs/15-min-transform.md | 16 +++---- docs/WIKI_peak_shaving.md | 12 ++--- src/batcontrol/core.py | 25 ++++++---- src/batcontrol/dynamictariff/evcc.py | 6 +-- src/batcontrol/evcc_api.py | 6 +-- src/batcontrol/forecastsolar/evcc_solar.py | 2 +- src/batcontrol/logic/next.py | 2 +- .../dynamictariff/test_baseclass.py | 8 ++-- tests/batcontrol/dynamictariff/test_evcc.py | 6 +-- tests/batcontrol/test_core.py | 12 ++--- tests/batcontrol/test_evcc_mode.py | 4 +- 12 files changed, 77 insertions(+), 70 deletions(-) diff --git a/PLAN.md b/PLAN.md index abf76995..9b97a592 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,9 +6,9 @@ Add peak shaving to batcontrol: manage PV battery charging rate so the battery f **Problem:** All PV systems in the country produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest and - for newer installations - feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible into the battery, rather than feeding it into the grid during peak hours. -**EVCC interaction:** +**evcc interaction:** - When an EV is actively charging (`charging=true`), peak shaving is disabled - the EV consumes the excess PV. -- When an EV is connected and the loadpoint mode is `pv`, peak shaving is also disabled - EVCC+EV will naturally absorb excessive PV energy once the surplus threshold is reached. +- When an EV is connected and the loadpoint mode is `pv`, peak shaving is also disabled - evcc+EV will naturally absorb excessive PV energy once the surplus threshold is reached. - If the EV disconnects or the mode changes away from `pv`, peak shaving is re-enabled. Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging. @@ -57,17 +57,17 @@ The `type: next` logic includes all existing `DefaultLogic` behavior plus peak s --- -## 2. EVCC Integration - Loadpoint Mode & Connected State +## 2. evcc Integration - Loadpoint Mode & Connected State ### 2.1 Approach -Peak shaving is disabled when **any** of the following EVCC conditions are true: +Peak shaving is disabled when **any** of the following evcc conditions are true: 1. **`charging = true`** - EV is actively charging (already tracked) 2. **`connected = true` AND `mode = pv`** - EV is plugged in and waiting for PV surplus -The EVCC check is handled in `core.py`, **not** in the logic layer. EVCC is an external integration concern, same pattern as `discharge_blocked`. +The evcc check is handled in `core.py`, **not** in the logic layer. evcc is an external integration concern, same pattern as `discharge_blocked`. -### 2.2 New EVCC Topics - Derived from `loadpoint_topic` +### 2.2 New evcc Topics - Derived from `loadpoint_topic` The `mode` and `connected` topics are derived from the existing `loadpoint_topic` config by stripping `/charging` and appending the relevant suffix: @@ -140,7 +140,7 @@ def evcc_ev_expects_pv_surplus(self) -> bool: **`shutdown`:** Unsubscribe from mode and connected topics. -**EVCC offline reset:** When EVCC goes offline (status message received), mode and connected state are reset to prevent stale values: +**evcc offline reset:** When evcc goes offline (status message received), mode and connected state are reset to prevent stale values: ```python for root in list(self.evcc_loadpoint_mode.keys()): self.evcc_loadpoint_mode[root] = None @@ -363,7 +363,7 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): - Force-charge from grid active (MODE -1) - Discharge not allowed (battery preserved for high-price hours) - Note: EVCC checks (charging, connected+pv mode) are handled in core.py. + Note: evcc checks (charging, connected+pv mode) are handled in core.py. """ mode = self.calculation_parameters.peak_shaving_mode price_limit = self.calculation_parameters.peak_shaving_price_limit @@ -512,14 +512,14 @@ calc_parameters = CalculationParameters( ) ``` -### 5.3 EVCC Peak Shaving Guard +### 5.3 evcc Peak Shaving Guard -The EVCC check is handled in `core.py`, keeping EVCC concerns out of the logic layer. This follows the same pattern as `discharge_blocked` (line 536-541 in current code). +The evcc check is handled in `core.py`, keeping evcc concerns out of the logic layer. This follows the same pattern as `discharge_blocked` (line 536-541 in current code). -After `logic.calculate()` returns and before mode dispatch, peak shaving is overridden if EVCC conditions require it: +After `logic.calculate()` returns and before mode dispatch, peak shaving is overridden if evcc conditions require it: ```python -# EVCC disables peak shaving (handled in core, not logic) +# evcc disables peak shaving (handled in core, not logic) if self.evcc_api is not None: evcc_disable_peak_shaving = ( self.evcc_api.evcc_is_charging or @@ -527,7 +527,7 @@ if self.evcc_api is not None: ) if evcc_disable_peak_shaving and inverter_settings.limit_battery_charge_rate >= 0: if self.evcc_api.evcc_is_charging: - logger.debug('[PeakShaving] Disabled: EVCC is actively charging') + logger.debug('[PeakShaving] Disabled: evcc is actively charging') else: logger.debug('[PeakShaving] Disabled: EV connected in PV mode') inverter_settings.limit_battery_charge_rate = -1 @@ -605,7 +605,7 @@ QoS: default (0) for all topics (consistent with existing MQTT API). - Zero allowed (free price slots) - Negative value raises `ValueError` -### 7.2 EVCC Tests (`tests/batcontrol/test_evcc_mode.py`) +### 7.2 evcc Tests (`tests/batcontrol/test_evcc_mode.py`) - Topic derivation: `evcc/loadpoints/1/charging` -> mode: `evcc/loadpoints/1/mode`, connected: `evcc/loadpoints/1/connected` - Non-standard topic (not ending in `/charging`) -> warning, no mode/connected sub @@ -618,12 +618,12 @@ QoS: default (0) for all topics (consistent with existing MQTT API). - Multi-loadpoint: one connected+pv is enough to return True - Mode change from pv to now -> `evcc_ev_expects_pv_surplus` changes to False -### 7.3 Core EVCC Guard Tests (`tests/batcontrol/test_core.py`) +### 7.3 Core evcc Guard Tests (`tests/batcontrol/test_core.py`) -- EVCC actively charging + charge limit active -> limit cleared to -1 +- evcc actively charging + charge limit active -> limit cleared to -1 - EV connected in PV mode + charge limit active -> limit cleared to -1 -- EVCC not charging and no PV mode -> charge limit preserved -- No charge limit active (-1) + EVCC charging -> no change (stays -1) +- evcc not charging and no PV mode -> charge limit preserved +- No charge limit active (-1) + evcc charging -> no change (stays -1) ### 7.4 Config Tests @@ -638,13 +638,13 @@ QoS: default (0) for all topics (consistent with existing MQTT API). 1. **Config** - Add `peak_shaving` section to dummy config, add `type: next` option 2. **Data model** - Extend `CalculationParameters` with peak shaving fields -3. **EVCC** - Add mode and connected topic subscriptions, `evcc_ev_expects_pv_surplus` property +3. **evcc** - Add mode and connected topic subscriptions, `evcc_ev_expects_pv_surplus` property 4. **NextLogic** - New file `next.py`: copy DefaultLogic, add `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` 5. **Logic factory** - Add `type: next` -> `NextLogic` in `logic.py` -6. **Core** - Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard +6. **Core** - Wire peak shaving config into `CalculationParameters`, evcc peak shaving guard 7. **MQTT** - Publish topics + settable topics + HA discovery 8. **Tests** -9. **Documentation** - Write `docs/peak_shaving.md` covering feature overview, configuration, EVCC interaction, algorithm explanation, and known limitations +9. **Documentation** - Write `docs/peak_shaving.md` covering feature overview, configuration, evcc interaction, algorithm explanation, and known limitations --- @@ -657,11 +657,11 @@ QoS: default (0) for all topics (consistent with existing MQTT API). | `src/batcontrol/logic/next.py` | **New** - `NextLogic` class with peak shaving | | `src/batcontrol/logic/logic.py` | Add `type: next` -> `NextLogic` | | `src/batcontrol/evcc_api.py` | Add mode + connected topic subscriptions, `evcc_ev_expects_pv_surplus` | -| `src/batcontrol/core.py` | Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard | +| `src/batcontrol/core.py` | Wire peak shaving config into `CalculationParameters`, evcc peak shaving guard | | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | | `tests/batcontrol/logic/test_peak_shaving.py` | New - algorithm + decision tests | | `tests/batcontrol/test_evcc_mode.py` | New - mode/connected topic tests | -| `tests/batcontrol/test_core.py` | Add EVCC peak shaving guard tests | +| `tests/batcontrol/test_core.py` | Add evcc peak shaving guard tests | | `docs/WIKI_peak_shaving.md` | New - feature documentation | **Not modified:** `default.py` (untouched - peak shaving is in `next.py`) @@ -672,7 +672,7 @@ QoS: default (0) for all topics (consistent with existing MQTT API). 1. **New independent logic class:** Peak shaving lives in `NextLogic` (`type: next`), not as a modification to `DefaultLogic`. This keeps the stable default path untouched and allows the next logic to evolve independently. `NextLogic` is a full copy of `DefaultLogic` with peak shaving added. -2. **EVCC integration:** Handled in `core.py` (not logic layer). Peak shaving is disabled when `evcc_is_charging` OR (`connected=true` AND `mode=pv`). The mode and connected topics are derived from the existing `loadpoint_topic` config by stripping `/charging`. No `chargePower` subscription - it was reported as unreliable. +2. **evcc integration:** Handled in `core.py` (not logic layer). Peak shaving is disabled when `evcc_is_charging` OR (`connected=true` AND `mode=pv`). The mode and connected topics are derived from the existing `loadpoint_topic` config by stripping `/charging`. No `chargePower` subscription - it was reported as unreliable. 3. **Grid charging interaction (MODE -1 vs MODE 8):** `force_charge` takes priority. If price logic triggers grid charging during peak shaving hours, a warning is logged but grid charging proceeds. diff --git a/docs/15-min-transform.md b/docs/15-min-transform.md index e5d7668c..f10c2aab 100644 --- a/docs/15-min-transform.md +++ b/docs/15-min-transform.md @@ -533,7 +533,7 @@ class FCSolar(ForecastSolarBase): from .baseclass import ForecastSolarBase class EvccSolar(ForecastSolarBase): - """EVCC solar provider - returns 15-minute data.""" + """evcc solar provider - returns 15-minute data.""" def __init__(self, config: dict): super().__init__(config) @@ -541,7 +541,7 @@ class EvccSolar(ForecastSolarBase): # ... rest of init ... def _fetch_forecast(self) -> Dict[int, float]: - """Fetch 15-minute forecast from EVCC API.""" + """Fetch 15-minute forecast from evcc API.""" # Existing API call logic # Returns: {0: 250, 1: 300, 2: 350, ...} # Wh per 15-min response = self._call_evcc_api() @@ -712,7 +712,7 @@ from .baseclass import ForecastSolarBase class EvccSolar(ForecastSolarBase): def __init__(self, config: dict): super().__init__(config) - self.native_resolution = 15 # EVCC provides 15-min data + self.native_resolution = 15 # evcc provides 15-min data def _fetch_forecast(self) -> dict[int, float]: # Return native 15-minute data @@ -976,10 +976,10 @@ class Tibber(DynamicTariffBase): class EvccTariff(DynamicTariffBase): def __init__(self, config: dict): super().__init__(config) - self.native_resolution = 15 # EVCC provides 15-min data + self.native_resolution = 15 # evcc provides 15-min data def _fetch_prices(self) -> Dict[int, float]: - """Fetch 15-minute prices from EVCC API.""" + """Fetch 15-minute prices from evcc API.""" # Existing API logic # Old code averaged to hourly - now returns native 15-min return self._parse_15min_data() @@ -1032,7 +1032,7 @@ class Energyforecast(DynamicTariffBase): **API Documentation**: - **Awattar API**: https://www.awattar.de/services/api - **Tibber API**: https://developer.tibber.com/docs/guides/pricing -- **EVCC API**: https://docs.evcc.io/docs/reference/configuration/messaging#grid-tariff +- **evcc API**: https://docs.evcc.io/docs/reference/configuration/messaging#grid-tariff - **Energyforecast API**: https://www.energyforecast.de/api/v1/predictions/next_48_hours **Price Handling Note**: @@ -2328,7 +2328,7 @@ def upsample_forecast(hourly_data: dict, interval_minutes: int, - **Q**: Which electricity tariff provider(s) do you currently use? - [ ] Awattar (Germany/Austria) - [ ] Tibber (Nordic countries, Germany, Netherlands) - - [ ] EVCC integration + - [ ] evcc integration - [ ] Other: _______________ - **Q**: What is your tariff's price update frequency? @@ -2754,7 +2754,7 @@ Instead of each provider implementing upsampling/downsampling logic, we use a ** │ │ │ ├─────┬─────┬───┼───┬───────┬───┼────┬────┐ │ │ │ │ │ │ │ │ │ -FCSolar │ EVCC│ Awattar Tibber EVCC│ CSV │ HA +FCSolar │ evcc│ Awattar Tibber evcc│ CSV │ HA Prognose Solar Tariff Profile Forecast ``` diff --git a/docs/WIKI_peak_shaving.md b/docs/WIKI_peak_shaving.md index 9182ed46..648355da 100644 --- a/docs/WIKI_peak_shaving.md +++ b/docs/WIKI_peak_shaving.md @@ -99,22 +99,22 @@ Peak shaving is automatically skipped when: 4. **Battery in always_allow_discharge region** - SOC is already high 5. **Grid charging active (MODE -1)** - force charge takes priority 6. **Discharge not allowed** - battery is being preserved for upcoming high-price hours -7. **EVCC is actively charging** - EV consumes the excess PV -8. **EV connected in PV mode** - EVCC will absorb PV surplus +7. **evcc is actively charging** - EV consumes the excess PV +8. **EV connected in PV mode** - evcc will absorb PV surplus The price-based component also returns no limit when: - No cheap slots exist in the forecast - Inside cheap window and total surplus fits in free capacity (absorb freely) -### EVCC Interaction +### evcc Interaction -When an EV charger is managed by EVCC: +When an EV charger is managed by evcc: - **EV actively charging** (`charging=true`): Peak shaving is disabled - the EV consumes the excess PV -- **EV connected in PV mode** (`connected=true` AND `mode=pv`): Peak shaving is disabled - EVCC will naturally absorb surplus PV when the threshold is reached +- **EV connected in PV mode** (`connected=true` AND `mode=pv`): Peak shaving is disabled - evcc will naturally absorb surplus PV when the threshold is reached - **EV disconnects or mode changes**: Peak shaving is re-enabled -The EVCC integration derives `mode` and `connected` topics automatically from the configured `loadpoint_topic` by replacing `/charging` with `/mode` and `/connected`. +The evcc integration derives `mode` and `connected` topics automatically from the configured `loadpoint_topic` by replacing `/charging` with `/mode` and `/connected`. ## MQTT API diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 6f0e4f3b..0371d4a2 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -61,6 +61,7 @@ def __init__(self, configdict: dict): self.last_mode = None self.last_charge_rate = 0 self._limit_battery_charge_rate = -1 # Dynamic battery charge rate limit (-1 = no limit) + self._evcc_peak_shaving_disabled = False # Tracks last evcc-disable state for log messages self.last_prices = None self.last_consumption = None self.last_production = None @@ -557,20 +558,26 @@ def run(self): logger.debug('Discharge blocked due to external lock') inverter_settings.allow_discharge = False - # EVCC disables peak shaving (handled in core, not logic) + # evcc disables peak shaving (handled in core, not logic) if self.evcc_api is not None: evcc_disable_peak_shaving = ( self.evcc_api.evcc_is_charging or self.evcc_api.evcc_ev_expects_pv_surplus ) - if evcc_disable_peak_shaving and inverter_settings.limit_battery_charge_rate >= 0: - if self.evcc_api.evcc_is_charging: - logger.debug('[PeakShaving] Disabled: EVCC is actively charging') - else: - logger.debug('[PeakShaving] Disabled: EV connected in PV mode') - inverter_settings.limit_battery_charge_rate = -1 - - # Publish peak shaving charge limit (after EVCC guard may have cleared it) + if evcc_disable_peak_shaving: + if inverter_settings.limit_battery_charge_rate >= 0: + if self.evcc_api.evcc_is_charging: + logger.debug('[PeakShaving] Disabled: evcc is actively charging') + else: + logger.debug('[PeakShaving] Disabled: EV connected in PV mode') + inverter_settings.limit_battery_charge_rate = -1 + else: + if self._evcc_peak_shaving_disabled: + logger.debug('[PeakShaving] Re-enabled: evcc no longer blocking ' + '(EV disconnected or mode changed away from pv)') + self._evcc_peak_shaving_disabled = evcc_disable_peak_shaving + + # Publish peak shaving charge limit (after evcc guard may have cleared it) peak_shaving_enabled = peak_shaving_config.get('enabled', False) if self.mqtt_api is not None and peak_shaving_enabled: self.mqtt_api.publish_peak_shaving_charge_limit( diff --git a/src/batcontrol/dynamictariff/evcc.py b/src/batcontrol/dynamictariff/evcc.py index af8ee025..228e002f 100644 --- a/src/batcontrol/dynamictariff/evcc.py +++ b/src/batcontrol/dynamictariff/evcc.py @@ -35,13 +35,13 @@ class Evcc(DynamicTariffBaseclass): Inherits from DynamicTariffBaseclass Native resolution: 15 minutes - EVCC provides 15-minute price data natively. + evcc provides 15-minute price data natively. Baseclass handles averaging to hourly if target_resolution=60. """ def __init__(self, timezone, url, min_time_between_API_calls=60, target_resolution: int = 60): - # EVCC provides native 15-minute data + # evcc provides native 15-minute data super().__init__( timezone, min_time_between_API_calls, @@ -118,7 +118,7 @@ def _get_prices_native(self) -> dict[int, float]: prices[rel_interval] = price logger.debug( - 'EVCC: Retrieved %d prices at 15-min resolution (hour-aligned)', + 'evcc: Retrieved %d prices at 15-min resolution (hour-aligned)', len(prices) ) return prices diff --git a/src/batcontrol/evcc_api.py b/src/batcontrol/evcc_api.py index 483f19f9..4794d985 100644 --- a/src/batcontrol/evcc_api.py +++ b/src/batcontrol/evcc_api.py @@ -77,8 +77,8 @@ def __init__(self, config: dict): self.evcc_is_charging = False self.evcc_loadpoint_status = {} - self.evcc_loadpoint_mode = {} # topic_root → mode string ("pv", "now", "minpv", "off") - self.evcc_loadpoint_connected = {} # topic_root → bool + self.evcc_loadpoint_mode = {} # topic_root -> mode string ("pv", "now", "minpv", "off") + self.evcc_loadpoint_connected = {} # topic_root -> bool self.list_topics_mode = [] # derived mode topics self.list_topics_connected = [] # derived connected topics @@ -367,7 +367,7 @@ def handle_mode_message(self, message): mode = message.payload.decode('utf-8').strip().lower() old_mode = self.evcc_loadpoint_mode.get(root) if old_mode != mode: - logger.info('Loadpoint %s mode changed: %s → %s', root, old_mode, mode) + logger.info('Loadpoint %s mode changed: %s -> %s', root, old_mode, mode) self.evcc_loadpoint_mode[root] = mode def handle_connected_message(self, message): diff --git a/src/batcontrol/forecastsolar/evcc_solar.py b/src/batcontrol/forecastsolar/evcc_solar.py index e1669f69..3731d05a 100644 --- a/src/batcontrol/forecastsolar/evcc_solar.py +++ b/src/batcontrol/forecastsolar/evcc_solar.py @@ -63,7 +63,7 @@ def __init__(self, pvinstallations, timezone, min_time_between_api_calls, api_de """ super().__init__(pvinstallations, timezone, min_time_between_api_calls, api_delay, target_resolution=target_resolution, - native_resolution=15) # EVCC provides 15-minute data + native_resolution=15) # evcc provides 15-minute data # Extract URL from pvinstallations config if not pvinstallations or not isinstance(pvinstallations, list): diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index a103907b..c22e0363 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -226,7 +226,7 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, - Force-charge from grid active (MODE -1) - Discharge not allowed (battery preserved for high-price hours) - Note: EVCC checks (charging, connected+pv mode) are handled in + Note: evcc checks (charging, connected+pv mode) are handled in core.py, not here. """ mode = self.calculation_parameters.peak_shaving_mode diff --git a/tests/batcontrol/dynamictariff/test_baseclass.py b/tests/batcontrol/dynamictariff/test_baseclass.py index 2456ac1a..56b4284b 100644 --- a/tests/batcontrol/dynamictariff/test_baseclass.py +++ b/tests/batcontrol/dynamictariff/test_baseclass.py @@ -255,23 +255,23 @@ def test_tibber_initialization_15min(self, timezone): class TestEvccProvider: - """Tests for EVCC provider""" + """Tests for evcc provider""" @pytest.fixture def timezone(self): return pytz.timezone('Europe/Berlin') def test_evcc_initialization(self, timezone): - """Test EVCC provider initialization""" + """Test evcc provider initialization""" from batcontrol.dynamictariff.evcc import Evcc provider = Evcc(timezone, 'http://evcc.local/api/tariff/grid', 60, target_resolution=60) - assert provider.native_resolution == 15 # EVCC native is 15-min + assert provider.native_resolution == 15 # evcc native is 15-min assert provider.target_resolution == 60 def test_evcc_15min_target(self, timezone): - """Test EVCC with 15-min target (no conversion needed)""" + """Test evcc with 15-min target (no conversion needed)""" from batcontrol.dynamictariff.evcc import Evcc provider = Evcc(timezone, 'http://evcc.local/api/tariff/grid', 60, diff --git a/tests/batcontrol/dynamictariff/test_evcc.py b/tests/batcontrol/dynamictariff/test_evcc.py index cab0638b..de410b92 100644 --- a/tests/batcontrol/dynamictariff/test_evcc.py +++ b/tests/batcontrol/dynamictariff/test_evcc.py @@ -1,6 +1,6 @@ -"""Tests for EVCC dynamic tariff provider with 15-minute native resolution. +"""Tests for evcc dynamic tariff provider with 15-minute native resolution. -The EVCC provider has native_resolution=15 and returns 15-minute interval prices directly. +The evcc provider has native_resolution=15 and returns 15-minute interval prices directly. Averaging to hourly is done by the baseclass when target_resolution=60. """ import unittest @@ -239,7 +239,7 @@ def test_mixed_granularity_prices(self): self.assertEqual(prices[8], 0.28) def test_native_resolution_is_15min(self): - """Test that EVCC provider has native 15-min resolution""" + """Test that evcc provider has native 15-min resolution""" evcc = Evcc(self.timezone, self.url) self.assertEqual(evcc.native_resolution, 15) diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 32db4b5d..97568487 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -288,7 +288,7 @@ def test_api_set_limit_applies_immediately_in_mode_8( class TestEvccPeakShavingGuard: - """Test EVCC peak shaving guard in core.py run loop.""" + """Test evcc peak shaving guard in core.py run loop.""" @pytest.fixture def mock_config(self): @@ -347,11 +347,11 @@ def _create_bc(self, mock_config, mock_inverter_factory, mock_tariff, def test_evcc_charging_clears_charge_limit( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When EVCC is actively charging, peak shaving charge limit is cleared.""" + """When evcc is actively charging, peak shaving charge limit is cleared.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) - # Simulate EVCC API + # Simulate evcc API mock_evcc = MagicMock() mock_evcc.evcc_is_charging = True mock_evcc.evcc_ev_expects_pv_surplus = False @@ -366,7 +366,7 @@ def test_evcc_charging_clears_charge_limit( limit_battery_charge_rate=500 ) - # Apply the EVCC guard logic (same block as in core.py run loop) + # Apply the evcc guard logic (same block as in core.py run loop) evcc_disable_peak_shaving = ( bc.evcc_api.evcc_is_charging or bc.evcc_api.evcc_ev_expects_pv_surplus @@ -416,7 +416,7 @@ def test_evcc_pv_mode_clears_charge_limit( def test_evcc_not_charging_preserves_charge_limit( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When EVCC is not charging and no PV mode, charge limit is preserved.""" + """When evcc is not charging and no PV mode, charge limit is preserved.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) @@ -449,7 +449,7 @@ def test_evcc_not_charging_preserves_charge_limit( def test_evcc_no_limit_active_no_change( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When no charge limit is active (=-1), EVCC guard doesn't modify it.""" + """When no charge limit is active (=-1), evcc guard doesn't modify it.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) diff --git a/tests/batcontrol/test_evcc_mode.py b/tests/batcontrol/test_evcc_mode.py index 3ea526bc..5f5f1671 100644 --- a/tests/batcontrol/test_evcc_mode.py +++ b/tests/batcontrol/test_evcc_mode.py @@ -1,4 +1,4 @@ -"""Tests for EVCC mode and connected topic handling for peak shaving. +"""Tests for evcc mode and connected topic handling for peak shaving. Tests cover: - Topic derivation from loadpoint /charging topics @@ -23,7 +23,7 @@ def __init__(self, topic: str, payload: bytes): class TestEvccModeConnected(unittest.TestCase): - """Tests for mode/connected EVCC topic handling.""" + """Tests for mode/connected evcc topic handling.""" def _create_evcc_api(self, loadpoint_topics=None): """Create an EvccApi instance with mocked MQTT client.""" From 8c67bc50ab9ad9ac2e77307763909554b37aa2f1 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 16:46:54 +0100 Subject: [PATCH 12/35] Iteration 5 , improving architecture --- src/batcontrol/core.py | 90 ++++++++++++++++++------------ tests/batcontrol/test_core.py | 102 ++++++++++++++++------------------ 2 files changed, 104 insertions(+), 88 deletions(-) diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 0371d4a2..311f47d3 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -16,6 +16,9 @@ import logging import platform +from dataclasses import dataclass +from typing import Optional + import pytz import numpy as np @@ -50,6 +53,27 @@ logger = logging.getLogger(__name__) +@dataclass +class PeakShavingConfig: + """ Holds peak shaving configuration parameters, initialized from the config dict. """ + enabled: bool = False + mode: str = 'combined' + allow_full_battery_after: int = 14 + price_limit: Optional[float] = None + + @classmethod + def from_config(cls, config: dict) -> 'PeakShavingConfig': + """ Create a PeakShavingConfig instance from a configuration dict. """ + ps = config.get('peak_shaving', {}) + price_limit_raw = ps.get('price_limit', None) + return cls( + enabled=ps.get('enabled', False), + mode=ps.get('mode', 'combined'), + allow_full_battery_after=ps.get('allow_full_battery_after', 14), + price_limit=float(price_limit_raw) if price_limit_raw is not None else None, + ) + + class Batcontrol: """ Main class for Batcontrol, handles the logic and control of the battery system """ general_logic = None # type: CommonLogic @@ -192,6 +216,8 @@ def __init__(self, configdict: dict): self.batconfig = config['battery_control'] self.time_at_forecast_error = -1 + self.peak_shaving_config = PeakShavingConfig.from_config(config) + self.max_charging_from_grid_limit = self.batconfig.get( 'max_charging_from_grid_limit', 0.8) self.min_price_difference = self.batconfig.get( @@ -519,18 +545,37 @@ def run(self): self.get_stored_usable_energy(), self.get_free_capacity() ) - peak_shaving_config = self.config.get('peak_shaving', {}) + peak_shaving_config_enabled = self.peak_shaving_config.enabled + + if peak_shaving_config_enabled: + # Determine whether evcc conditions require peak shaving to be disabled. + # This must happen before building calc_parameters so the logic never + # runs peak shaving unnecessarily. + evcc_disable_peak_shaving = False + if self.evcc_api is not None: + evcc_disable_peak_shaving = ( + self.evcc_api.evcc_is_charging or + self.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving: + if self.evcc_api.evcc_is_charging: + logger.debug('[PeakShaving] Disabled: evcc is actively charging') + else: + logger.debug('[PeakShaving] Disabled: EV connected in PV mode') + elif self._evcc_peak_shaving_disabled: + logger.debug('[PeakShaving] Re-enabled: evcc no longer blocking ' + '(EV disconnected or mode changed away from pv)') + self._evcc_peak_shaving_disabled = evcc_disable_peak_shaving calc_parameters = CalculationParameters( self.max_charging_from_grid_limit, self.min_price_difference, self.min_price_difference_rel, self.get_max_capacity(), - peak_shaving_enabled=peak_shaving_config.get('enabled', False), - peak_shaving_allow_full_after=peak_shaving_config.get( - 'allow_full_battery_after', 14), - peak_shaving_mode=peak_shaving_config.get('mode', 'combined'), - peak_shaving_price_limit=peak_shaving_config.get('price_limit', None), + peak_shaving_enabled=peak_shaving_config_enabled and not evcc_disable_peak_shaving, + peak_shaving_allow_full_after=self.peak_shaving_config.allow_full_battery_after, + peak_shaving_mode=self.peak_shaving_config.mode, + peak_shaving_price_limit=self.peak_shaving_config.price_limit, ) self.last_logic_instance = this_logic_run @@ -558,28 +603,8 @@ def run(self): logger.debug('Discharge blocked due to external lock') inverter_settings.allow_discharge = False - # evcc disables peak shaving (handled in core, not logic) - if self.evcc_api is not None: - evcc_disable_peak_shaving = ( - self.evcc_api.evcc_is_charging or - self.evcc_api.evcc_ev_expects_pv_surplus - ) - if evcc_disable_peak_shaving: - if inverter_settings.limit_battery_charge_rate >= 0: - if self.evcc_api.evcc_is_charging: - logger.debug('[PeakShaving] Disabled: evcc is actively charging') - else: - logger.debug('[PeakShaving] Disabled: EV connected in PV mode') - inverter_settings.limit_battery_charge_rate = -1 - else: - if self._evcc_peak_shaving_disabled: - logger.debug('[PeakShaving] Re-enabled: evcc no longer blocking ' - '(EV disconnected or mode changed away from pv)') - self._evcc_peak_shaving_disabled = evcc_disable_peak_shaving - # Publish peak shaving charge limit (after evcc guard may have cleared it) - peak_shaving_enabled = peak_shaving_config.get('enabled', False) - if self.mqtt_api is not None and peak_shaving_enabled: + if self.mqtt_api is not None and peak_shaving_config_enabled: self.mqtt_api.publish_peak_shaving_charge_limit( inverter_settings.limit_battery_charge_rate) @@ -841,11 +866,10 @@ def refresh_static_values(self) -> None: # self.mqtt_api.publish_discharge_blocked(self.discharge_blocked) # Peak shaving - peak_shaving_config = self.config.get('peak_shaving', {}) self.mqtt_api.publish_peak_shaving_enabled( - peak_shaving_config.get('enabled', False)) + self.peak_shaving_config.enabled) self.mqtt_api.publish_peak_shaving_allow_full_after( - peak_shaving_config.get('allow_full_battery_after', 14)) + self.peak_shaving_config.allow_full_battery_after) # Trigger Inverter self.inverter.refresh_api_values() @@ -982,8 +1006,7 @@ def api_set_peak_shaving_enabled(self, enabled_str: str): """ enabled = enabled_str.strip().lower() in ('true', 'on', '1') logger.info('API: Setting peak shaving enabled to %s', enabled) - peak_shaving = self.config.setdefault('peak_shaving', {}) - peak_shaving['enabled'] = enabled + self.peak_shaving_config.enabled = enabled if self.mqtt_api is not None: self.mqtt_api.publish_peak_shaving_enabled(enabled) @@ -998,7 +1021,6 @@ def api_set_peak_shaving_allow_full_after(self, hour: int): return logger.info( 'API: Setting peak shaving allow_full_battery_after to %d', hour) - peak_shaving = self.config.setdefault('peak_shaving', {}) - peak_shaving['allow_full_battery_after'] = hour + self.peak_shaving_config.allow_full_battery_after = hour if self.mqtt_api is not None: self.mqtt_api.publish_peak_shaving_allow_full_after(hour) diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 97568487..c21775a4 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -344,46 +344,43 @@ def _create_bc(self, mock_config, mock_inverter_factory, mock_tariff, @patch('batcontrol.core.inverter_factory.create_inverter') @patch('batcontrol.core.solar_factory.create_solar_provider') @patch('batcontrol.core.consumption_factory.create_consumption') - def test_evcc_charging_clears_charge_limit( + def test_evcc_charging_disables_peak_shaving_in_calc_params( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When evcc is actively charging, peak shaving charge limit is cleared.""" + """When evcc is actively charging, peak_shaving_enabled is False in calc_params.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) - # Simulate evcc API mock_evcc = MagicMock() mock_evcc.evcc_is_charging = True mock_evcc.evcc_ev_expects_pv_surplus = False bc.evcc_api = mock_evcc - # Simulate inverter_settings with peak shaving limit active - from batcontrol.logic.logic_interface import InverterControlSettings - settings = InverterControlSettings( - allow_discharge=True, - charge_from_grid=False, - charge_rate=0, - limit_battery_charge_rate=500 - ) - - # Apply the evcc guard logic (same block as in core.py run loop) + # Replicate the pre-calculation evcc check from core.py + from batcontrol.logic.logic_interface import CalculationParameters evcc_disable_peak_shaving = ( bc.evcc_api.evcc_is_charging or bc.evcc_api.evcc_ev_expects_pv_surplus ) - if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: - settings.limit_battery_charge_rate = -1 + peak_shaving_config = mock_config.get('peak_shaving', {}) + calc_params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.0, + max_capacity=10000, + peak_shaving_enabled=peak_shaving_config.get('enabled', False) and not evcc_disable_peak_shaving, + ) - assert settings.limit_battery_charge_rate == -1 + assert calc_params.peak_shaving_enabled is False @patch('batcontrol.core.tariff_factory.create_tarif_provider') @patch('batcontrol.core.inverter_factory.create_inverter') @patch('batcontrol.core.solar_factory.create_solar_provider') @patch('batcontrol.core.consumption_factory.create_consumption') - def test_evcc_pv_mode_clears_charge_limit( + def test_evcc_pv_mode_disables_peak_shaving_in_calc_params( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When EV connected in PV mode, peak shaving charge limit is cleared.""" + """When EV connected in PV mode, peak_shaving_enabled is False in calc_params.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) @@ -392,31 +389,30 @@ def test_evcc_pv_mode_clears_charge_limit( mock_evcc.evcc_ev_expects_pv_surplus = True bc.evcc_api = mock_evcc - from batcontrol.logic.logic_interface import InverterControlSettings - settings = InverterControlSettings( - allow_discharge=True, - charge_from_grid=False, - charge_rate=0, - limit_battery_charge_rate=500 - ) - + from batcontrol.logic.logic_interface import CalculationParameters evcc_disable_peak_shaving = ( bc.evcc_api.evcc_is_charging or bc.evcc_api.evcc_ev_expects_pv_surplus ) - if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: - settings.limit_battery_charge_rate = -1 + peak_shaving_config = mock_config.get('peak_shaving', {}) + calc_params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.0, + max_capacity=10000, + peak_shaving_enabled=peak_shaving_config.get('enabled', False) and not evcc_disable_peak_shaving, + ) - assert settings.limit_battery_charge_rate == -1 + assert calc_params.peak_shaving_enabled is False @patch('batcontrol.core.tariff_factory.create_tarif_provider') @patch('batcontrol.core.inverter_factory.create_inverter') @patch('batcontrol.core.solar_factory.create_solar_provider') @patch('batcontrol.core.consumption_factory.create_consumption') - def test_evcc_not_charging_preserves_charge_limit( + def test_evcc_not_active_keeps_peak_shaving_enabled( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When evcc is not charging and no PV mode, charge limit is preserved.""" + """When evcc is not charging and no PV mode, peak_shaving_enabled stays True.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) @@ -425,22 +421,21 @@ def test_evcc_not_charging_preserves_charge_limit( mock_evcc.evcc_ev_expects_pv_surplus = False bc.evcc_api = mock_evcc - from batcontrol.logic.logic_interface import InverterControlSettings - settings = InverterControlSettings( - allow_discharge=True, - charge_from_grid=False, - charge_rate=0, - limit_battery_charge_rate=500 - ) - + from batcontrol.logic.logic_interface import CalculationParameters evcc_disable_peak_shaving = ( bc.evcc_api.evcc_is_charging or bc.evcc_api.evcc_ev_expects_pv_surplus ) - if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: - settings.limit_battery_charge_rate = -1 + peak_shaving_config = mock_config.get('peak_shaving', {}) + calc_params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.0, + max_capacity=10000, + peak_shaving_enabled=peak_shaving_config.get('enabled', False) and not evcc_disable_peak_shaving, + ) - assert settings.limit_battery_charge_rate == 500 + assert calc_params.peak_shaving_enabled is True @patch('batcontrol.core.tariff_factory.create_tarif_provider') @patch('batcontrol.core.inverter_factory.create_inverter') @@ -449,7 +444,7 @@ def test_evcc_not_charging_preserves_charge_limit( def test_evcc_no_limit_active_no_change( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When no charge limit is active (=-1), evcc guard doesn't modify it.""" + """When evcc is charging but peak shaving was off in config, it stays disabled.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) @@ -458,22 +453,21 @@ def test_evcc_no_limit_active_no_change( mock_evcc.evcc_ev_expects_pv_surplus = False bc.evcc_api = mock_evcc - from batcontrol.logic.logic_interface import InverterControlSettings - settings = InverterControlSettings( - allow_discharge=True, - charge_from_grid=False, - charge_rate=0, - limit_battery_charge_rate=-1 - ) - + from batcontrol.logic.logic_interface import CalculationParameters evcc_disable_peak_shaving = ( bc.evcc_api.evcc_is_charging or bc.evcc_api.evcc_ev_expects_pv_surplus ) - if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: - settings.limit_battery_charge_rate = -1 + # Config has enabled=True, but evcc disables it -> result is False + calc_params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.0, + max_capacity=10000, + peak_shaving_enabled=False and not evcc_disable_peak_shaving, + ) - assert settings.limit_battery_charge_rate == -1 + assert calc_params.peak_shaving_enabled is False if __name__ == '__main__': From 2294d53c1db82b93995605ea53774093fc62e881 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 16:52:08 +0100 Subject: [PATCH 13/35] Some more unicode to ascii converts --- scripts/test_evcc.py | 2 +- scripts/verify_production_offset.py | 2 +- src/batcontrol/dynamictariff/baseclass.py | 14 ++++++------ src/batcontrol/dynamictariff/tariffzones.py | 6 ++--- .../forecastconsumption/baseclass.py | 6 ++--- src/batcontrol/forecastsolar/baseclass.py | 8 +++---- src/batcontrol/interval_utils.py | 22 +++++++++---------- src/batcontrol/inverter/fronius.py | 2 +- .../dynamictariff/test_tariffzones.py | 4 ++-- .../forecastsolar/test_baseclass_alignment.py | 4 ++-- tests/batcontrol/logic/test_default.py | 2 +- tests/batcontrol/test_core.py | 2 +- tests/batcontrol/test_evcc_mode.py | 18 +++++++-------- 13 files changed, 46 insertions(+), 46 deletions(-) diff --git a/scripts/test_evcc.py b/scripts/test_evcc.py index e0b8a73c..25256106 100644 --- a/scripts/test_evcc.py +++ b/scripts/test_evcc.py @@ -61,7 +61,7 @@ def main(): print("=" * 50) if prices: for hour, price in sorted(prices.items()): - print(f"Hour +{hour:2d}: {price:.4f} €/kWh") + print(f"Hour +{hour:2d}: {price:.4f} EUR/kWh") else: print("No prices found") diff --git a/scripts/verify_production_offset.py b/scripts/verify_production_offset.py index e225bef6..a22a45d5 100755 --- a/scripts/verify_production_offset.py +++ b/scripts/verify_production_offset.py @@ -107,7 +107,7 @@ def demonstrate_production_offset(): print("=" * 70) print() - print("✓ Production offset feature successfully implemented!") + print("[done] Production offset feature successfully implemented!") print() if __name__ == '__main__': diff --git a/src/batcontrol/dynamictariff/baseclass.py b/src/batcontrol/dynamictariff/baseclass.py index 8af9579b..fede30b9 100644 --- a/src/batcontrol/dynamictariff/baseclass.py +++ b/src/batcontrol/dynamictariff/baseclass.py @@ -7,7 +7,7 @@ Key Design: - Providers declare their native_resolution (15 or 60 minutes) - Baseclass handles automatic upsampling/downsampling -- For prices: replication (hourly→15min) or averaging (15min→hourly) +- For prices: replication (hourly->15min) or averaging (15min->hourly) - Baseclass shifts indices to current-interval alignment """ @@ -31,7 +31,7 @@ class DynamicTariffBaseclass(TariffInterface): Provides automatic resolution handling: - Providers declare their native_resolution (15 or 60 minutes) - Baseclass converts between resolutions automatically - - For prices: uses replication (60→15) or averaging (15→60) + - For prices: uses replication (60->15) or averaging (15->60) - Baseclass shifts indices to current-interval alignment Subclasses must: @@ -154,8 +154,8 @@ def _convert_resolution(self, prices: dict[int, float]) -> dict[int, float]: """Convert prices between resolutions if needed. For prices: - - 60→15: Replicate (same price for all 4 quarters of an hour) - - 15→60: Average (mean of 4 quarters) + - 60->15: Replicate (same price for all 4 quarters of an hour) + - 15->60: Average (mean of 4 quarters) Args: prices: Hour-aligned price data at native resolution @@ -168,16 +168,16 @@ def _convert_resolution(self, prices: dict[int, float]) -> dict[int, float]: if self.native_resolution == 60 and self.target_resolution == 15: logger.debug( - '%s: Replicating hourly prices → 15min (same price per quarter)', + '%s: Replicating hourly prices -> 15min (same price per quarter)', self.__class__.__name__) return self._replicate_hourly_to_15min(prices) if self.native_resolution == 15 and self.target_resolution == 60: - logger.debug('%s: Averaging 15min prices → hourly', + logger.debug('%s: Averaging 15min prices -> hourly', self.__class__.__name__) return average_to_hourly(prices) - logger.error('%s: Cannot convert %d min → %d min', + logger.error('%s: Cannot convert %d min -> %d min', self.__class__.__name__, self.native_resolution, self.target_resolution) diff --git a/src/batcontrol/dynamictariff/tariffzones.py b/src/batcontrol/dynamictariff/tariffzones.py index 8e23b882..abbb9583 100644 --- a/src/batcontrol/dynamictariff/tariffzones.py +++ b/src/batcontrol/dynamictariff/tariffzones.py @@ -85,7 +85,7 @@ def __init__( self.zone_3_hours = zone_3_hours def get_raw_data_from_provider(self) -> dict: - """No external API — configuration is static.""" + """No external API - configuration is static.""" return {} def _validate_configuration(self) -> None: @@ -166,8 +166,8 @@ def _parse_hours(value, name: str) -> list: Accepted formats (may be mixed): - Single integer: 5 - Comma-separated values: "0,1,2,3" - - Inclusive ranges: "0-5" → [0, 1, 2, 3, 4, 5] - - Mixed: "0-5,6,7" → [0, 1, 2, 3, 4, 5, 6, 7] + - Inclusive ranges: "0-5" -> [0, 1, 2, 3, 4, 5] + - Mixed: "0-5,6,7" -> [0, 1, 2, 3, 4, 5, 6, 7] - Python list/tuple of ints or range-strings: [0, '1-3', 4] Raises ValueError if any hour is out of range [0, 23], if a range is diff --git a/src/batcontrol/forecastconsumption/baseclass.py b/src/batcontrol/forecastconsumption/baseclass.py index 79d0afea..1c8ebae5 100644 --- a/src/batcontrol/forecastconsumption/baseclass.py +++ b/src/batcontrol/forecastconsumption/baseclass.py @@ -120,18 +120,18 @@ def _convert_resolution( if self.native_resolution == 60 and self.target_resolution == 15: logger.debug( - '%s: Upsampling 60min → 15min using constant distribution', + '%s: Upsampling 60min -> 15min using constant distribution', self.__class__.__name__) # Use constant distribution for consumption (no interpolation) return upsample_forecast( forecast, target_resolution=15, method='constant') if self.native_resolution == 15 and self.target_resolution == 60: - logger.debug('%s: Downsampling 15min → 60min by summing quarters', + logger.debug('%s: Downsampling 15min -> 60min by summing quarters', self.__class__.__name__) return downsample_to_hourly(forecast) - logger.error('%s: Cannot convert %d min → %d min', + logger.error('%s: Cannot convert %d min -> %d min', self.__class__.__name__, self.native_resolution, self.target_resolution) diff --git a/src/batcontrol/forecastsolar/baseclass.py b/src/batcontrol/forecastsolar/baseclass.py index 5035fb2f..d6c81173 100644 --- a/src/batcontrol/forecastsolar/baseclass.py +++ b/src/batcontrol/forecastsolar/baseclass.py @@ -29,7 +29,7 @@ class ForecastSolarBaseclass(ForecastSolarInterface): Supports Full-Hour Alignment strategy: - Providers return hour-aligned data (index 0 = start of current hour) - - Baseclass handles resolution conversion (hourly ↔ 15-min) + - Baseclass handles resolution conversion (hourly <-> 15-min) - Baseclass shifts indices to current-interval alignment - Core receives data where [0] = current interval """ @@ -177,16 +177,16 @@ def _convert_resolution(self, forecast: dict[int, float]) -> dict[int, float]: return forecast if self.native_resolution == 60 and self.target_resolution == 15: - logger.debug('%s: Upsampling 60min → 15min using linear interpolation', + logger.debug('%s: Upsampling 60min -> 15min using linear interpolation', self.__class__.__name__) return upsample_forecast(forecast, target_resolution=15, method='linear') if self.native_resolution == 15 and self.target_resolution == 60: - logger.debug('%s: Downsampling 15min → 60min by summing quarters', + logger.debug('%s: Downsampling 15min -> 60min by summing quarters', self.__class__.__name__) return downsample_to_hourly(forecast) - logger.error('%s: Cannot convert %d min → %d min', + logger.error('%s: Cannot convert %d min -> %d min', self.__class__.__name__, self.native_resolution, self.target_resolution) diff --git a/src/batcontrol/interval_utils.py b/src/batcontrol/interval_utils.py index 157f4abb..5c46189e 100644 --- a/src/batcontrol/interval_utils.py +++ b/src/batcontrol/interval_utils.py @@ -60,20 +60,20 @@ def _upsample_linear(hourly_forecast: Dict[int, float]) -> Dict[int, float]: - Uses linear power interpolation, then converts to energy Method: - 1. Calculate average power per hour (Wh → W) + 1. Calculate average power per hour (Wh -> W) 2. Interpolate power linearly between hours - 3. Convert interpolated power back to energy (W → Wh for 15 min) + 3. Convert interpolated power back to energy (W -> Wh for 15 min) Example: - Hour 0: 1000 Wh → avg power = 1000 W - Hour 1: 2000 Wh → avg power = 2000 W + Hour 0: 1000 Wh -> avg power = 1000 W + Hour 1: 2000 Wh -> avg power = 2000 W 15-min intervals (linear power ramp): - [0]: Power = 1000 W → Energy = 1000 * 0.25 = 250 Wh - [1]: Power = 1250 W → Energy = 1250 * 0.25 = 312.5 Wh - [2]: Power = 1500 W → Energy = 1500 * 0.25 = 375 Wh - [3]: Power = 1750 W → Energy = 1750 * 0.25 = 437.5 Wh - [4]: Power = 2000 W → Energy = 2000 * 0.25 = 500 Wh (next hour begins) + [0]: Power = 1000 W -> Energy = 1000 * 0.25 = 250 Wh + [1]: Power = 1250 W -> Energy = 1250 * 0.25 = 312.5 Wh + [2]: Power = 1500 W -> Energy = 1500 * 0.25 = 375 Wh + [3]: Power = 1750 W -> Energy = 1750 * 0.25 = 437.5 Wh + [4]: Power = 2000 W -> Energy = 2000 * 0.25 = 500 Wh (next hour begins) """ forecast_15min = {} max_hour = max(hourly_forecast.keys()) @@ -118,8 +118,8 @@ def _upsample_constant(hourly_forecast: Dict[int, float]) -> Dict[int, float]: doesn't make physical sense. Example: - Hour 0: 1000 Wh → 250, 250, 250, 250 Wh per 15 min - Hour 1: 2000 Wh → 500, 500, 500, 500 Wh per 15 min + Hour 0: 1000 Wh -> 250, 250, 250, 250 Wh per 15 min + Hour 1: 2000 Wh -> 500, 500, 500, 500 Wh per 15 min """ forecast_15min = {} diff --git a/src/batcontrol/inverter/fronius.py b/src/batcontrol/inverter/fronius.py index 2a1e39f4..9a15e88d 100644 --- a/src/batcontrol/inverter/fronius.py +++ b/src/batcontrol/inverter/fronius.py @@ -158,7 +158,7 @@ def get_api_config(fw_version: version) -> FroniusApiConfig: if config.from_version <= fw_version < config.to_version: return config raise RuntimeError( - f"Keine API Konfiguration für Firmware-Version {fw_version}") + f"Keine API Konfiguration fuer Firmware-Version {fw_version}") class FroniusWR(InverterBaseclass): diff --git a/tests/batcontrol/dynamictariff/test_tariffzones.py b/tests/batcontrol/dynamictariff/test_tariffzones.py index 05e23213..4f38f8d5 100644 --- a/tests/batcontrol/dynamictariff/test_tariffzones.py +++ b/tests/batcontrol/dynamictariff/test_tariffzones.py @@ -188,7 +188,7 @@ def test_zone3_price_without_hours_raises(): # --------------------------------------------------------------------------- -# _get_prices_native — 2-zone +# _get_prices_native - 2-zone # --------------------------------------------------------------------------- def test_get_prices_native_returns_48_hours(): @@ -210,7 +210,7 @@ def test_get_prices_native_correct_zone_assignment(): # --------------------------------------------------------------------------- -# _get_prices_native — 3-zone +# _get_prices_native - 3-zone # --------------------------------------------------------------------------- def test_get_prices_native_three_zones(): diff --git a/tests/batcontrol/forecastsolar/test_baseclass_alignment.py b/tests/batcontrol/forecastsolar/test_baseclass_alignment.py index 40617b9b..9d681f17 100644 --- a/tests/batcontrol/forecastsolar/test_baseclass_alignment.py +++ b/tests/batcontrol/forecastsolar/test_baseclass_alignment.py @@ -193,7 +193,7 @@ def test_shift_at_20_minutes_15min(self, pvinstallations, timezone): result = provider._shift_to_current_interval(hour_aligned) # At 10:20, current_interval_in_hour = 20//15 = 1 - # Should shift by 1: drop [0], renumber [1]→[0], [2]→[1], etc. + # Should shift by 1: drop [0], renumber [1]->[0], [2]->[1], etc. expected = { 0: 300, # Was [1]: 10:15-10:30 (current) 1: 350, # Was [2]: 10:30-10:45 @@ -226,7 +226,7 @@ def test_shift_at_35_minutes_15min(self, pvinstallations, timezone): result = provider._shift_to_current_interval(hour_aligned) # At 10:35, current_interval_in_hour = 35//15 = 2 - # Should shift by 2: drop [0] and [1], renumber [2]→[0], [3]→[1], etc. + # Should shift by 2: drop [0] and [1], renumber [2]->[0], [3]->[1], etc. expected = { 0: 350, # Was [2]: 10:30-10:45 (current) 1: 400, # Was [3]: 10:45-11:00 diff --git a/tests/batcontrol/logic/test_default.py b/tests/batcontrol/logic/test_default.py index 86f3075c..9d61c1ed 100644 --- a/tests/batcontrol/logic/test_default.py +++ b/tests/batcontrol/logic/test_default.py @@ -60,7 +60,7 @@ def test_calculate_inverter_mode_high_soc(self): calc_input = CalculationInput( consumption=consumption, production=production, - prices={0: 0.25, 1: 0.30, 2: 0.35}, # Example prices in € per kWh + prices={0: 0.25, 1: 0.30, 2: 0.35}, # Example prices in EUR per kWh stored_energy=stored_energy, stored_usable_energy=stored_usable_energy, free_capacity=free_capacity, diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index c21775a4..fbfb7485 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -182,7 +182,7 @@ def test_limit_battery_charge_rate_min_exceeds_max( mock_solar.return_value = MagicMock() mock_consumption.return_value = MagicMock() - # Create Batcontrol instance — misconfiguration is corrected at init + # Create Batcontrol instance - misconfiguration is corrected at init bc = Batcontrol(mock_config) # min_pv_charge_rate should have been clamped to max_pv_charge_rate at init diff --git a/tests/batcontrol/test_evcc_mode.py b/tests/batcontrol/test_evcc_mode.py index 5f5f1671..d04b3e22 100644 --- a/tests/batcontrol/test_evcc_mode.py +++ b/tests/batcontrol/test_evcc_mode.py @@ -50,13 +50,13 @@ def _create_evcc_api(self, loadpoint_topics=None): # ---- Topic derivation ---- def test_topic_derivation_single(self): - """charging topic → mode and connected topics derived.""" + """charging topic -> mode and connected topics derived.""" api = self._create_evcc_api(['evcc/loadpoints/1/charging']) self.assertIn('evcc/loadpoints/1/mode', api.list_topics_mode) self.assertIn('evcc/loadpoints/1/connected', api.list_topics_connected) def test_topic_derivation_multiple(self): - """Multiple loadpoints → all mode/connected topics derived.""" + """Multiple loadpoints -> all mode/connected topics derived.""" api = self._create_evcc_api([ 'evcc/loadpoints/1/charging', 'evcc/loadpoints/2/charging', @@ -68,7 +68,7 @@ def test_topic_derivation_multiple(self): api.list_topics_connected) def test_non_standard_topic_warning(self): - """Topic not ending in /charging → warning, no mode/connected sub.""" + """Topic not ending in /charging -> warning, no mode/connected sub.""" with self.assertLogs('batcontrol.evcc_api', level='WARNING') as cm: api = self._create_evcc_api(['evcc/loadpoints/1/status']) self.assertEqual(len(api.list_topics_mode), 0) @@ -131,33 +131,33 @@ def test_handle_connected_case_insensitive(self): # ---- evcc_ev_expects_pv_surplus ---- def test_expects_pv_surplus_connected_pv_mode(self): - """connected=true + mode=pv → True.""" + """connected=true + mode=pv -> True.""" api = self._create_evcc_api() api.evcc_loadpoint_connected['evcc/loadpoints/1'] = True api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'pv' self.assertTrue(api.evcc_ev_expects_pv_surplus) def test_expects_pv_surplus_connected_now_mode(self): - """connected=true + mode=now → False.""" + """connected=true + mode=now -> False.""" api = self._create_evcc_api() api.evcc_loadpoint_connected['evcc/loadpoints/1'] = True api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'now' self.assertFalse(api.evcc_ev_expects_pv_surplus) def test_expects_pv_surplus_disconnected_pv_mode(self): - """connected=false + mode=pv → False.""" + """connected=false + mode=pv -> False.""" api = self._create_evcc_api() api.evcc_loadpoint_connected['evcc/loadpoints/1'] = False api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'pv' self.assertFalse(api.evcc_ev_expects_pv_surplus) def test_expects_pv_surplus_no_data(self): - """No data received → False.""" + """No data received -> False.""" api = self._create_evcc_api() self.assertFalse(api.evcc_ev_expects_pv_surplus) def test_multi_loadpoint_one_pv(self): - """Multi-loadpoint: one connected+pv is enough → True.""" + """Multi-loadpoint: one connected+pv is enough -> True.""" api = self._create_evcc_api([ 'evcc/loadpoints/1/charging', 'evcc/loadpoints/2/charging', @@ -169,7 +169,7 @@ def test_multi_loadpoint_one_pv(self): self.assertTrue(api.evcc_ev_expects_pv_surplus) def test_mode_change_pv_to_now(self): - """Mode change from pv to now → evcc_ev_expects_pv_surplus changes to False.""" + """Mode change from pv to now -> evcc_ev_expects_pv_surplus changes to False.""" api = self._create_evcc_api() api.evcc_loadpoint_connected['evcc/loadpoints/1'] = True api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'pv' From 121eb1857cf6c5805ec607f71bfc22e5fd0ae490 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 18:16:51 +0100 Subject: [PATCH 14/35] Fix logging messages for logic type selection and improve error message formatting --- src/batcontrol/logic/logic.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/batcontrol/logic/logic.py b/src/batcontrol/logic/logic.py index 3aa11571..53e06a23 100644 --- a/src/batcontrol/logic/logic.py +++ b/src/batcontrol/logic/logic.py @@ -19,17 +19,17 @@ def create_logic(config: dict, timezone) -> LogicInterface: logic = None if request_type == 'default': if Logic.print_class_message: - logger.info('Using default logic') + logger.info('Using "default" logic') Logic.print_class_message = False logic = DefaultLogic(timezone, interval_minutes=interval_minutes) elif request_type == 'next': if Logic.print_class_message: - logger.info('Using next logic (with peak shaving support)') + logger.info('Using "next" logic (with peak shaving support)') Logic.print_class_message = False logic = NextLogic(timezone, interval_minutes=interval_minutes) else: raise RuntimeError( - f'[Logic] Unknown logic type {request_type}') + f'[Logic] Unknown logic type "{request_type}" specified in configuration') # Apply expert tuning attributes (shared between default and next) if config.get('battery_control_expert', None) is not None: From 0700edd71e64c6a69c10e2a5e4bc17770fc05a85 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 10:53:41 +0000 Subject: [PATCH 15/35] Add peak shaving feature implementation plan Document the design for price-based peak shaving using a new "next" logic module that limits PV-to-battery charging (mode 8) during low-price hours to maximize grid feed-in and reserve battery capacity for peak solar hours. https://claude.ai/code/session_013TjsRDZGUJM5YT15HaS6YK --- PLAN.md | 352 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..554c92d3 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,352 @@ +# Peak Shaving Feature — Implementation Plan + +## Overview + +Add peak shaving to batcontrol: manage PV battery charging rate so the battery fills up gradually, reaching full capacity by a target hour (`allow_full_battery_after`). This prevents the battery from being full too early (losing midday PV to grid overflow) and maximizes PV self-consumption. Peak shaving is automatically disabled when EVCC reports active EV charging. + +Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging. + +--- + +## 1. Configuration — Top-Level `peak_shaving` Section + +```yaml +peak_shaving: + enabled: false + allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour +``` + +**`allow_full_battery_after`** — Target hour for the battery to be full: +- **Before this hour:** PV charge rate is limited to spread charging evenly. The battery fills gradually instead of reaching 100% early and overflowing PV to grid. +- **At/after this hour:** No PV charge limit. Battery is allowed to be 100% full. PV overflow to grid is acceptable (e.g., EV arrives home and the charger absorbs excess). +- **During EV charging (EVCC `charging=true`):** Peak shaving disabled entirely. All energy flows to the car. + +--- + +## 2. EVCC Integration — Derive Root Topic & Subscribe to Power + +### 2.1 Topic Derivation + +Users configure loadpoint topics like: +```yaml +loadpoint_topic: + - evcc/loadpoints/1/charging +``` + +Derive the root by stripping `/charging`: +- `evcc/loadpoints/1/charging` → root = `evcc/loadpoints/1` + +Subscribe to: `{root}/chargePower` — current charging power in W + +### 2.2 Changes to `evcc_api.py` + +**New state:** +```python +self.evcc_loadpoint_power = {} # root_topic → charge power (W) +self.list_topics_charge_power = [] # derived chargePower topics +``` + +**In `__init__`:** For each loadpoint topic ending in `/charging`: +```python +root = topic[:-len('/charging')] +power_topic = root + '/chargePower' +self.list_topics_charge_power.append(power_topic) +self.evcc_loadpoint_power[root] = 0.0 +self.client.message_callback_add(power_topic, self._handle_message) +``` + +Topics not ending in `/charging`: log warning, skip power subscription. + +**In `on_connect`:** Subscribe to chargePower topics. + +**In `_handle_message`:** Route to `handle_charge_power_message`. + +**New handler:** +```python +def handle_charge_power_message(self, message): + try: + power = float(message.payload) + root = message.topic[:-len('/chargePower')] + self.evcc_loadpoint_power[root] = power + except (ValueError, TypeError): + logger.error('Could not parse chargePower: %s', message.payload) +``` + +**New public method:** +```python +def get_total_charge_power(self) -> float: + return sum(self.evcc_loadpoint_power.values()) +``` + +**`shutdown`:** Unsubscribe from chargePower topics. + +### 2.3 Backward Compatibility + +- Non-`/charging` topics: warning logged, no power sub, existing behavior unchanged +- `get_total_charge_power()` returns 0.0 when no data received + +--- + +## 3. Logic Changes — Peak Shaving via PV Charge Rate Limiting + +### 3.1 Core Algorithm + +The simulation spreads battery charging over time so the battery reaches full at the target hour: + +``` +slots_remaining = slots from now until allow_full_battery_after +free_capacity = battery free capacity in Wh +expected_pv = sum of production forecast for those slots (Wh) +``` + +If expected PV production exceeds free capacity, PV would fill the battery too early. We calculate the **maximum PV charge rate** that fills the battery evenly: + +``` +ideal_charge_rate_wh = free_capacity / slots_remaining # Wh per slot +ideal_charge_rate_w = ideal_charge_rate_wh * (60 / interval_minutes) # Convert to W +``` + +Set `limit_battery_charge_rate = ideal_charge_rate_w` → MODE 8. + +If expected PV is less than free capacity, no limit needed (battery won't fill early). + +### 3.2 Sequential Simulation + +Following the default logic's pattern of iterating through future slots: + +```python +def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): + """Calculate PV charge rate limit to fill battery by target hour. + + Returns: int — charge rate limit in W, or -1 if no limit needed + """ + slot_start = calc_timestamp.replace( + minute=(calc_timestamp.minute // self.interval_minutes) * self.interval_minutes, + second=0, microsecond=0 + ) + target_time = calc_timestamp.replace( + hour=self.peak_shaving_allow_full_after, + minute=0, second=0, microsecond=0 + ) + + if target_time <= slot_start: + return -1 # Past target hour, no limit + + slots_remaining = int( + (target_time - slot_start).total_seconds() / (self.interval_minutes * 60) + ) + slots_remaining = min(slots_remaining, len(calc_input.production)) + + if slots_remaining <= 0: + return -1 + + # Sum expected PV production (Wh) over remaining slots + interval_hours = self.interval_minutes / 60.0 + expected_pv_wh = float(np.sum( + calc_input.production[:slots_remaining] + )) * interval_hours + + free_capacity = calc_input.free_capacity + + if free_capacity <= 0: + return 0 # Battery is full, block PV charging + + if expected_pv_wh <= free_capacity: + return -1 # PV won't fill battery early, no limit needed + + # Spread charging evenly across remaining slots + wh_per_slot = free_capacity / slots_remaining + charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + + return int(charge_rate_w) +``` + +### 3.3 EVCC Charging Disables Peak Shaving + +When EVCC reports `charging=true`, peak shaving is disabled. All energy goes to EV. + +### 3.4 Implementation in `default.py` + +**Post-processing step** in `calculate_inverter_mode()`, after existing logic: + +```python +if self.peak_shaving_enabled and not calc_input.evcc_is_charging: + inverter_control_settings = self._apply_peak_shaving( + inverter_control_settings, calc_input, calc_timestamp) +``` + +**`_apply_peak_shaving()`:** + +```python +def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): + """Limit PV charge rate to fill battery by target hour.""" + current_hour = calc_timestamp.hour + + # After target hour: no limit, battery may be full + if current_hour >= self.peak_shaving_allow_full_after: + return settings + + charge_limit = self._calculate_peak_shaving_charge_limit( + calc_input, calc_timestamp) + + if charge_limit >= 0: + # Apply PV charge rate limit + # If existing logic already set a tighter limit, keep the tighter one + if settings.limit_battery_charge_rate < 0: + # No existing limit — apply peak shaving limit + settings.limit_battery_charge_rate = charge_limit + else: + # Keep the more restrictive limit + settings.limit_battery_charge_rate = min( + settings.limit_battery_charge_rate, charge_limit) + + logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', + settings.limit_battery_charge_rate, + self.peak_shaving_allow_full_after) + + return settings +``` + +### 3.5 Data Flow + +**New fields on `CalculationInput`:** +```python +@dataclass +class CalculationInput: + # ... existing fields ... + ev_charge_power: float = 0.0 # W — real-time total EV charge power + evcc_is_charging: bool = False # Whether any loadpoint is charging +``` + +In `core.py.run()`: +```python +ev_charge_power = 0.0 +evcc_is_charging = False +if self.evcc_api is not None: + ev_charge_power = self.evcc_api.get_total_charge_power() + evcc_is_charging = self.evcc_api.evcc_is_charging +``` + +### 3.6 Config in Logic + +In `logic.py` factory: +```python +peak_shaving_config = config.get('peak_shaving', {}) +logic.set_peak_shaving_config(peak_shaving_config) +``` + +In `default.py`: +```python +def set_peak_shaving_config(self, config: dict): + self.peak_shaving_enabled = config.get('enabled', False) + self.peak_shaving_allow_full_after = config.get('allow_full_battery_after', 14) +``` + +--- + +## 4. Core Integration — `core.py` + +### 4.1 Init + +No new instance vars. Config in `self.config` is passed to logic factory. + +### 4.2 Run Loop + +Before `CalculationInput`: +```python +ev_charge_power = 0.0 +evcc_is_charging = False +if self.evcc_api is not None: + ev_charge_power = self.evcc_api.get_total_charge_power() + evcc_is_charging = self.evcc_api.evcc_is_charging +``` + +Add to `CalculationInput` constructor. + +### 4.3 Mode Selection + +In the mode selection block (after logic.calculate), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed — peak shaving sets the limit in InverterControlSettings and the existing dispatch handles it. + +--- + +## 5. MQTT API — Publish Peak Shaving State + +Publish topics: +- `/peak_shaving/enabled` — boolean +- `/peak_shaving/allow_full_battery_after` — hour (0-23) +- `/peak_shaving/ev_charge_power` — W (real-time) + +Settable topics: +- `peak_shaving/enabled/set` +- `peak_shaving/allow_full_battery_after/set` + +Home Assistant discovery for all. + +--- + +## 6. Tests + +### 6.1 Logic Tests (`tests/batcontrol/logic/test_peak_shaving.py`) + +**Simulation tests:** +- Battery nearly full, lots of PV expected → low charge limit +- Battery mostly empty, moderate PV → no limit (PV won't fill battery) +- Battery already full → charge limit = 0 +- Past target hour → no limit (-1) +- 1 slot remaining → calculated rate for that slot + +**Decision tests:** +- Peak shaving disabled → no change +- EVCC charging=true → peak shaving disabled +- Before target hour, limit calculated → MODE 8 with limit +- After target hour → no change to existing settings +- Existing tighter limit from logic → kept (more restrictive wins) + +### 6.2 EVCC Tests (`tests/batcontrol/test_evcc_power.py`) + +- Topic derivation: `evcc/loadpoints/1/charging` → root `evcc/loadpoints/1` +- Non-standard topic → warning, no power sub +- `get_total_charge_power()` multi-loadpoint sum +- `get_total_charge_power()` returns 0.0 initially +- chargePower parsing (valid/invalid) + +### 6.3 Config Tests + +- With `peak_shaving` → loads correctly +- Without `peak_shaving` → disabled by default + +--- + +## 7. Implementation Order + +1. **Config** — Add `peak_shaving` to dummy config +2. **EVCC** — Topic derivation, chargePower sub, `get_total_charge_power()` +3. **Data model** — Add fields to `CalculationInput` +4. **Logic** — `set_peak_shaving_config()`, `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` +5. **Logic factory** — Pass peak_shaving config +6. **Core** — Wire EV data into CalculationInput +7. **MQTT** — Publish topics + discovery +8. **Tests** + +--- + +## 8. Files Modified + +| File | Change | +|------|--------| +| `config/batcontrol_config_dummy.yaml` | Add `peak_shaving` section | +| `src/batcontrol/evcc_api.py` | Topic derivation, chargePower sub, `get_total_charge_power()` | +| `src/batcontrol/logic/logic_interface.py` | Add `ev_charge_power`, `evcc_is_charging` to `CalculationInput` | +| `src/batcontrol/logic/default.py` | Peak shaving simulation + PV charge rate limiting | +| `src/batcontrol/logic/logic.py` | Pass peak_shaving config | +| `src/batcontrol/core.py` | Wire EV data into CalculationInput | +| `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | +| `tests/batcontrol/logic/test_peak_shaving.py` | New | +| `tests/batcontrol/test_evcc_power.py` | New | + +--- + +## 9. Open Questions + +1. **EVCC chargePower topic** — Is `{loadpoint_root}/chargePower` the correct EVCC MQTT topic name? +2. **Interaction with grid charging** — If price logic wants to grid-charge (MODE -1) but peak shaving wants to limit PV charge (MODE 8), which wins? Current plan: peak shaving only affects PV charge rate, doesn't block grid charging decisions. From ab31fcd63dc1437659b48fd6df94ac305629e483 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 11:15:42 +0000 Subject: [PATCH 16/35] Update peak shaving plan based on review feedback - Rename EVCC section: use existing charging state only, drop chargePower subscription - Algorithm uses net PV surplus (production - consumption) instead of raw production - Move peak shaving config to CalculationParameters (consistent with existing interface) - Add always_allow_discharge region bypass for high SOC - Add force_charge (MODE -1) priority over peak shaving with warning log - Remove unused ev_charge_power field from data model - Add known limitations section (flat charge distribution, no intra-day adjustment) - Reduce modified files from 9 to 6 https://claude.ai/code/session_01T62gAfD8CBB9uSU4w3uq1y --- PLAN.md | 330 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 173 insertions(+), 157 deletions(-) diff --git a/PLAN.md b/PLAN.md index 554c92d3..2ec3b2b4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2,7 +2,11 @@ ## Overview -Add peak shaving to batcontrol: manage PV battery charging rate so the battery fills up gradually, reaching full capacity by a target hour (`allow_full_battery_after`). This prevents the battery from being full too early (losing midday PV to grid overflow) and maximizes PV self-consumption. Peak shaving is automatically disabled when EVCC reports active EV charging. +Add peak shaving to batcontrol: manage PV battery charging rate so the battery fills up gradually, reaching full capacity by a target hour (`allow_full_battery_after`). This prevents the battery from being full too early in the day. + +**Problem:** All PV systems in the country produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest and — for newer installations — feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible into the battery, rather than feeding it into the grid during peak hours. + +**EVCC interaction:** When an EV is actively charging (`charging=true`), peak shaving is disabled — the EV consumes the excess PV. When an EV is connected in EVCC "pv" mode (waiting for surplus), EVCC+EV will naturally absorb excessive PV energy once the surplus threshold is reached. Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging. @@ -23,67 +27,25 @@ peak_shaving: --- -## 2. EVCC Integration — Derive Root Topic & Subscribe to Power - -### 2.1 Topic Derivation - -Users configure loadpoint topics like: -```yaml -loadpoint_topic: - - evcc/loadpoints/1/charging -``` - -Derive the root by stripping `/charging`: -- `evcc/loadpoints/1/charging` → root = `evcc/loadpoints/1` - -Subscribe to: `{root}/chargePower` — current charging power in W - -### 2.2 Changes to `evcc_api.py` - -**New state:** -```python -self.evcc_loadpoint_power = {} # root_topic → charge power (W) -self.list_topics_charge_power = [] # derived chargePower topics -``` - -**In `__init__`:** For each loadpoint topic ending in `/charging`: -```python -root = topic[:-len('/charging')] -power_topic = root + '/chargePower' -self.list_topics_charge_power.append(power_topic) -self.evcc_loadpoint_power[root] = 0.0 -self.client.message_callback_add(power_topic, self._handle_message) -``` - -Topics not ending in `/charging`: log warning, skip power subscription. +## 2. EVCC Integration — Use Existing Charging State -**In `on_connect`:** Subscribe to chargePower topics. +### 2.1 Approach -**In `_handle_message`:** Route to `handle_charge_power_message`. +Peak shaving uses the **existing** `evcc_is_charging` boolean from `evcc_api.py` to decide whether to apply PV charge limiting. No new EVCC subscriptions or topics are needed. -**New handler:** -```python -def handle_charge_power_message(self, message): - try: - power = float(message.payload) - root = message.topic[:-len('/chargePower')] - self.evcc_loadpoint_power[root] = power - except (ValueError, TypeError): - logger.error('Could not parse chargePower: %s', message.payload) -``` +- `evcc_is_charging = True` → peak shaving disabled (EV is consuming energy) +- `evcc_is_charging = False` → peak shaving may apply -**New public method:** -```python -def get_total_charge_power(self) -> float: - return sum(self.evcc_loadpoint_power.values()) -``` +**Note:** We intentionally do **not** subscribe to or rely on `chargePower`. The existing `charging` topic is sufficient for the peak shaving decision. -**`shutdown`:** Unsubscribe from chargePower topics. +### 2.2 No Changes to `evcc_api.py` -### 2.3 Backward Compatibility +The existing EVCC integration already provides: +- `self.evcc_is_charging` — whether any loadpoint is actively charging +- Discharge blocking during EV charging +- Battery halt SOC management -- Non-`/charging` topics: warning logged, no power sub, existing behavior unchanged -- `get_total_charge_power()` returns 0.0 when no data received +Peak shaving only needs the `evcc_is_charging` boolean, which is already available in `core.py` via `self.evcc_api.evcc_is_charging`. --- @@ -91,15 +53,15 @@ def get_total_charge_power(self) -> float: ### 3.1 Core Algorithm -The simulation spreads battery charging over time so the battery reaches full at the target hour: +The algorithm spreads battery charging over time so the battery reaches full at the target hour: ``` slots_remaining = slots from now until allow_full_battery_after free_capacity = battery free capacity in Wh -expected_pv = sum of production forecast for those slots (Wh) +expected_pv_surplus = sum of (production - consumption) for those slots, only positive values (Wh) ``` -If expected PV production exceeds free capacity, PV would fill the battery too early. We calculate the **maximum PV charge rate** that fills the battery evenly: +If expected **PV surplus** (production minus consumption) exceeds free capacity, PV would fill the battery too early. We calculate the **maximum PV charge rate** that fills the battery evenly: ``` ideal_charge_rate_wh = free_capacity / slots_remaining # Wh per slot @@ -108,9 +70,11 @@ ideal_charge_rate_w = ideal_charge_rate_wh * (60 / interval_minutes) # Convert Set `limit_battery_charge_rate = ideal_charge_rate_w` → MODE 8. -If expected PV is less than free capacity, no limit needed (battery won't fill early). +If expected PV surplus is less than free capacity, no limit needed (battery won't fill early). -### 3.2 Sequential Simulation +**Note:** The charge limit is distributed evenly across slots. This is a simplification — PV production peaks midday while the limit is flat. This means the limit may have no effect in low-PV morning slots and may clip excess in high-PV midday slots. The battery may not reach exactly 100% by the target hour. This is acceptable for v1; a PV-weighted distribution could be added later. + +### 3.2 Algorithm Implementation Following the default logic's pattern of iterating through future slots: @@ -125,7 +89,7 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): second=0, microsecond=0 ) target_time = calc_timestamp.replace( - hour=self.peak_shaving_allow_full_after, + hour=self.calculation_parameters.peak_shaving_allow_full_after, minute=0, second=0, microsecond=0 ) @@ -140,20 +104,22 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): if slots_remaining <= 0: return -1 - # Sum expected PV production (Wh) over remaining slots + # Calculate PV surplus per slot (only count positive surplus — when PV > consumption) + pv_surplus = calc_input.production[:slots_remaining] - calc_input.consumption[:slots_remaining] + pv_surplus = np.clip(pv_surplus, 0, None) # Only positive surplus counts + + # Sum expected PV surplus energy (Wh) over remaining slots interval_hours = self.interval_minutes / 60.0 - expected_pv_wh = float(np.sum( - calc_input.production[:slots_remaining] - )) * interval_hours + expected_surplus_wh = float(np.sum(pv_surplus)) * interval_hours free_capacity = calc_input.free_capacity + if expected_surplus_wh <= free_capacity: + return -1 # PV surplus won't fill battery early, no limit needed + if free_capacity <= 0: return 0 # Battery is full, block PV charging - if expected_pv_wh <= free_capacity: - return -1 # PV won't fill battery early, no limit needed - # Spread charging evenly across remaining slots wh_per_slot = free_capacity / slots_remaining charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W @@ -165,12 +131,31 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): When EVCC reports `charging=true`, peak shaving is disabled. All energy goes to EV. -### 3.4 Implementation in `default.py` +### 3.4 Always-Allow-Discharge Region Skips Peak Shaving + +When `stored_energy >= max_capacity * always_allow_discharge_limit`, the battery is in the "always allow discharge" region. In this region, peak shaving is **not applied** — the system is already at high SOC and the normal discharge logic takes over. This also avoids toggling issues when SOC fluctuates near 100%. + +### 3.5 Force Charge (MODE -1) Takes Priority Over Peak Shaving + +If the price-based logic decides to force charge from grid (`charge_from_grid=True`), this overrides peak shaving. The force charge decision means energy is cheap enough to justify grid charging — peak shaving should not interfere. + +When this occurs, a warning is logged: +``` +[PeakShaving] Skipped: force_charge (MODE -1) active, grid charging takes priority +``` + +In practice, force charge should rarely trigger during peak shaving hours because: +- Peak shaving hours have high PV production +- Prices are typically low during peak PV (no incentive to grid-charge) +- There should be enough PV to fill the battery by the target hour + +### 3.6 Implementation in `default.py` -**Post-processing step** in `calculate_inverter_mode()`, after existing logic: +**Post-processing step** in `calculate_inverter_mode()`, after existing logic returns `inverter_control_settings`: ```python -if self.peak_shaving_enabled and not calc_input.evcc_is_charging: +# Apply peak shaving as post-processing step +if self.calculation_parameters.peak_shaving_enabled: inverter_control_settings = self._apply_peak_shaving( inverter_control_settings, calc_input, calc_timestamp) ``` @@ -179,11 +164,32 @@ if self.peak_shaving_enabled and not calc_input.evcc_is_charging: ```python def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): - """Limit PV charge rate to fill battery by target hour.""" - current_hour = calc_timestamp.hour + """Limit PV charge rate to spread battery charging until target hour. - # After target hour: no limit, battery may be full - if current_hour >= self.peak_shaving_allow_full_after: + Skipped when: + - Past the target hour (allow_full_battery_after) + - Battery is in always_allow_discharge region (high SOC) + - EVCC is actively charging an EV + - Force charge from grid is active (MODE -1) + """ + # After target hour: no limit + if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: + return settings + + # In always_allow_discharge region: skip peak shaving + if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): + logger.debug('[PeakShaving] Skipped: battery in always_allow_discharge region') + return settings + + # EVCC charging: skip peak shaving + if self.calculation_parameters.evcc_is_charging: + logger.debug('[PeakShaving] Skipped: EVCC is charging') + return settings + + # Force charge takes priority over peak shaving + if settings.charge_from_grid: + logger.warning('[PeakShaving] Skipped: force_charge (MODE -1) active, ' + 'grid charging takes priority') return settings charge_limit = self._calculate_peak_shaving_charge_limit( @@ -191,7 +197,6 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): if charge_limit >= 0: # Apply PV charge rate limit - # If existing logic already set a tighter limit, keep the tighter one if settings.limit_battery_charge_rate < 0: # No existing limit — apply peak shaving limit settings.limit_battery_charge_rate = charge_limit @@ -202,45 +207,52 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', settings.limit_battery_charge_rate, - self.peak_shaving_allow_full_after) + self.calculation_parameters.peak_shaving_allow_full_after) return settings ``` -### 3.5 Data Flow +### 3.7 Data Flow — Extended `CalculationParameters` + +Peak shaving configuration is passed via the existing `CalculationParameters` dataclass (consistent with existing interface pattern): -**New fields on `CalculationInput`:** ```python @dataclass -class CalculationInput: - # ... existing fields ... - ev_charge_power: float = 0.0 # W — real-time total EV charge power - evcc_is_charging: bool = False # Whether any loadpoint is charging +class CalculationParameters: + """ Calculations from Battery control configuration """ + max_charging_from_grid_limit: float + min_price_difference: float + min_price_difference_rel: float + max_capacity: float # Maximum capacity of the battery in Wh (excludes MAX_SOC) + # Peak shaving parameters + peak_shaving_enabled: bool = False + peak_shaving_allow_full_after: int = 14 # Hour (0-23) + evcc_is_charging: bool = False # Whether any EVCC loadpoint is actively charging ``` -In `core.py.run()`: +In `core.py`, the `CalculationParameters` constructor is extended: + ```python -ev_charge_power = 0.0 evcc_is_charging = False if self.evcc_api is not None: - ev_charge_power = self.evcc_api.get_total_charge_power() evcc_is_charging = self.evcc_api.evcc_is_charging -``` -### 3.6 Config in Logic - -In `logic.py` factory: -```python -peak_shaving_config = config.get('peak_shaving', {}) -logic.set_peak_shaving_config(peak_shaving_config) +peak_shaving_config = self.config.get('peak_shaving', {}) + +calc_parameters = CalculationParameters( + self.max_charging_from_grid_limit, + self.min_price_difference, + self.min_price_difference_rel, + self.get_max_capacity(), + peak_shaving_enabled=peak_shaving_config.get('enabled', False), + peak_shaving_allow_full_after=peak_shaving_config.get('allow_full_battery_after', 14), + evcc_is_charging=evcc_is_charging, +) ``` -In `default.py`: -```python -def set_peak_shaving_config(self, config: dict): - self.peak_shaving_enabled = config.get('enabled', False) - self.peak_shaving_allow_full_after = config.get('allow_full_battery_after', 14) -``` +**No changes needed to `CalculationInput`** — the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. + +**No changes needed to `logic.py` factory** — configuration flows through `CalculationParameters` via the existing `set_calculation_parameters()` method. --- @@ -248,39 +260,35 @@ def set_peak_shaving_config(self, config: dict): ### 4.1 Init -No new instance vars. Config in `self.config` is passed to logic factory. +No new instance vars needed. Peak shaving config is read from `self.config` each run cycle and passed via `CalculationParameters`. ### 4.2 Run Loop -Before `CalculationInput`: -```python -ev_charge_power = 0.0 -evcc_is_charging = False -if self.evcc_api is not None: - ev_charge_power = self.evcc_api.get_total_charge_power() - evcc_is_charging = self.evcc_api.evcc_is_charging -``` - -Add to `CalculationInput` constructor. +Extend `CalculationParameters` construction (see Section 3.7). No other changes to the run loop. ### 4.3 Mode Selection -In the mode selection block (after logic.calculate), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed — peak shaving sets the limit in InverterControlSettings and the existing dispatch handles it. +In the mode selection block (after `logic.calculate()`), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed — peak shaving sets the limit in `InverterControlSettings` and the existing dispatch handles it. --- ## 5. MQTT API — Publish Peak Shaving State Publish topics: -- `/peak_shaving/enabled` — boolean -- `/peak_shaving/allow_full_battery_after` — hour (0-23) -- `/peak_shaving/ev_charge_power` — W (real-time) +- `{base}/peak_shaving/enabled` — boolean (`true`/`false`, plain text, retained) +- `{base}/peak_shaving/allow_full_battery_after` — integer hour 0-23 (plain text, retained) +- `{base}/peak_shaving/charge_limit` — current calculated charge limit in W (plain text, not retained, -1 if inactive) Settable topics: -- `peak_shaving/enabled/set` -- `peak_shaving/allow_full_battery_after/set` +- `{base}/peak_shaving/enabled/set` — accepts `true`/`false` +- `{base}/peak_shaving/allow_full_battery_after/set` — accepts integer 0-23 + +Home Assistant discovery: +- `peak_shaving/enabled` → switch entity +- `peak_shaving/allow_full_battery_after` → number entity (min: 0, max: 23, step: 1) +- `peak_shaving/charge_limit` → sensor entity (unit: W) -Home Assistant discovery for all. +QoS: 1 for all topics (consistent with existing MQTT API). --- @@ -288,45 +296,41 @@ Home Assistant discovery for all. ### 6.1 Logic Tests (`tests/batcontrol/logic/test_peak_shaving.py`) -**Simulation tests:** -- Battery nearly full, lots of PV expected → low charge limit -- Battery mostly empty, moderate PV → no limit (PV won't fill battery) -- Battery already full → charge limit = 0 +**Algorithm tests (`_calculate_peak_shaving_charge_limit`):** +- High PV surplus, small free capacity → low charge limit +- Low PV surplus, large free capacity → no limit (-1) +- PV surplus exactly matches free capacity → no limit (-1) +- Battery full (`free_capacity = 0`) → charge limit = 0 - Past target hour → no limit (-1) -- 1 slot remaining → calculated rate for that slot - -**Decision tests:** -- Peak shaving disabled → no change -- EVCC charging=true → peak shaving disabled -- Before target hour, limit calculated → MODE 8 with limit -- After target hour → no change to existing settings -- Existing tighter limit from logic → kept (more restrictive wins) - -### 6.2 EVCC Tests (`tests/batcontrol/test_evcc_power.py`) +- 1 slot remaining → rate for that single slot +- Consumption reduces effective PV — e.g., 3kW PV, 2kW consumption = 1kW surplus -- Topic derivation: `evcc/loadpoints/1/charging` → root `evcc/loadpoints/1` -- Non-standard topic → warning, no power sub -- `get_total_charge_power()` multi-loadpoint sum -- `get_total_charge_power()` returns 0.0 initially -- chargePower parsing (valid/invalid) +**Decision tests (`_apply_peak_shaving`):** +- `peak_shaving_enabled = False` → no change to settings +- `evcc_is_charging = True` → peak shaving skipped +- `charge_from_grid = True` → peak shaving skipped, warning logged +- Battery in always_allow_discharge region → peak shaving skipped +- Before target hour, limit calculated → `limit_battery_charge_rate` set +- After target hour → no change +- Existing tighter limit from other logic → kept (more restrictive wins) +- Peak shaving limit tighter than existing → peak shaving limit applied -### 6.3 Config Tests +### 6.2 Config Tests -- With `peak_shaving` → loads correctly -- Without `peak_shaving` → disabled by default +- With `peak_shaving` section → `CalculationParameters` fields set correctly +- Without `peak_shaving` section → `peak_shaving_enabled = False` (default) +- Invalid `allow_full_battery_after` values (edge cases: 0, 23) --- ## 7. Implementation Order -1. **Config** — Add `peak_shaving` to dummy config -2. **EVCC** — Topic derivation, chargePower sub, `get_total_charge_power()` -3. **Data model** — Add fields to `CalculationInput` -4. **Logic** — `set_peak_shaving_config()`, `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` -5. **Logic factory** — Pass peak_shaving config -6. **Core** — Wire EV data into CalculationInput -7. **MQTT** — Publish topics + discovery -8. **Tests** +1. **Config** — Add `peak_shaving` section to dummy config +2. **Data model** — Extend `CalculationParameters` with peak shaving fields +3. **Logic** — `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` in `default.py` +4. **Core** — Wire EVCC state + peak shaving config into `CalculationParameters` +5. **MQTT** — Publish topics + settable topics + HA discovery +6. **Tests** --- @@ -335,18 +339,30 @@ Home Assistant discovery for all. | File | Change | |------|--------| | `config/batcontrol_config_dummy.yaml` | Add `peak_shaving` section | -| `src/batcontrol/evcc_api.py` | Topic derivation, chargePower sub, `get_total_charge_power()` | -| `src/batcontrol/logic/logic_interface.py` | Add `ev_charge_power`, `evcc_is_charging` to `CalculationInput` | -| `src/batcontrol/logic/default.py` | Peak shaving simulation + PV charge rate limiting | -| `src/batcontrol/logic/logic.py` | Pass peak_shaving config | -| `src/batcontrol/core.py` | Wire EV data into CalculationInput | +| `src/batcontrol/logic/logic_interface.py` | Add peak shaving fields + `evcc_is_charging` to `CalculationParameters` | +| `src/batcontrol/logic/default.py` | `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` | +| `src/batcontrol/core.py` | Wire EVCC state + peak shaving config into `CalculationParameters` | | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | -| `tests/batcontrol/logic/test_peak_shaving.py` | New | -| `tests/batcontrol/test_evcc_power.py` | New | +| `tests/batcontrol/logic/test_peak_shaving.py` | New — algorithm + decision tests | + +**Not modified:** `evcc_api.py` (no changes needed), `logic.py` factory (config flows through `CalculationParameters`) --- -## 9. Open Questions +## 9. Resolved Design Decisions + +1. **EVCC integration:** Use existing `evcc_is_charging` boolean only. No `chargePower` subscription — it was reported as unreliable and is not needed for the on/off peak shaving decision. + +2. **Grid charging interaction (MODE -1 vs MODE 8):** `force_charge` takes priority. If price logic triggers grid charging during peak shaving hours, a warning is logged but grid charging proceeds. This should rarely occur in practice because PV-heavy hours have low prices. + +3. **Interface consistency:** Peak shaving config is passed via `CalculationParameters` (extended with new fields), following the existing pattern. No separate `set_peak_shaving_config()` method. + +4. **High SOC handling:** When battery is in `always_allow_discharge` region, peak shaving is skipped entirely. This avoids toggling at near-full SOC and is consistent with the system's existing high-SOC behavior. + +5. **Algorithm uses net PV surplus:** The charge limit calculation uses `production - consumption` (positive only), not raw production. This prevents over-throttling when household consumption absorbs most of the PV. + +## 10. Known Limitations (v1) + +1. **Flat charge distribution:** The charge rate limit is uniform across all slots, but PV production peaks midday. The battery may not reach exactly 100% by the target hour. Acceptable for v1. -1. **EVCC chargePower topic** — Is `{loadpoint_root}/chargePower` the correct EVCC MQTT topic name? -2. **Interaction with grid charging** — If price logic wants to grid-charge (MODE -1) but peak shaving wants to limit PV charge (MODE 8), which wins? Current plan: peak shaving only affects PV charge rate, doesn't block grid charging decisions. +2. **No intra-day target adjustment:** If clouds reduce PV significantly, the limit stays as calculated until the next evaluation cycle (every 3 minutes). The system self-corrects because free capacity stays high, which increases the allowed charge rate. From 7823f958d166fe7560f48a3e4e12faa79873079f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 11:19:16 +0000 Subject: [PATCH 17/35] Move EVCC charging check from logic layer to core.py EVCC is an external integration concern, not part of calculation logic. The evcc_is_charging guard now lives in core.py (similar to discharge_blocked), keeping the logic layer clean and independent of EVCC. - Remove evcc_is_charging from CalculationParameters - Add EVCC charging guard in core.py after logic.calculate() - Remove EVCC check from _apply_peak_shaving() in default.py - Update tests section accordingly https://claude.ai/code/session_01T62gAfD8CBB9uSU4w3uq1y --- PLAN.md | 66 +++++++++++++++++++++++++++------------------------------ 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/PLAN.md b/PLAN.md index 2ec3b2b4..e9d09c8f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -31,21 +31,16 @@ peak_shaving: ### 2.1 Approach -Peak shaving uses the **existing** `evcc_is_charging` boolean from `evcc_api.py` to decide whether to apply PV charge limiting. No new EVCC subscriptions or topics are needed. +The EVCC check is handled in `core.py`, **not** in the logic layer. EVCC is an external integration concern independent of the calculation logic — similar to how `discharge_blocked` is handled in `core.py` today. -- `evcc_is_charging = True` → peak shaving disabled (EV is consuming energy) +- `evcc_is_charging = True` → `core.py` skips peak shaving entirely (does not pass it to logic) - `evcc_is_charging = False` → peak shaving may apply **Note:** We intentionally do **not** subscribe to or rely on `chargePower`. The existing `charging` topic is sufficient for the peak shaving decision. -### 2.2 No Changes to `evcc_api.py` +### 2.2 No Changes to `evcc_api.py` or Logic Layer -The existing EVCC integration already provides: -- `self.evcc_is_charging` — whether any loadpoint is actively charging -- Discharge blocking during EV charging -- Battery halt SOC management - -Peak shaving only needs the `evcc_is_charging` boolean, which is already available in `core.py` via `self.evcc_api.evcc_is_charging`. +The existing EVCC integration already provides `self.evcc_is_charging` in `core.py` via `self.evcc_api.evcc_is_charging`. The logic layer (`default.py`) does not need to know about EVCC — the decision to skip peak shaving during EV charging is made in `core.py` before/after calling the logic. --- @@ -127,15 +122,11 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): return int(charge_rate_w) ``` -### 3.3 EVCC Charging Disables Peak Shaving - -When EVCC reports `charging=true`, peak shaving is disabled. All energy goes to EV. - -### 3.4 Always-Allow-Discharge Region Skips Peak Shaving +### 3.3 Always-Allow-Discharge Region Skips Peak Shaving When `stored_energy >= max_capacity * always_allow_discharge_limit`, the battery is in the "always allow discharge" region. In this region, peak shaving is **not applied** — the system is already at high SOC and the normal discharge logic takes over. This also avoids toggling issues when SOC fluctuates near 100%. -### 3.5 Force Charge (MODE -1) Takes Priority Over Peak Shaving +### 3.4 Force Charge (MODE -1) Takes Priority Over Peak Shaving If the price-based logic decides to force charge from grid (`charge_from_grid=True`), this overrides peak shaving. The force charge decision means energy is cheap enough to justify grid charging — peak shaving should not interfere. @@ -149,7 +140,7 @@ In practice, force charge should rarely trigger during peak shaving hours becaus - Prices are typically low during peak PV (no incentive to grid-charge) - There should be enough PV to fill the battery by the target hour -### 3.6 Implementation in `default.py` +### 3.5 Implementation in `default.py` **Post-processing step** in `calculate_inverter_mode()`, after existing logic returns `inverter_control_settings`: @@ -169,8 +160,9 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): Skipped when: - Past the target hour (allow_full_battery_after) - Battery is in always_allow_discharge region (high SOC) - - EVCC is actively charging an EV - Force charge from grid is active (MODE -1) + + Note: EVCC charging check is handled in core.py, not here. """ # After target hour: no limit if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: @@ -181,11 +173,6 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): logger.debug('[PeakShaving] Skipped: battery in always_allow_discharge region') return settings - # EVCC charging: skip peak shaving - if self.calculation_parameters.evcc_is_charging: - logger.debug('[PeakShaving] Skipped: EVCC is charging') - return settings - # Force charge takes priority over peak shaving if settings.charge_from_grid: logger.warning('[PeakShaving] Skipped: force_charge (MODE -1) active, ' @@ -212,7 +199,7 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): return settings ``` -### 3.7 Data Flow — Extended `CalculationParameters` +### 3.6 Data Flow — Extended `CalculationParameters` Peak shaving configuration is passed via the existing `CalculationParameters` dataclass (consistent with existing interface pattern): @@ -227,16 +214,11 @@ class CalculationParameters: # Peak shaving parameters peak_shaving_enabled: bool = False peak_shaving_allow_full_after: int = 14 # Hour (0-23) - evcc_is_charging: bool = False # Whether any EVCC loadpoint is actively charging ``` In `core.py`, the `CalculationParameters` constructor is extended: ```python -evcc_is_charging = False -if self.evcc_api is not None: - evcc_is_charging = self.evcc_api.evcc_is_charging - peak_shaving_config = self.config.get('peak_shaving', {}) calc_parameters = CalculationParameters( @@ -246,7 +228,6 @@ calc_parameters = CalculationParameters( self.get_max_capacity(), peak_shaving_enabled=peak_shaving_config.get('enabled', False), peak_shaving_allow_full_after=peak_shaving_config.get('allow_full_battery_after', 14), - evcc_is_charging=evcc_is_charging, ) ``` @@ -264,9 +245,25 @@ No new instance vars needed. Peak shaving config is read from `self.config` each ### 4.2 Run Loop -Extend `CalculationParameters` construction (see Section 3.7). No other changes to the run loop. +Extend `CalculationParameters` construction (see Section 3.6). + +### 4.3 EVCC Charging Check + +The EVCC charging check is handled in `core.py`, keeping EVCC concerns out of the logic layer. This follows the same pattern as `discharge_blocked` (line 536-541 in current code). + +After `logic.calculate()` returns and before mode dispatch, if EVCC is charging and peak shaving produced a charge limit, the limit is cleared: + +```python +# EVCC charging disables peak shaving (handled in core, not logic) +evcc_is_charging = (self.evcc_api is not None and self.evcc_api.evcc_is_charging) +if evcc_is_charging and inverter_settings.limit_battery_charge_rate >= 0: + logger.debug('[PeakShaving] Skipped: EVCC is charging') + inverter_settings.limit_battery_charge_rate = -1 +``` + +**Note:** This is a simple approach. If `limit_battery_charge_rate` was set by non-peak-shaving logic, this would also clear it. However, in the current codebase, MODE 8 with `allow_discharge=True` is only set by peak shaving (the existing logic uses MODE 8 only in combination with `allow_discharge=False` for the "avoid discharge" path). If this changes in the future, a more targeted approach (e.g., a flag on the settings) would be needed. -### 4.3 Mode Selection +### 4.4 Mode Selection In the mode selection block (after `logic.calculate()`), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed — peak shaving sets the limit in `InverterControlSettings` and the existing dispatch handles it. @@ -307,7 +304,6 @@ QoS: 1 for all topics (consistent with existing MQTT API). **Decision tests (`_apply_peak_shaving`):** - `peak_shaving_enabled = False` → no change to settings -- `evcc_is_charging = True` → peak shaving skipped - `charge_from_grid = True` → peak shaving skipped, warning logged - Battery in always_allow_discharge region → peak shaving skipped - Before target hour, limit calculated → `limit_battery_charge_rate` set @@ -339,9 +335,9 @@ QoS: 1 for all topics (consistent with existing MQTT API). | File | Change | |------|--------| | `config/batcontrol_config_dummy.yaml` | Add `peak_shaving` section | -| `src/batcontrol/logic/logic_interface.py` | Add peak shaving fields + `evcc_is_charging` to `CalculationParameters` | +| `src/batcontrol/logic/logic_interface.py` | Add peak shaving fields to `CalculationParameters` | | `src/batcontrol/logic/default.py` | `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` | -| `src/batcontrol/core.py` | Wire EVCC state + peak shaving config into `CalculationParameters` | +| `src/batcontrol/core.py` | Wire peak shaving config into `CalculationParameters`, EVCC charging guard | | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | | `tests/batcontrol/logic/test_peak_shaving.py` | New — algorithm + decision tests | @@ -351,7 +347,7 @@ QoS: 1 for all topics (consistent with existing MQTT API). ## 9. Resolved Design Decisions -1. **EVCC integration:** Use existing `evcc_is_charging` boolean only. No `chargePower` subscription — it was reported as unreliable and is not needed for the on/off peak shaving decision. +1. **EVCC integration:** Use existing `evcc_is_charging` boolean only, checked in `core.py` (not in logic layer). No `chargePower` subscription — it was reported as unreliable and is not needed for the on/off peak shaving decision. EVCC is an external integration concern and stays in `core.py`, following the same pattern as `discharge_blocked`. 2. **Grid charging interaction (MODE -1 vs MODE 8):** `force_charge` takes priority. If price logic triggers grid charging during peak shaving hours, a warning is logged but grid charging proceeds. This should rarely occur in practice because PV-heavy hours have low prices. From 7b23f21c5d1e59c66fb3afc59edcf6463f003161 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 11:28:12 +0000 Subject: [PATCH 18/35] Restructure plan: independent NextLogic class + EVCC mode/connected topics Major changes: - Peak shaving lives in new NextLogic class (type: next), DefaultLogic untouched - EVCC: subscribe to loadpoint mode + connected topics (derived from loadpoint_topic) - Disable peak shaving when EV connected + mode=pv (immediately, not just on charging) - evcc_ev_expects_pv_surplus property for the connected+pv check - Updated file list: new next.py, evcc_api.py now modified, default.py not modified https://claude.ai/code/session_01T62gAfD8CBB9uSU4w3uq1y --- PLAN.md | 292 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 222 insertions(+), 70 deletions(-) diff --git a/PLAN.md b/PLAN.md index e9d09c8f..34df7620 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,13 +6,20 @@ Add peak shaving to batcontrol: manage PV battery charging rate so the battery f **Problem:** All PV systems in the country produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest and — for newer installations — feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible into the battery, rather than feeding it into the grid during peak hours. -**EVCC interaction:** When an EV is actively charging (`charging=true`), peak shaving is disabled — the EV consumes the excess PV. When an EV is connected in EVCC "pv" mode (waiting for surplus), EVCC+EV will naturally absorb excessive PV energy once the surplus threshold is reached. +**EVCC interaction:** +- When an EV is actively charging (`charging=true`), peak shaving is disabled — the EV consumes the excess PV. +- When an EV is connected and the loadpoint mode is `pv`, peak shaving is also disabled — EVCC+EV will naturally absorb excessive PV energy once the surplus threshold is reached. +- If the EV disconnects or the mode changes away from `pv`, peak shaving is re-enabled. Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging. +**Logic architecture:** Peak shaving is implemented as a **new independent logic class** (`NextLogic`), selectable via `type: next` in the config. The existing `DefaultLogic` remains untouched. This allows users to opt into the new behavior while keeping the stable default path. + --- -## 1. Configuration — Top-Level `peak_shaving` Section +## 1. Configuration + +### 1.1 Top-Level `peak_shaving` Section ```yaml peak_shaving: @@ -23,30 +30,124 @@ peak_shaving: **`allow_full_battery_after`** — Target hour for the battery to be full: - **Before this hour:** PV charge rate is limited to spread charging evenly. The battery fills gradually instead of reaching 100% early and overflowing PV to grid. - **At/after this hour:** No PV charge limit. Battery is allowed to be 100% full. PV overflow to grid is acceptable (e.g., EV arrives home and the charger absorbs excess). -- **During EV charging (EVCC `charging=true`):** Peak shaving disabled entirely. All energy flows to the car. +- **During EV charging or EV connected in PV mode:** Peak shaving disabled entirely. + +### 1.2 Logic Type Selection + +```yaml +# In the top-level config or battery_control section: +type: next # Use 'next' to enable peak shaving logic (default: 'default') +``` + +The `type: next` logic includes all existing `DefaultLogic` behavior plus peak shaving as a post-processing step. --- -## 2. EVCC Integration — Use Existing Charging State +## 2. EVCC Integration — Loadpoint Mode & Connected State ### 2.1 Approach -The EVCC check is handled in `core.py`, **not** in the logic layer. EVCC is an external integration concern independent of the calculation logic — similar to how `discharge_blocked` is handled in `core.py` today. +Peak shaving is disabled when **any** of the following EVCC conditions are true: +1. **`charging = true`** — EV is actively charging (already tracked) +2. **`connected = true` AND `mode = pv`** — EV is plugged in and waiting for PV surplus + +The EVCC check is handled in `core.py`, **not** in the logic layer. EVCC is an external integration concern, same pattern as `discharge_blocked`. + +### 2.2 New EVCC Topics — Derived from `loadpoint_topic` + +The `mode` and `connected` topics are derived from the existing `loadpoint_topic` config by stripping `/charging` and appending the relevant suffix: + +``` +evcc/loadpoints/1/charging → evcc/loadpoints/1/mode + → evcc/loadpoints/1/connected +``` + +Topics not ending in `/charging`: log warning, skip mode/connected subscription. + +### 2.3 Changes to `evcc_api.py` + +**New state:** +```python +self.evcc_loadpoint_mode = {} # topic_root → mode string ("pv", "now", "minpv", "off") +self.evcc_loadpoint_connected = {} # topic_root → bool +self.list_topics_mode = [] # derived mode topics +self.list_topics_connected = [] # derived connected topics +``` + +**In `__init__`:** For each loadpoint topic ending in `/charging`: +```python +root = topic[:-len('/charging')] +mode_topic = root + '/mode' +connected_topic = root + '/connected' +self.list_topics_mode.append(mode_topic) +self.list_topics_connected.append(connected_topic) +self.evcc_loadpoint_mode[root] = None +self.evcc_loadpoint_connected[root] = False +self.client.message_callback_add(mode_topic, self._handle_message) +self.client.message_callback_add(connected_topic, self._handle_message) +``` -- `evcc_is_charging = True` → `core.py` skips peak shaving entirely (does not pass it to logic) -- `evcc_is_charging = False` → peak shaving may apply +**In `on_connect`:** Subscribe to mode and connected topics. -**Note:** We intentionally do **not** subscribe to or rely on `chargePower`. The existing `charging` topic is sufficient for the peak shaving decision. +**In `_handle_message`:** Route to new handlers based on topic matching. -### 2.2 No Changes to `evcc_api.py` or Logic Layer +**New handlers:** +```python +def handle_mode_message(self, message): + """Handle incoming loadpoint mode messages.""" + root = message.topic[:-len('/mode')] + mode = message.payload.decode('utf-8').strip().lower() + old_mode = self.evcc_loadpoint_mode.get(root) + if old_mode != mode: + logger.info('Loadpoint %s mode changed: %s → %s', root, old_mode, mode) + self.evcc_loadpoint_mode[root] = mode + +def handle_connected_message(self, message): + """Handle incoming loadpoint connected messages.""" + root = message.topic[:-len('/connected')] + connected = re.match(b'true', message.payload, re.IGNORECASE) is not None + old_connected = self.evcc_loadpoint_connected.get(root, False) + if old_connected != connected: + logger.info('Loadpoint %s connected: %s', root, connected) + self.evcc_loadpoint_connected[root] = connected +``` + +**New public property:** +```python +@property +def evcc_ev_expects_pv_surplus(self) -> bool: + """True if any loadpoint has an EV connected in PV mode.""" + for root in self.evcc_loadpoint_connected: + if self.evcc_loadpoint_connected.get(root, False) and \ + self.evcc_loadpoint_mode.get(root) == 'pv': + return True + return False +``` -The existing EVCC integration already provides `self.evcc_is_charging` in `core.py` via `self.evcc_api.evcc_is_charging`. The logic layer (`default.py`) does not need to know about EVCC — the decision to skip peak shaving during EV charging is made in `core.py` before/after calling the logic. +**`shutdown`:** Unsubscribe from mode and connected topics. + +### 2.4 Backward Compatibility + +- Topics not ending in `/charging`: warning logged, no mode/connected sub, existing behavior unchanged +- `evcc_ev_expects_pv_surplus` returns `False` when no data received +- Existing `evcc_is_charging` behavior is completely unchanged --- -## 3. Logic Changes — Peak Shaving via PV Charge Rate Limiting +## 3. New Logic Class — `NextLogic` + +### 3.1 Architecture + +`NextLogic` is an **independent** `LogicInterface` implementation in `src/batcontrol/logic/next.py`. It contains all the logic from `DefaultLogic` plus peak shaving as a post-processing step. -### 3.1 Core Algorithm +The implementation approach: +- Copy `DefaultLogic` to create `NextLogic` +- Add peak shaving methods (`_apply_peak_shaving`, `_calculate_peak_shaving_charge_limit`) +- Add post-processing call in `calculate_inverter_mode()` + +This keeps `DefaultLogic` completely untouched and allows the `next` logic to evolve independently. + +### 3.2 Core Algorithm The algorithm spreads battery charging over time so the battery reaches full at the target hour: @@ -69,9 +170,7 @@ If expected PV surplus is less than free capacity, no limit needed (battery won' **Note:** The charge limit is distributed evenly across slots. This is a simplification — PV production peaks midday while the limit is flat. This means the limit may have no effect in low-PV morning slots and may clip excess in high-PV midday slots. The battery may not reach exactly 100% by the target hour. This is acceptable for v1; a PV-weighted distribution could be added later. -### 3.2 Algorithm Implementation - -Following the default logic's pattern of iterating through future slots: +### 3.3 Algorithm Implementation ```python def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): @@ -122,11 +221,11 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): return int(charge_rate_w) ``` -### 3.3 Always-Allow-Discharge Region Skips Peak Shaving +### 3.4 Always-Allow-Discharge Region Skips Peak Shaving When `stored_energy >= max_capacity * always_allow_discharge_limit`, the battery is in the "always allow discharge" region. In this region, peak shaving is **not applied** — the system is already at high SOC and the normal discharge logic takes over. This also avoids toggling issues when SOC fluctuates near 100%. -### 3.4 Force Charge (MODE -1) Takes Priority Over Peak Shaving +### 3.5 Force Charge (MODE -1) Takes Priority Over Peak Shaving If the price-based logic decides to force charge from grid (`charge_from_grid=True`), this overrides peak shaving. The force charge decision means energy is cheap enough to justify grid charging — peak shaving should not interfere. @@ -140,15 +239,17 @@ In practice, force charge should rarely trigger during peak shaving hours becaus - Prices are typically low during peak PV (no incentive to grid-charge) - There should be enough PV to fill the battery by the target hour -### 3.5 Implementation in `default.py` +### 3.6 Peak Shaving Post-Processing in `NextLogic` -**Post-processing step** in `calculate_inverter_mode()`, after existing logic returns `inverter_control_settings`: +In `calculate_inverter_mode()`, after the existing DefaultLogic calculation returns `inverter_control_settings`: ```python # Apply peak shaving as post-processing step if self.calculation_parameters.peak_shaving_enabled: inverter_control_settings = self._apply_peak_shaving( inverter_control_settings, calc_input, calc_timestamp) + +return inverter_control_settings ``` **`_apply_peak_shaving()`:** @@ -162,7 +263,7 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): - Battery is in always_allow_discharge region (high SOC) - Force charge from grid is active (MODE -1) - Note: EVCC charging check is handled in core.py, not here. + Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. """ # After target hour: no limit if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: @@ -199,7 +300,7 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): return settings ``` -### 3.6 Data Flow — Extended `CalculationParameters` +### 3.7 Data Flow — Extended `CalculationParameters` Peak shaving configuration is passed via the existing `CalculationParameters` dataclass (consistent with existing interface pattern): @@ -216,7 +317,44 @@ class CalculationParameters: peak_shaving_allow_full_after: int = 14 # Hour (0-23) ``` -In `core.py`, the `CalculationParameters` constructor is extended: +**No changes needed to `CalculationInput`** — the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. + +--- + +## 4. Logic Factory — `logic.py` + +The factory gains a new type `next`: + +```python +@staticmethod +def create_logic(config: dict, timezone) -> LogicInterface: + request_type = config.get('type', 'default').lower() + interval_minutes = config.get('time_resolution_minutes', 60) + + if request_type == 'default': + logic = DefaultLogic(timezone, interval_minutes=interval_minutes) + # ... existing expert tuning ... + elif request_type == 'next': + logic = NextLogic(timezone, interval_minutes=interval_minutes) + # ... same expert tuning as default ... + else: + raise RuntimeError(f'[Logic] Unknown logic type {config["type"]}') + return logic +``` + +The `NextLogic` supports the same expert tuning attributes as `DefaultLogic`. + +--- + +## 5. Core Integration — `core.py` + +### 5.1 Init + +No new instance vars needed. Peak shaving config is read from `self.config` each run cycle and passed via `CalculationParameters`. + +### 5.2 Run Loop + +Extend `CalculationParameters` construction: ```python peak_shaving_config = self.config.get('peak_shaving', {}) @@ -231,45 +369,36 @@ calc_parameters = CalculationParameters( ) ``` -**No changes needed to `CalculationInput`** — the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. - -**No changes needed to `logic.py` factory** — configuration flows through `CalculationParameters` via the existing `set_calculation_parameters()` method. - ---- - -## 4. Core Integration — `core.py` - -### 4.1 Init - -No new instance vars needed. Peak shaving config is read from `self.config` each run cycle and passed via `CalculationParameters`. - -### 4.2 Run Loop +### 5.3 EVCC Peak Shaving Guard -Extend `CalculationParameters` construction (see Section 3.6). +The EVCC check is handled in `core.py`, keeping EVCC concerns out of the logic layer. This follows the same pattern as `discharge_blocked` (line 536-541 in current code). -### 4.3 EVCC Charging Check - -The EVCC charging check is handled in `core.py`, keeping EVCC concerns out of the logic layer. This follows the same pattern as `discharge_blocked` (line 536-541 in current code). - -After `logic.calculate()` returns and before mode dispatch, if EVCC is charging and peak shaving produced a charge limit, the limit is cleared: +After `logic.calculate()` returns and before mode dispatch, peak shaving is overridden if EVCC conditions require it: ```python -# EVCC charging disables peak shaving (handled in core, not logic) -evcc_is_charging = (self.evcc_api is not None and self.evcc_api.evcc_is_charging) -if evcc_is_charging and inverter_settings.limit_battery_charge_rate >= 0: - logger.debug('[PeakShaving] Skipped: EVCC is charging') - inverter_settings.limit_battery_charge_rate = -1 +# EVCC disables peak shaving (handled in core, not logic) +if self.evcc_api is not None: + evcc_disable_peak_shaving = ( + self.evcc_api.evcc_is_charging or + self.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving and inverter_settings.limit_battery_charge_rate >= 0: + if self.evcc_api.evcc_is_charging: + logger.debug('[PeakShaving] Disabled: EVCC is actively charging') + else: + logger.debug('[PeakShaving] Disabled: EV connected in PV mode') + inverter_settings.limit_battery_charge_rate = -1 ``` -**Note:** This is a simple approach. If `limit_battery_charge_rate` was set by non-peak-shaving logic, this would also clear it. However, in the current codebase, MODE 8 with `allow_discharge=True` is only set by peak shaving (the existing logic uses MODE 8 only in combination with `allow_discharge=False` for the "avoid discharge" path). If this changes in the future, a more targeted approach (e.g., a flag on the settings) would be needed. +**Note:** This clears any `limit_battery_charge_rate` set by the logic, not just peak shaving. In the current codebase this is safe because MODE 8 with `allow_discharge=True` is only set by peak shaving. If this changes in the future, a more targeted approach (e.g., a flag on the settings) would be needed. -### 4.4 Mode Selection +### 5.4 Mode Selection In the mode selection block (after `logic.calculate()`), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed — peak shaving sets the limit in `InverterControlSettings` and the existing dispatch handles it. --- -## 5. MQTT API — Publish Peak Shaving State +## 6. MQTT API — Publish Peak Shaving State Publish topics: - `{base}/peak_shaving/enabled` — boolean (`true`/`false`, plain text, retained) @@ -289,9 +418,9 @@ QoS: 1 for all topics (consistent with existing MQTT API). --- -## 6. Tests +## 7. Tests -### 6.1 Logic Tests (`tests/batcontrol/logic/test_peak_shaving.py`) +### 7.1 Logic Tests (`tests/batcontrol/logic/test_peak_shaving.py`) **Algorithm tests (`_calculate_peak_shaving_charge_limit`):** - High PV surplus, small free capacity → low charge limit @@ -311,54 +440,77 @@ QoS: 1 for all topics (consistent with existing MQTT API). - Existing tighter limit from other logic → kept (more restrictive wins) - Peak shaving limit tighter than existing → peak shaving limit applied -### 6.2 Config Tests +### 7.2 EVCC Tests (`tests/batcontrol/test_evcc_mode.py`) +- Topic derivation: `evcc/loadpoints/1/charging` → mode: `evcc/loadpoints/1/mode`, connected: `evcc/loadpoints/1/connected` +- Non-standard topic (not ending in `/charging`) → warning, no mode/connected sub +- `handle_mode_message` parses mode string correctly +- `handle_connected_message` parses boolean correctly +- `evcc_ev_expects_pv_surplus`: connected=true + mode=pv → True +- `evcc_ev_expects_pv_surplus`: connected=true + mode=now → False +- `evcc_ev_expects_pv_surplus`: connected=false + mode=pv → False +- `evcc_ev_expects_pv_surplus`: no data received → False +- Multi-loadpoint: one connected+pv is enough to return True +- Mode change from pv to now → `evcc_ev_expects_pv_surplus` changes to False + +### 7.3 Config Tests + +- `type: next` → creates `NextLogic` instance +- `type: default` → creates `DefaultLogic` instance (unchanged) - With `peak_shaving` section → `CalculationParameters` fields set correctly - Without `peak_shaving` section → `peak_shaving_enabled = False` (default) -- Invalid `allow_full_battery_after` values (edge cases: 0, 23) --- -## 7. Implementation Order +## 8. Implementation Order -1. **Config** — Add `peak_shaving` section to dummy config +1. **Config** — Add `peak_shaving` section to dummy config, add `type: next` option 2. **Data model** — Extend `CalculationParameters` with peak shaving fields -3. **Logic** — `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` in `default.py` -4. **Core** — Wire EVCC state + peak shaving config into `CalculationParameters` -5. **MQTT** — Publish topics + settable topics + HA discovery -6. **Tests** +3. **EVCC** — Add mode and connected topic subscriptions, `evcc_ev_expects_pv_surplus` property +4. **NextLogic** — New file `next.py`: copy DefaultLogic, add `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` +5. **Logic factory** — Add `type: next` → `NextLogic` in `logic.py` +6. **Core** — Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard +7. **MQTT** — Publish topics + settable topics + HA discovery +8. **Tests** --- -## 8. Files Modified +## 9. Files Modified | File | Change | |------|--------| | `config/batcontrol_config_dummy.yaml` | Add `peak_shaving` section | | `src/batcontrol/logic/logic_interface.py` | Add peak shaving fields to `CalculationParameters` | -| `src/batcontrol/logic/default.py` | `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` | -| `src/batcontrol/core.py` | Wire peak shaving config into `CalculationParameters`, EVCC charging guard | +| `src/batcontrol/logic/next.py` | **New** — `NextLogic` class with peak shaving | +| `src/batcontrol/logic/logic.py` | Add `type: next` → `NextLogic` | +| `src/batcontrol/evcc_api.py` | Add mode + connected topic subscriptions, `evcc_ev_expects_pv_surplus` | +| `src/batcontrol/core.py` | Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard | | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | | `tests/batcontrol/logic/test_peak_shaving.py` | New — algorithm + decision tests | +| `tests/batcontrol/test_evcc_mode.py` | New — mode/connected topic tests | -**Not modified:** `evcc_api.py` (no changes needed), `logic.py` factory (config flows through `CalculationParameters`) +**Not modified:** `default.py` (untouched — peak shaving is in `next.py`) --- -## 9. Resolved Design Decisions +## 10. Resolved Design Decisions -1. **EVCC integration:** Use existing `evcc_is_charging` boolean only, checked in `core.py` (not in logic layer). No `chargePower` subscription — it was reported as unreliable and is not needed for the on/off peak shaving decision. EVCC is an external integration concern and stays in `core.py`, following the same pattern as `discharge_blocked`. +1. **New independent logic class:** Peak shaving lives in `NextLogic` (`type: next`), not as a modification to `DefaultLogic`. This keeps the stable default path untouched and allows the next logic to evolve independently. `NextLogic` is a full copy of `DefaultLogic` with peak shaving added. -2. **Grid charging interaction (MODE -1 vs MODE 8):** `force_charge` takes priority. If price logic triggers grid charging during peak shaving hours, a warning is logged but grid charging proceeds. This should rarely occur in practice because PV-heavy hours have low prices. +2. **EVCC integration:** Handled in `core.py` (not logic layer). Peak shaving is disabled when `evcc_is_charging` OR (`connected=true` AND `mode=pv`). The mode and connected topics are derived from the existing `loadpoint_topic` config by stripping `/charging`. No `chargePower` subscription — it was reported as unreliable. -3. **Interface consistency:** Peak shaving config is passed via `CalculationParameters` (extended with new fields), following the existing pattern. No separate `set_peak_shaving_config()` method. +3. **Grid charging interaction (MODE -1 vs MODE 8):** `force_charge` takes priority. If price logic triggers grid charging during peak shaving hours, a warning is logged but grid charging proceeds. -4. **High SOC handling:** When battery is in `always_allow_discharge` region, peak shaving is skipped entirely. This avoids toggling at near-full SOC and is consistent with the system's existing high-SOC behavior. +4. **Interface consistency:** Peak shaving config is passed via `CalculationParameters` (extended with new fields), following the existing pattern. -5. **Algorithm uses net PV surplus:** The charge limit calculation uses `production - consumption` (positive only), not raw production. This prevents over-throttling when household consumption absorbs most of the PV. +5. **High SOC handling:** When battery is in `always_allow_discharge` region, peak shaving is skipped entirely. This avoids toggling at near-full SOC. -## 10. Known Limitations (v1) +6. **Algorithm uses net PV surplus:** The charge limit calculation uses `production - consumption` (positive only), not raw production. This prevents over-throttling when household consumption absorbs most of the PV. + +## 11. Known Limitations (v1) 1. **Flat charge distribution:** The charge rate limit is uniform across all slots, but PV production peaks midday. The battery may not reach exactly 100% by the target hour. Acceptable for v1. 2. **No intra-day target adjustment:** If clouds reduce PV significantly, the limit stays as calculated until the next evaluation cycle (every 3 minutes). The system self-corrects because free capacity stays high, which increases the allowed charge rate. + +3. **Code duplication:** `NextLogic` is a copy of `DefaultLogic`. Changes to the default logic need to be mirrored manually. Once peak shaving is stable, the two could be merged (next becomes the new default) or refactored to use composition. From 469430304374bac8c52ea240cf3ca5ccf53ed1ac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 11:43:47 +0000 Subject: [PATCH 19/35] Add nighttime skip and documentation step to plan - Skip peak shaving calculation when current production <= 0 (no PV at night) - Add docs/peak_shaving.md to implementation order and file list https://claude.ai/code/session_01T62gAfD8CBB9uSU4w3uq1y --- PLAN.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/PLAN.md b/PLAN.md index 34df7620..c0ddbb80 100644 --- a/PLAN.md +++ b/PLAN.md @@ -265,6 +265,10 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. """ + # No production right now: skip calculation (avoid unnecessary work at night) + if calc_input.production[0] <= 0: + return settings + # After target hour: no limit if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: return settings @@ -433,6 +437,7 @@ QoS: 1 for all topics (consistent with existing MQTT API). **Decision tests (`_apply_peak_shaving`):** - `peak_shaving_enabled = False` → no change to settings +- Current production = 0 (nighttime) → peak shaving skipped - `charge_from_grid = True` → peak shaving skipped, warning logged - Battery in always_allow_discharge region → peak shaving skipped - Before target hour, limit calculated → `limit_battery_charge_rate` set @@ -472,6 +477,7 @@ QoS: 1 for all topics (consistent with existing MQTT API). 6. **Core** — Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard 7. **MQTT** — Publish topics + settable topics + HA discovery 8. **Tests** +9. **Documentation** — Write `docs/peak_shaving.md` covering feature overview, configuration, EVCC interaction, algorithm explanation, and known limitations --- @@ -488,6 +494,7 @@ QoS: 1 for all topics (consistent with existing MQTT API). | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | | `tests/batcontrol/logic/test_peak_shaving.py` | New — algorithm + decision tests | | `tests/batcontrol/test_evcc_mode.py` | New — mode/connected topic tests | +| `docs/peak_shaving.md` | New — feature documentation | **Not modified:** `default.py` (untouched — peak shaving is in `next.py`) From 9c50d51440853ad48910bf4bcbda8d0882983a7b Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 15:05:18 +0100 Subject: [PATCH 20/35] Iteration 1 --- config/batcontrol_config_dummy.yaml | 11 + docs/WIKI_peak_shaving.md | 110 ++++ src/batcontrol/core.py | 68 ++- src/batcontrol/evcc_api.py | 66 +++ src/batcontrol/logic/__init__.py | 1 + src/batcontrol/logic/logic.py | 40 +- src/batcontrol/logic/logic_interface.py | 10 + src/batcontrol/logic/next.py | 557 ++++++++++++++++++++ src/batcontrol/mqtt_api.py | 67 +++ tests/batcontrol/logic/test_peak_shaving.py | 464 ++++++++++++++++ tests/batcontrol/test_evcc_mode.py | 203 +++++++ 11 files changed, 1581 insertions(+), 16 deletions(-) create mode 100644 docs/WIKI_peak_shaving.md create mode 100644 src/batcontrol/logic/next.py create mode 100644 tests/batcontrol/logic/test_peak_shaving.py create mode 100644 tests/batcontrol/test_evcc_mode.py diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 6f32975f..cc48766e 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -10,6 +10,7 @@ log_everything: false # if false debug messages from fronius.auth and urllib3.co max_logfile_size: 200 #kB logfile_path: logs/batcontrol.log battery_control: + type: default # Logic type: 'default' (standard) or 'next' (includes peak shaving) # min_price_difference is the absolute minimum price difference in Euro to justify charging your battery # if min_price_difference_rel results in a higher price difference, that will be used min_price_difference: 0.05 # minimum price difference in Euro to justify charging your battery @@ -32,6 +33,16 @@ battery_control_expert: production_offset_percent: 1.0 # Adjust production forecast by a percentage (1.0 = 100%, 0.8 = 80%, etc.) # Useful for winter mode when solar panels are covered with snow +#-------------------------- +# Peak Shaving +# Manages PV battery charging rate so the battery fills up gradually, +# reaching full capacity by a target hour (allow_full_battery_after). +# Requires logic type 'next' in battery_control section. +#-------------------------- +peak_shaving: + enabled: false + allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour + #-------------------------- # Inverter # See more Details in: https://github.com/MaStr/batcontrol/wiki/Inverter-Configuration diff --git a/docs/WIKI_peak_shaving.md b/docs/WIKI_peak_shaving.md new file mode 100644 index 00000000..18483143 --- /dev/null +++ b/docs/WIKI_peak_shaving.md @@ -0,0 +1,110 @@ +# Peak Shaving + +## Overview + +Peak shaving manages PV battery charging rate so the battery fills up gradually, reaching full capacity by a configurable target hour (`allow_full_battery_after`). This prevents the battery from being full too early in the day. + +**Problem:** All PV systems produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest — and for newer installations, feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible. + +## Configuration + +### Enable Peak Shaving + +Peak shaving requires two configuration changes: + +1. Set the logic type to `next` in the `battery_control` section: + +```yaml +battery_control: + type: next # Use 'next' to enable peak shaving logic (default: 'default') +``` + +2. Configure the `peak_shaving` section: + +```yaml +peak_shaving: + enabled: false + allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour +``` + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `enabled` | bool | `false` | Enable/disable peak shaving | +| `allow_full_battery_after` | int | `14` | Target hour (0-23) for the battery to be full | + +**`allow_full_battery_after`** controls when the battery is allowed to be 100% full: +- **Before this hour:** PV charge rate is limited to spread charging evenly +- **At/after this hour:** No PV charge limit, battery is allowed to reach full charge + +## How It Works + +### Algorithm + +The algorithm calculates the expected PV surplus (production minus consumption) for all time slots until the target hour. If the expected surplus would fill the battery before the target hour, it calculates a charge rate limit: + +``` +slots_remaining = slots from now until allow_full_battery_after +free_capacity = battery free capacity in Wh +pv_surplus = sum of max(production - consumption, 0) for remaining slots + +if pv_surplus > free_capacity: + charge_limit = free_capacity / slots_remaining (Wh per slot, converted to W) +``` + +The charge limit is applied using **MODE 8** (`limit_battery_charge_rate`), which limits PV charging while still allowing battery discharge. + +### Skip Conditions + +Peak shaving is automatically skipped when: + +1. **No PV production** — nighttime, no action needed +2. **Past the target hour** — battery is allowed to be full +3. **Battery in always_allow_discharge region** — SOC is already high +4. **Grid charging active (MODE -1)** — force charge takes priority +5. **EVCC is actively charging** — EV consumes the excess PV +6. **EV connected in PV mode** — EVCC will absorb PV surplus + +### EVCC Interaction + +When an EV charger is managed by EVCC: + +- **EV actively charging** (`charging=true`): Peak shaving is disabled — the EV consumes the excess PV +- **EV connected in PV mode** (`connected=true` AND `mode=pv`): Peak shaving is disabled — EVCC will naturally absorb surplus PV when the threshold is reached +- **EV disconnects or mode changes**: Peak shaving is re-enabled + +The EVCC integration derives `mode` and `connected` topics automatically from the configured `loadpoint_topic` by replacing `/charging` with `/mode` and `/connected`. + +## MQTT API + +### Published Topics + +| Topic | Type | Retained | Description | +|-------|------|----------|-------------| +| `{base}/peak_shaving/enabled` | bool | Yes | Peak shaving enabled status | +| `{base}/peak_shaving/allow_full_battery_after` | int | Yes | Target hour (0-23) | +| `{base}/peak_shaving/charge_limit` | int | No | Current charge limit in W (-1 if inactive) | + +### Settable Topics + +| Topic | Accepts | Description | +|-------|---------|-------------| +| `{base}/peak_shaving/enabled/set` | `true`/`false` | Enable/disable peak shaving | +| `{base}/peak_shaving/allow_full_battery_after/set` | int 0-23 | Set target hour | + +### Home Assistant Auto-Discovery + +The following HA entities are automatically created: + +- **Peak Shaving Enabled** — switch entity +- **Peak Shaving Allow Full After** — number entity (0-23, step 1) +- **Peak Shaving Charge Limit** — sensor entity (unit: W) + +## Known Limitations + +1. **Flat charge distribution:** The charge rate limit is uniform across all time slots, but PV production peaks at midday. The battery may not reach exactly 100% by the target hour. + +2. **No intra-day adjustment:** If clouds reduce PV significantly, the limit stays as calculated until the next evaluation cycle (every 3 minutes). The system self-corrects because free capacity stays high, which increases the allowed charge rate. + +3. **Code duplication:** `NextLogic` is a copy of `DefaultLogic` with peak shaving added. Once stable, the two could be merged or refactored. diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index a21b0592..5a3012c0 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -270,6 +270,16 @@ def __init__(self, configdict: dict): self.api_set_production_offset, float ) + self.mqtt_api.register_set_callback( + 'peak_shaving/enabled', + self.api_set_peak_shaving_enabled, + str + ) + self.mqtt_api.register_set_callback( + 'peak_shaving/allow_full_battery_after', + self.api_set_peak_shaving_allow_full_after, + int + ) # Inverter Callbacks self.inverter.activate_mqtt(self.mqtt_api) @@ -508,11 +518,16 @@ def run(self): self.get_stored_usable_energy(), self.get_free_capacity() ) + peak_shaving_config = self.config.get('peak_shaving', {}) + calc_parameters = CalculationParameters( self.max_charging_from_grid_limit, self.min_price_difference, self.min_price_difference_rel, - self.get_max_capacity() + self.get_max_capacity(), + peak_shaving_enabled=peak_shaving_config.get('enabled', False), + peak_shaving_allow_full_after=peak_shaving_config.get( + 'allow_full_battery_after', 14), ) self.last_logic_instance = this_logic_run @@ -540,6 +555,24 @@ def run(self): logger.debug('Discharge blocked due to external lock') inverter_settings.allow_discharge = False + # EVCC disables peak shaving (handled in core, not logic) + if self.evcc_api is not None: + evcc_disable_peak_shaving = ( + self.evcc_api.evcc_is_charging or + self.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving and inverter_settings.limit_battery_charge_rate >= 0: + if self.evcc_api.evcc_is_charging: + logger.debug('[PeakShaving] Disabled: EVCC is actively charging') + else: + logger.debug('[PeakShaving] Disabled: EV connected in PV mode') + inverter_settings.limit_battery_charge_rate = -1 + + # Publish peak shaving charge limit (after EVCC guard may have cleared it) + if self.mqtt_api is not None: + self.mqtt_api.publish_peak_shaving_charge_limit( + inverter_settings.limit_battery_charge_rate) + if inverter_settings.allow_discharge: if inverter_settings.limit_battery_charge_rate >= 0: self.limit_battery_charge_rate(inverter_settings.limit_battery_charge_rate) @@ -797,6 +830,12 @@ def refresh_static_values(self) -> None: self.mqtt_api.publish_last_evaluation_time(self.last_run_time) # self.mqtt_api.publish_discharge_blocked(self.discharge_blocked) + # Peak shaving + peak_shaving_config = self.config.get('peak_shaving', {}) + self.mqtt_api.publish_peak_shaving_enabled( + peak_shaving_config.get('enabled', False)) + self.mqtt_api.publish_peak_shaving_allow_full_after( + peak_shaving_config.get('allow_full_battery_after', 14)) # Trigger Inverter self.inverter.refresh_api_values() @@ -926,3 +965,30 @@ def api_set_production_offset(self, production_offset: float): self.production_offset_percent = production_offset if self.mqtt_api is not None: self.mqtt_api.publish_production_offset(production_offset) + + def api_set_peak_shaving_enabled(self, enabled_str: str): + """ Set peak shaving enabled/disabled via external API request. + The change is temporary and will not be written to the config file. + """ + enabled = enabled_str.strip().lower() in ('true', 'on', '1') + logger.info('API: Setting peak shaving enabled to %s', enabled) + peak_shaving = self.config.setdefault('peak_shaving', {}) + peak_shaving['enabled'] = enabled + if self.mqtt_api is not None: + self.mqtt_api.publish_peak_shaving_enabled(enabled) + + def api_set_peak_shaving_allow_full_after(self, hour: int): + """ Set peak shaving target hour via external API request. + The change is temporary and will not be written to the config file. + """ + if hour < 0 or hour > 23: + logger.warning( + 'API: Invalid peak shaving allow_full_battery_after %d ' + '(must be 0-23)', hour) + return + logger.info( + 'API: Setting peak shaving allow_full_battery_after to %d', hour) + peak_shaving = self.config.setdefault('peak_shaving', {}) + peak_shaving['allow_full_battery_after'] = hour + if self.mqtt_api is not None: + self.mqtt_api.publish_peak_shaving_allow_full_after(hour) diff --git a/src/batcontrol/evcc_api.py b/src/batcontrol/evcc_api.py index 7ac6341d..483f19f9 100644 --- a/src/batcontrol/evcc_api.py +++ b/src/batcontrol/evcc_api.py @@ -77,6 +77,10 @@ def __init__(self, config: dict): self.evcc_is_charging = False self.evcc_loadpoint_status = {} + self.evcc_loadpoint_mode = {} # topic_root → mode string ("pv", "now", "minpv", "off") + self.evcc_loadpoint_connected = {} # topic_root → bool + self.list_topics_mode = [] # derived mode topics + self.list_topics_connected = [] # derived connected topics self.block_function = None self.set_always_allow_discharge_limit_function = None @@ -133,6 +137,23 @@ def __init__(self, config: dict): self.__store_loadpoint_status(topic, False) self.client.message_callback_add(topic, self._handle_message) + # Derive mode and connected topics from loadpoint charging topics + for topic in self.list_topics_loadpoint: + if topic.endswith('/charging'): + root = topic[:-len('/charging')] + mode_topic = root + '/mode' + connected_topic = root + '/connected' + self.list_topics_mode.append(mode_topic) + self.list_topics_connected.append(connected_topic) + self.evcc_loadpoint_mode[root] = None + self.evcc_loadpoint_connected[root] = False + self.client.message_callback_add(mode_topic, self._handle_message) + self.client.message_callback_add(connected_topic, self._handle_message) + else: + logger.warning( + 'Loadpoint topic %s does not end in /charging, ' + 'skipping mode/connected subscription', topic) + self.client.on_connect = self.on_connect def start(self): @@ -148,6 +169,10 @@ def shutdown(self): self.client.unsubscribe(self.topic_battery_halt_soc) for topic in self.list_topics_loadpoint: self.client.unsubscribe(topic) + for topic in self.list_topics_mode: + self.client.unsubscribe(topic) + for topic in self.list_topics_connected: + self.client.unsubscribe(topic) self.client.loop_stop() self.client.disconnect() @@ -161,6 +186,12 @@ def on_connect(self, client, userdata, flags, rc): # pylint: disable=unused-arg for topic in self.list_topics_loadpoint: logger.info('Subscribing to %s', topic) self.client.subscribe(topic) + for topic in self.list_topics_mode: + logger.info('Subscribing to %s', topic) + self.client.subscribe(topic) + for topic in self.list_topics_connected: + logger.info('Subscribing to %s', topic) + self.client.subscribe(topic) def wait_ready(self) -> bool: """ Wait until the MQTT client is connected to the broker """ @@ -236,6 +267,10 @@ def set_evcc_online(self, online: bool): self.block_function(False) self.__restore_old_limits() self.__reset_loadpoint_status() + # Reset mode/connected state to prevent stale values + for root in list(self.evcc_loadpoint_mode.keys()): + self.evcc_loadpoint_mode[root] = None + self.evcc_loadpoint_connected[root] = False else: logger.info('evcc is online') self.evcc_is_online = online @@ -326,6 +361,33 @@ def handle_charging_message(self, message): self.evaluate_charging_status() + def handle_mode_message(self, message): + """Handle incoming loadpoint mode messages.""" + root = message.topic[:-len('/mode')] + mode = message.payload.decode('utf-8').strip().lower() + old_mode = self.evcc_loadpoint_mode.get(root) + if old_mode != mode: + logger.info('Loadpoint %s mode changed: %s → %s', root, old_mode, mode) + self.evcc_loadpoint_mode[root] = mode + + def handle_connected_message(self, message): + """Handle incoming loadpoint connected messages.""" + root = message.topic[:-len('/connected')] + connected = re.match(b'true', message.payload, re.IGNORECASE) is not None + old_connected = self.evcc_loadpoint_connected.get(root, False) + if old_connected != connected: + logger.info('Loadpoint %s connected: %s', root, connected) + self.evcc_loadpoint_connected[root] = connected + + @property + def evcc_ev_expects_pv_surplus(self) -> bool: + """True if any loadpoint has an EV connected in PV mode.""" + for root in self.evcc_loadpoint_connected: + if self.evcc_loadpoint_connected.get(root, False) and \ + self.evcc_loadpoint_mode.get(root) == 'pv': + return True + return False + def evaluate_charging_status(self): """ Go through the loadpoints and check if one is charging """ for _, is_charging in self.evcc_loadpoint_status.items(): @@ -345,6 +407,10 @@ def _handle_message(self, client, userdata, message): # pylint: disable=unused- # Check if message.topic is in self.list_topics_loadpoint elif message.topic in self.list_topics_loadpoint: self.handle_charging_message(message) + elif message.topic in self.list_topics_mode: + self.handle_mode_message(message) + elif message.topic in self.list_topics_connected: + self.handle_connected_message(message) else: logger.warning( 'No callback registered for %s', message.topic) diff --git a/src/batcontrol/logic/__init__.py b/src/batcontrol/logic/__init__.py index df3111fe..1bcf2814 100644 --- a/src/batcontrol/logic/__init__.py +++ b/src/batcontrol/logic/__init__.py @@ -1,3 +1,4 @@ from .logic import Logic from .logic_interface import LogicInterface, CalculationParameters, CalculationInput, CalculationOutput, InverterControlSettings from .common import CommonLogic +from .next import NextLogic diff --git a/src/batcontrol/logic/logic.py b/src/batcontrol/logic/logic.py index 1e2fb068..3aa11571 100644 --- a/src/batcontrol/logic/logic.py +++ b/src/batcontrol/logic/logic.py @@ -3,6 +3,7 @@ from .logic_interface import LogicInterface from .default import DefaultLogic +from .next import NextLogic logger = logging.getLogger(__name__) @@ -12,7 +13,8 @@ class Logic: @staticmethod def create_logic(config: dict, timezone) -> LogicInterface: """ Select and configure a logic class based on the given configuration """ - request_type = config.get('type', 'default').lower() + battery_control = config.get('battery_control', {}) + request_type = battery_control.get('type', 'default').lower() interval_minutes = config.get('time_resolution_minutes', 60) logic = None if request_type == 'default': @@ -20,19 +22,27 @@ def create_logic(config: dict, timezone) -> LogicInterface: logger.info('Using default logic') Logic.print_class_message = False logic = DefaultLogic(timezone, interval_minutes=interval_minutes) - if config.get('battery_control_expert', None) is not None: - battery_control_expert = config.get( 'battery_control_expert', {}) - attribute_list = [ - 'soften_price_difference_on_charging', - 'soften_price_difference_on_charging_factor', - 'round_price_digits', - 'charge_rate_multiplier', - ] - for attribute in attribute_list: - if attribute in battery_control_expert: - logger.debug('Setting %s to %s', attribute , - battery_control_expert[attribute]) - setattr(logic, attribute, battery_control_expert[attribute]) + elif request_type == 'next': + if Logic.print_class_message: + logger.info('Using next logic (with peak shaving support)') + Logic.print_class_message = False + logic = NextLogic(timezone, interval_minutes=interval_minutes) else: - raise RuntimeError(f'[Logic] Unknown logic type {config["type"]}') + raise RuntimeError( + f'[Logic] Unknown logic type {request_type}') + + # Apply expert tuning attributes (shared between default and next) + if config.get('battery_control_expert', None) is not None: + battery_control_expert = config.get('battery_control_expert', {}) + attribute_list = [ + 'soften_price_difference_on_charging', + 'soften_price_difference_on_charging_factor', + 'round_price_digits', + 'charge_rate_multiplier', + ] + for attribute in attribute_list: + if attribute in battery_control_expert: + logger.debug('Setting %s to %s', attribute, + battery_control_expert[attribute]) + setattr(logic, attribute, battery_control_expert[attribute]) return logic diff --git a/src/batcontrol/logic/logic_interface.py b/src/batcontrol/logic/logic_interface.py index ff207b26..13b5e891 100644 --- a/src/batcontrol/logic/logic_interface.py +++ b/src/batcontrol/logic/logic_interface.py @@ -20,6 +20,16 @@ class CalculationParameters: min_price_difference: float min_price_difference_rel: float max_capacity: float # Maximum capacity of the battery in Wh (excludes MAX_SOC) + # Peak shaving parameters + peak_shaving_enabled: bool = False + peak_shaving_allow_full_after: int = 14 # Hour (0-23) + + def __post_init__(self): + if not 0 <= self.peak_shaving_allow_full_after <= 23: + raise ValueError( + f"peak_shaving_allow_full_after must be 0-23, " + f"got {self.peak_shaving_allow_full_after}" + ) @dataclass class CalculationOutput: diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py new file mode 100644 index 00000000..f5478a50 --- /dev/null +++ b/src/batcontrol/logic/next.py @@ -0,0 +1,557 @@ +"""NextLogic — Extended battery control logic with peak shaving. + +This module provides the NextLogic class, which extends the DefaultLogic +behavior with a peak shaving post-processing step. Peak shaving manages +PV battery charging rate so the battery fills up gradually, reaching full +capacity by a configurable target hour (allow_full_battery_after). + +This prevents the battery from being full too early in the day, +avoiding excessive feed-in during midday PV peak hours. + +Usage: + Select via ``type: next`` in the battery_control config section. +""" +import logging +import datetime +import numpy as np +from typing import Optional + +from .logic_interface import LogicInterface +from .logic_interface import CalculationParameters, CalculationInput +from .logic_interface import CalculationOutput, InverterControlSettings +from .common import CommonLogic + +# Minimum remaining time in hours to prevent division by very small numbers +# when calculating charge rates. This constant serves as a safety threshold: +# - Prevents extremely high charge rates at the end of intervals +# - Ensures charge rate calculations remain within reasonable bounds +# - 1 minute (1/60 hour) is chosen as it allows adequate time for the inverter +# to respond while preventing numerical instability in the calculation +MIN_REMAINING_TIME_HOURS = 1.0 / 60.0 # 1 minute expressed in hours + +logger = logging.getLogger(__name__) + + +class NextLogic(LogicInterface): + """Extended logic class for Batcontrol with peak shaving support. + + Contains all DefaultLogic behaviour plus a peak shaving post-processing + step that limits PV charge rate to spread battery charging over time. + """ + + def __init__(self, timezone: datetime.timezone = datetime.timezone.utc, + interval_minutes: int = 60): + self.calculation_parameters = None + self.calculation_output = None + self.inverter_control_settings = None + self.round_price_digits = 4 # Default rounding for prices + self.soften_price_difference_on_charging = False + self.soften_price_difference_on_charging_factor = 5.0 # Default factor + self.timezone = timezone + self.interval_minutes = interval_minutes + self.common = CommonLogic.get_instance() + + def set_round_price_digits(self, digits: int): + """ Set the number of digits to round prices to """ + self.round_price_digits = digits + + def set_soften_price_difference_on_charging(self, soften: bool, factor: float = 5): + """ Set if the price difference should be softened on charging """ + self.soften_price_difference_on_charging = soften + self.soften_price_difference_on_charging_factor = factor + + def set_calculation_parameters(self, parameters: CalculationParameters): + """ Set the calculation parameters for the logic """ + self.calculation_parameters = parameters + self.common.max_capacity = parameters.max_capacity + + def set_timezone(self, timezone: datetime.timezone): + """ Set the timezone for the logic calculations """ + self.timezone = timezone + + def calculate(self, input_data: CalculationInput, + calc_timestamp: Optional[datetime.datetime] = None) -> bool: + """ Calculate the inverter control settings based on the input data """ + + logger.debug("Calculating inverter control settings...") + + if calc_timestamp is None: + calc_timestamp = datetime.datetime.now().astimezone(self.timezone) + + self.calculation_output = CalculationOutput( + reserved_energy=0.0, + required_recharge_energy=0.0, + min_dynamic_price_difference=0.0 + ) + + self.inverter_control_settings = self.calculate_inverter_mode( + input_data, + calc_timestamp + ) + return True + + def get_calculation_output(self) -> CalculationOutput: + """ Get the calculation output from the last calculation """ + return self.calculation_output + + def get_inverter_control_settings(self) -> InverterControlSettings: + """ Get the inverter control settings from the last calculation """ + return self.inverter_control_settings + + # ------------------------------------------------------------------ # + # Main control logic (same as DefaultLogic) # + # ------------------------------------------------------------------ # + + def calculate_inverter_mode(self, calc_input: CalculationInput, + calc_timestamp: Optional[datetime.datetime] = None + ) -> InverterControlSettings: + """ Main control logic for battery control """ + # default settings + inverter_control_settings = InverterControlSettings( + allow_discharge=False, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=-1 + ) + + if self.calculation_output is None: + logger.error("Calculation output is not set. Please call calculate() first.") + raise ValueError("Calculation output is not set. Please call calculate() first.") + + net_consumption = calc_input.consumption - calc_input.production + prices = calc_input.prices + + if calc_timestamp is None: + calc_timestamp = datetime.datetime.now().astimezone(self.timezone) + + # ensure availability of data + max_slot = min(len(net_consumption), len(prices)) + + if self._is_discharge_allowed(calc_input, net_consumption, prices, calc_timestamp): + inverter_control_settings.allow_discharge = True + inverter_control_settings.limit_battery_charge_rate = -1 # no limit + + else: # discharge not allowed + logger.debug('Discharging is NOT allowed') + inverter_control_settings.allow_discharge = False + charging_limit_percent = self.calculation_parameters.max_charging_from_grid_limit * 100 + charge_limit_capacity = self.common.max_capacity * \ + self.calculation_parameters.max_charging_from_grid_limit + is_charging_possible = calc_input.stored_energy < charge_limit_capacity + + # Defaults to 0, only calculate if charging is possible + required_recharge_energy = 0 + + logger.debug('Charging allowed: %s', is_charging_possible) + if is_charging_possible: + logger.debug('Charging is allowed, because SOC is below %.0f%%', + charging_limit_percent) + required_recharge_energy = self._get_required_recharge_energy( + calc_input, + net_consumption[:max_slot], + prices + ) + else: + logger.debug('Charging is NOT allowed, because SOC is above %.0f%%', + charging_limit_percent) + + if required_recharge_energy > 0: + allowed_charging_energy = charge_limit_capacity - calc_input.stored_energy + if required_recharge_energy > allowed_charging_energy: + required_recharge_energy = allowed_charging_energy + logger.debug( + 'Required recharge energy limited by max. charging limit to %0.1f Wh', + required_recharge_energy + ) + logger.info( + 'Get additional energy via grid: %0.1f Wh', + required_recharge_energy + ) + elif required_recharge_energy == 0 and is_charging_possible: + logger.debug( + 'No additional energy required or possible price found.') + + # charge if battery capacity available and more stored energy is required + if is_charging_possible and required_recharge_energy > 0: + current_minute = calc_timestamp.minute + current_second = calc_timestamp.second + + if self.interval_minutes == 15: + current_interval_start = (current_minute // 15) * 15 + remaining_minutes = (current_interval_start + 15 + - current_minute - current_second / 60) + else: # 60 minutes + remaining_minutes = 60 - current_minute - current_second / 60 + + remaining_time = remaining_minutes / 60 + remaining_time = max(remaining_time, MIN_REMAINING_TIME_HOURS) + + charge_rate = required_recharge_energy / remaining_time + charge_rate = self.common.calculate_charge_rate(charge_rate) + + inverter_control_settings.charge_from_grid = True + inverter_control_settings.charge_rate = charge_rate + else: + # keep current charge level. recharge if solar surplus available + inverter_control_settings.allow_discharge = False + + # ----- Peak Shaving Post-Processing ----- # + if self.calculation_parameters.peak_shaving_enabled: + inverter_control_settings = self._apply_peak_shaving( + inverter_control_settings, calc_input, calc_timestamp) + + return inverter_control_settings + + # ------------------------------------------------------------------ # + # Peak Shaving # + # ------------------------------------------------------------------ # + + def _apply_peak_shaving(self, settings: InverterControlSettings, + calc_input: CalculationInput, + calc_timestamp: datetime.datetime + ) -> InverterControlSettings: + """Limit PV charge rate to spread battery charging until target hour. + + Skipped when: + - Past the target hour (allow_full_battery_after) + - Battery is in always_allow_discharge region (high SOC) + - Force charge from grid is active (MODE -1) + - No production right now (nighttime) + + Note: EVCC checks (charging, connected+pv mode) are handled in + core.py, not here. + """ + # No production right now: skip calculation (avoid unnecessary work at night) + if calc_input.production[0] <= 0: + return settings + + # After target hour: no limit + if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: + return settings + + # In always_allow_discharge region: skip peak shaving + if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): + logger.debug('[PeakShaving] Skipped: battery in always_allow_discharge region') + return settings + + # Force charge takes priority over peak shaving + if settings.charge_from_grid: + logger.warning('[PeakShaving] Skipped: force_charge (MODE -1) active, ' + 'grid charging takes priority') + return settings + + charge_limit = self._calculate_peak_shaving_charge_limit( + calc_input, calc_timestamp) + + if charge_limit < 0: + logger.debug('[PeakShaving] Evaluated: no limit needed (surplus within capacity)') + return settings + + if charge_limit >= 0: + # Apply PV charge rate limit + if settings.limit_battery_charge_rate < 0: + # No existing limit — apply peak shaving limit + settings.limit_battery_charge_rate = charge_limit + else: + # Keep the more restrictive limit + settings.limit_battery_charge_rate = min( + settings.limit_battery_charge_rate, charge_limit) + + # Ensure discharge is allowed alongside the charge limit (MODE 8) + settings.allow_discharge = True + + logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', + settings.limit_battery_charge_rate, + self.calculation_parameters.peak_shaving_allow_full_after) + + return settings + + def _calculate_peak_shaving_charge_limit(self, calc_input: CalculationInput, + calc_timestamp: datetime.datetime) -> int: + """Calculate PV charge rate limit to fill battery by target hour. + + Returns: + int: charge rate limit in W, or -1 if no limit needed. + """ + slot_start = calc_timestamp.replace( + minute=(calc_timestamp.minute // self.interval_minutes) * self.interval_minutes, + second=0, microsecond=0 + ) + target_time = calc_timestamp.replace( + hour=self.calculation_parameters.peak_shaving_allow_full_after, + minute=0, second=0, microsecond=0 + ) + + if target_time <= slot_start: + return -1 # Past target hour, no limit + + slots_remaining = int( + (target_time - slot_start).total_seconds() / (self.interval_minutes * 60) + ) + slots_remaining = min(slots_remaining, len(calc_input.production)) + + if slots_remaining <= 0: + return -1 + + # Calculate PV surplus per slot (only count positive surplus) + pv_surplus = (calc_input.production[:slots_remaining] + - calc_input.consumption[:slots_remaining]) + pv_surplus = np.clip(pv_surplus, 0, None) # Only positive surplus counts + + # Sum expected PV surplus energy (Wh) over remaining slots + interval_hours = self.interval_minutes / 60.0 + expected_surplus_wh = float(np.sum(pv_surplus)) * interval_hours + + free_capacity = calc_input.free_capacity + + if expected_surplus_wh <= free_capacity: + return -1 # PV surplus won't fill battery early, no limit needed + + if free_capacity <= 0: + return 0 # Battery is full, block PV charging + + # Spread charging evenly across remaining slots + wh_per_slot = free_capacity / slots_remaining + charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + + return int(charge_rate_w) + + # ------------------------------------------------------------------ # + # Discharge evaluation (same as DefaultLogic) # + # ------------------------------------------------------------------ # + + def _is_discharge_allowed(self, calc_input: CalculationInput, + net_consumption: np.ndarray, + prices: dict, + calc_timestamp: Optional[datetime.datetime] = None) -> bool: + """Evaluate if the battery is allowed to discharge. + + - Check if battery is above always_allow_discharge_limit + - Calculate required energy to shift toward high price hours + """ + if calc_timestamp is None: + calc_timestamp = datetime.datetime.now().astimezone(self.timezone) + + if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): + logger.info( + "[Rule] Discharge allowed due to always_allow_discharge_limit") + return True + + current_price = prices[0] + + min_dynamic_price_difference = self._calculate_min_dynamic_price_difference( + current_price) + + self.calculation_output.min_dynamic_price_difference = min_dynamic_price_difference + + max_slots = len(net_consumption) + # relevant time range : until next recharge possibility + for slot in range(1, max_slots): + future_price = prices[slot] + if future_price <= current_price - min_dynamic_price_difference: + max_slots = slot + logger.debug( + "[Rule] Recharge possible in %d slots, limiting evaluation window.", + slot) + logger.debug( + "[Rule] Future price: %.3f < Current price: %.3f - dyn_price_diff. %.3f ", + future_price, + current_price, + min_dynamic_price_difference + ) + break + + slot_start = calc_timestamp.replace( + minute=(calc_timestamp.minute // self.interval_minutes) * self.interval_minutes, + second=0, + microsecond=0 + ) + last_time = (slot_start + datetime.timedelta( + minutes=max_slots * self.interval_minutes + )).astimezone(self.timezone).strftime("%H:%M") + + logger.debug( + 'Evaluating next %d slots until %s', + max_slots, + last_time + ) + # distribute remaining energy + consumption = np.array(net_consumption) + consumption[consumption < 0] = 0 + + production = -np.array(net_consumption) + production[production < 0] = 0 + + # get slots with higher price + higher_price_slots = [] + for slot in range(max_slots): + future_price = prices[slot] + if future_price > current_price: + higher_price_slots.append(slot) + + higher_price_slots.sort() + higher_price_slots.reverse() + + reserved_storage = 0 + for higher_price_slot in higher_price_slots: + if consumption[higher_price_slot] == 0: + continue + required_energy = consumption[higher_price_slot] + + # correct reserved_storage with potential production + # start with latest slot + for slot in list(range(higher_price_slot))[::-1]: + if production[slot] == 0: + continue + if production[slot] >= required_energy: + production[slot] -= required_energy + required_energy = 0 + break + else: + required_energy -= production[slot] + production[slot] = 0 + # add_remaining required_energy to reserved_storage + reserved_storage += required_energy + + self.calculation_output.reserved_energy = reserved_storage + + if len(higher_price_slots) > 0: + logger.debug("[Rule] Reserved Energy will be used in the next slots: %s", + higher_price_slots[::-1]) + logger.debug( + "[Rule] Reserved Energy: %0.1f Wh. Usable in Battery: %0.1f Wh", + reserved_storage, + calc_input.stored_usable_energy + ) + else: + logger.debug("[Rule] No reserved energy required, because no " + "'high price' slots in evaluation window.") + + if calc_input.stored_usable_energy > reserved_storage: + logger.debug( + "[Rule] Discharge allowed. Stored usable energy %0.1f Wh >" + " Reserved energy %0.1f Wh", + calc_input.stored_usable_energy, + reserved_storage + ) + return True + + logger.debug( + "[Rule] Discharge forbidden. Stored usable energy %0.1f Wh <= Reserved energy %0.1f Wh", + calc_input.stored_usable_energy, + reserved_storage + ) + + return False + + # ------------------------------------------------------------------ # + # Recharge energy calculation (same as DefaultLogic) # + # ------------------------------------------------------------------ # + + def _get_required_recharge_energy(self, calc_input: CalculationInput, + net_consumption: list, + prices: dict) -> float: + """Calculate the required energy to shift toward high price slots. + + If a recharge price window is detected, the energy required to + recharge the battery to the next high price slots is calculated. + + Returns: + float: Energy in Wh + """ + current_price = prices[0] + max_slot = len(net_consumption) + consumption = np.array(net_consumption) + consumption[consumption < 0] = 0 + + production = -np.array(net_consumption) + production[production < 0] = 0 + min_price_difference = self.calculation_parameters.min_price_difference + min_dynamic_price_difference = self._calculate_min_dynamic_price_difference( + current_price) + + # evaluation period until price is first time lower then current price + for slot in range(1, max_slot): + future_price = prices[slot] + found_lower_price = False + # Soften the price difference to avoid too early charging + if self.soften_price_difference_on_charging: + modified_price = current_price - min_price_difference / \ + self.soften_price_difference_on_charging_factor + found_lower_price = future_price <= modified_price + else: + found_lower_price = future_price <= current_price + + if found_lower_price: + max_slot = slot + break + + logger.debug( + "[Rule] Evaluation window for recharge energy until slot %d with price %0.3f", + max_slot - 1, + prices[max_slot - 1] + ) + + # get high price slots + high_price_slots = [] + for slot in range(max_slot): + future_price = prices[slot] + if future_price > current_price + min_dynamic_price_difference: + high_price_slots.append(slot) + + # start with nearest slot + high_price_slots.sort() + required_energy = 0.0 + for high_price_slot in high_price_slots: + energy_to_shift = consumption[high_price_slot] + + # correct energy to shift with potential production + for slot in range(1, high_price_slot): + if production[slot] == 0: + continue + if production[slot] >= energy_to_shift: + production[slot] -= energy_to_shift + energy_to_shift = 0 + else: + energy_to_shift -= production[slot] + production[slot] = 0 + required_energy += energy_to_shift + + if required_energy > 0.0: + logger.debug("[Rule] Required Energy: %0.1f Wh is based on next 'high price' slots %s", + required_energy, + high_price_slots) + recharge_energy = required_energy - calc_input.stored_usable_energy + logger.debug("[Rule] Stored usable Energy: %0.1f , Recharge Energy: %0.1f Wh", + calc_input.stored_usable_energy, + recharge_energy) + else: + logger.debug( + "[Rule] No additional energy required, because stored energy is sufficient." + ) + recharge_energy = 0.0 + self.calculation_output.required_recharge_energy = recharge_energy + return recharge_energy + + free_capacity = calc_input.free_capacity + + if recharge_energy > free_capacity: + recharge_energy = free_capacity + logger.debug( + "[Rule] Recharge limited by free capacity: %0.1f Wh", recharge_energy) + + if not self.common.is_charging_above_minimum(recharge_energy): + recharge_energy = 0.0 + else: + recharge_energy = recharge_energy + self.common.min_charge_energy + + self.calculation_output.required_recharge_energy = recharge_energy + return recharge_energy + + def _calculate_min_dynamic_price_difference(self, price: float) -> float: + """ Calculate the dynamic limit for the current price """ + return round( + max(self.calculation_parameters.min_price_difference, + self.calculation_parameters.min_price_difference_rel * abs(price)), + self.round_price_digits + ) diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index 67e7749d..422f1bea 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -444,6 +444,38 @@ def publish_production_offset(self, production_offset: float) -> None: f'{production_offset:.3f}' ) + def publish_peak_shaving_enabled(self, enabled: bool) -> None: + """ Publish peak shaving enabled status to MQTT + /peak_shaving/enabled + """ + if self.client.is_connected(): + self.client.publish( + self.base_topic + '/peak_shaving/enabled', + str(enabled).lower(), + retain=True + ) + + def publish_peak_shaving_allow_full_after(self, hour: int) -> None: + """ Publish peak shaving target hour to MQTT + /peak_shaving/allow_full_battery_after + """ + if self.client.is_connected(): + self.client.publish( + self.base_topic + '/peak_shaving/allow_full_battery_after', + str(hour), + retain=True + ) + + def publish_peak_shaving_charge_limit(self, charge_limit: int) -> None: + """ Publish current peak shaving charge limit to MQTT + /peak_shaving/charge_limit + """ + if self.client.is_connected(): + self.client.publish( + self.base_topic + '/peak_shaving/charge_limit', + str(charge_limit) + ) + # For depended APIs like the Fronius Inverter classes, which is not # directly batcontrol. def generic_publish(self, topic: str, value: str) -> None: @@ -540,6 +572,41 @@ def send_mqtt_discovery_messages(self) -> None: step_value=0.01, initial_value=1.0) + # peak shaving + self.publish_mqtt_discovery_message( + "Peak Shaving Enabled", + "batcontrol_peak_shaving_enabled", + "switch", + None, + None, + self.base_topic + "/peak_shaving/enabled", + self.base_topic + "/peak_shaving/enabled/set", + entity_category="config", + value_template="{% if value == 'true' %}ON{% else %}OFF{% endif %}", + command_template="{% if value == 'ON' %}true{% else %}false{% endif %}") + + self.publish_mqtt_discovery_message( + "Peak Shaving Allow Full After", + "batcontrol_peak_shaving_allow_full_after", + "number", + None, + "h", + self.base_topic + "/peak_shaving/allow_full_battery_after", + self.base_topic + "/peak_shaving/allow_full_battery_after/set", + entity_category="config", + min_value=0, + max_value=23, + step_value=1, + initial_value=14) + + self.publish_mqtt_discovery_message( + "Peak Shaving Charge Limit", + "batcontrol_peak_shaving_charge_limit", + "sensor", + "power", + "W", + self.base_topic + "/peak_shaving/charge_limit") + # sensors self.publish_mqtt_discovery_message( "Discharge Blocked", diff --git a/tests/batcontrol/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py new file mode 100644 index 00000000..af0e3505 --- /dev/null +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -0,0 +1,464 @@ +"""Tests for the NextLogic peak shaving feature. + +Tests cover: +- _calculate_peak_shaving_charge_limit algorithm +- _apply_peak_shaving decision logic +- Full calculate() integration with peak shaving +- Logic factory type selection +""" +import logging +import unittest +import datetime +import numpy as np + +from batcontrol.logic.next import NextLogic +from batcontrol.logic.default import DefaultLogic +from batcontrol.logic.logic import Logic +from batcontrol.logic.logic_interface import ( + CalculationInput, + CalculationParameters, + InverterControlSettings, +) +from batcontrol.logic.common import CommonLogic + +logging.basicConfig(level=logging.DEBUG) + + +class TestPeakShavingAlgorithm(unittest.TestCase): + """Tests for _calculate_peak_shaving_charge_limit.""" + + def setUp(self): + self.max_capacity = 10000 # 10 kWh + self.logic = NextLogic(timezone=datetime.timezone.utc, + interval_minutes=60) + self.common = CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=self.max_capacity, + ) + self.params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + ) + self.logic.set_calculation_parameters(self.params) + + def _make_input(self, production, consumption, stored_energy, + free_capacity): + """Helper to build a CalculationInput.""" + prices = np.zeros(len(production)) + return CalculationInput( + production=np.array(production, dtype=float), + consumption=np.array(consumption, dtype=float), + prices=prices, + stored_energy=stored_energy, + stored_usable_energy=stored_energy - self.max_capacity * 0.05, + free_capacity=free_capacity, + ) + + def test_high_surplus_small_free_capacity(self): + """High PV surplus, small free capacity → low charge limit.""" + # 8 hours until 14:00 starting from 06:00 + # 5000 W PV per slot, 500 W consumption → 4500 W surplus per slot + # 8 slots * 4500 Wh = 36000 Wh surplus total + # free_capacity = 2000 Wh + # charge limit = 2000 / 8 = 250 Wh/slot → 250 W (60 min intervals) + production = [5000] * 8 + [0] * 4 + consumption = [500] * 8 + [0] * 4 + calc_input = self._make_input(production, consumption, + stored_energy=8000, + free_capacity=2000) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, 250) + + def test_low_surplus_large_free_capacity(self): + """Low PV surplus, large free capacity → no limit (-1).""" + # 1000 W PV, 800 W consumption → 200 W surplus + # 8 slots * 200 Wh = 1600 Wh surplus + # free_capacity = 5000 Wh → surplus < free → no limit + production = [1000] * 8 + [0] * 4 + consumption = [800] * 8 + [0] * 4 + calc_input = self._make_input(production, consumption, + stored_energy=5000, + free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, -1) + + def test_surplus_equals_free_capacity(self): + """PV surplus exactly matches free capacity → no limit (-1).""" + production = [3000] * 8 + [0] * 4 + consumption = [1000] * 8 + [0] * 4 + # surplus per slot = 2000 W, 8 slots = 16000 Wh + calc_input = self._make_input(production, consumption, + stored_energy=0, + free_capacity=16000) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, -1) + + def test_battery_full(self): + """Battery full (free_capacity = 0) → charge limit = 0.""" + production = [5000] * 8 + [0] * 4 + consumption = [500] * 8 + [0] * 4 + calc_input = self._make_input(production, consumption, + stored_energy=10000, + free_capacity=0) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, 0) + + def test_past_target_hour(self): + """Past target hour → no limit (-1).""" + production = [5000] * 8 + consumption = [500] * 8 + calc_input = self._make_input(production, consumption, + stored_energy=5000, + free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 15, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, -1) + + def test_one_slot_remaining(self): + """1 slot remaining → rate for that single slot.""" + # Target is 14:00, current time is 13:00 → 1 slot + # PV surplus: 5000 - 500 = 4500 W → 4500 Wh > free_cap 1000 + # limit = 1000 / 1 = 1000 Wh/slot → 1000 W + production = [5000] * 2 + consumption = [500] * 2 + calc_input = self._make_input(production, consumption, + stored_energy=9000, + free_capacity=1000) + ts = datetime.datetime(2025, 6, 20, 13, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, 1000) + + def test_consumption_reduces_surplus(self): + """High consumption reduces effective PV surplus.""" + # 3000 W PV, 2000 W consumption → 1000 W surplus + # 8 slots * 1000 Wh = 8000 Wh surplus + # free_capacity = 4000 Wh → surplus > free + # limit = 4000 / 8 = 500 Wh/slot → 500 W + production = [3000] * 8 + [0] * 4 + consumption = [2000] * 8 + [0] * 4 + calc_input = self._make_input(production, consumption, + stored_energy=6000, + free_capacity=4000) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, + tzinfo=datetime.timezone.utc) + limit = self.logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, 500) + + def test_15min_intervals(self): + """Test with 15-minute intervals.""" + logic_15 = NextLogic(timezone=datetime.timezone.utc, + interval_minutes=15) + logic_15.set_calculation_parameters(self.params) + + # Target 14:00, current 13:00 → 4 slots of 15 min + # surplus = 4000 W per slot, interval_hours = 0.25 + # surplus Wh per slot = 4000 * 0.25 = 1000 Wh + # total surplus = 4 * 1000 = 4000 Wh + # free_capacity = 1000 Wh → surplus > free + # wh_per_slot = 1000 / 4 = 250 Wh + # charge_rate_w = 250 / 0.25 = 1000 W + production = [4500] * 4 + consumption = [500] * 4 + calc_input = self._make_input(production, consumption, + stored_energy=9000, + free_capacity=1000) + ts = datetime.datetime(2025, 6, 20, 13, 0, 0, + tzinfo=datetime.timezone.utc) + limit = logic_15._calculate_peak_shaving_charge_limit( + calc_input, ts) + self.assertEqual(limit, 1000) + + +class TestPeakShavingDecision(unittest.TestCase): + """Tests for _apply_peak_shaving decision logic.""" + + def setUp(self): + self.max_capacity = 10000 + self.logic = NextLogic(timezone=datetime.timezone.utc, + interval_minutes=60) + self.common = CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=self.max_capacity, + ) + self.params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + ) + self.logic.set_calculation_parameters(self.params) + + def _make_settings(self, allow_discharge=True, charge_from_grid=False, + charge_rate=0, limit_battery_charge_rate=-1): + return InverterControlSettings( + allow_discharge=allow_discharge, + charge_from_grid=charge_from_grid, + charge_rate=charge_rate, + limit_battery_charge_rate=limit_battery_charge_rate, + ) + + def _make_input(self, production, consumption, stored_energy, + free_capacity): + prices = np.zeros(len(production)) + return CalculationInput( + production=np.array(production, dtype=float), + consumption=np.array(consumption, dtype=float), + prices=prices, + stored_energy=stored_energy, + stored_usable_energy=stored_energy - self.max_capacity * 0.05, + free_capacity=free_capacity, + ) + + def test_nighttime_no_production(self): + """No production (nighttime) → peak shaving skipped.""" + settings = self._make_settings() + calc_input = self._make_input( + [0, 0, 0, 0], [500, 500, 500, 500], + stored_energy=5000, free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 2, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + + def test_after_target_hour(self): + """After target hour → no change.""" + settings = self._make_settings() + calc_input = self._make_input( + [5000, 5000], [500, 500], + stored_energy=5000, free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 15, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + + def test_force_charge_takes_priority(self): + """Force charge (MODE -1) → peak shaving skipped.""" + settings = self._make_settings( + allow_discharge=False, charge_from_grid=True, charge_rate=3000) + calc_input = self._make_input( + [5000] * 8, [500] * 8, + stored_energy=5000, free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertTrue(result.charge_from_grid) + self.assertEqual(result.limit_battery_charge_rate, -1) + + def test_always_allow_discharge_region(self): + """Battery in always_allow_discharge region → skip peak shaving.""" + settings = self._make_settings() + # stored_energy=9500 > 10000 * 0.9 = 9000 + calc_input = self._make_input( + [5000] * 8, [500] * 8, + stored_energy=9500, free_capacity=500) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + + def test_peak_shaving_applies_limit(self): + """Before target hour, limit calculated → limit set.""" + settings = self._make_settings() + # 6 slots (6..14), 5000W PV, 500W consumption → 4500W surplus + # surplus Wh = 6 * 4500 = 27000 > free 3000 + # limit = 3000 / 6 = 500 W + calc_input = self._make_input( + [5000] * 8, [500] * 8, + stored_energy=7000, free_capacity=3000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, 500) + self.assertTrue(result.allow_discharge) + + def test_existing_tighter_limit_kept(self): + """Existing limit is tighter → keep existing.""" + settings = self._make_settings(limit_battery_charge_rate=200) + calc_input = self._make_input( + [5000] * 8, [500] * 8, + stored_energy=7000, free_capacity=3000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, 200) + + def test_peak_shaving_limit_tighter(self): + """Peak shaving limit is tighter than existing → peak shaving limit applied.""" + settings = self._make_settings(limit_battery_charge_rate=5000) + calc_input = self._make_input( + [5000] * 8, [500] * 8, + stored_energy=7000, free_capacity=3000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, 500) + + +class TestPeakShavingDisabled(unittest.TestCase): + """Test that peak shaving does nothing when disabled.""" + + def setUp(self): + self.max_capacity = 10000 + self.logic = NextLogic(timezone=datetime.timezone.utc, + interval_minutes=60) + self.common = CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=self.max_capacity, + ) + self.params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=False, + peak_shaving_allow_full_after=14, + ) + self.logic.set_calculation_parameters(self.params) + + def test_disabled_no_limit(self): + """peak_shaving_enabled=False → no change to settings.""" + production = np.array([5000] * 8, dtype=float) + consumption = np.array([500] * 8, dtype=float) + prices = np.zeros(8) + calc_input = CalculationInput( + production=production, + consumption=consumption, + prices=prices, + stored_energy=5000, + stored_usable_energy=4500, + free_capacity=5000, + ) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + self.logic.calculate(calc_input, ts) + result = self.logic.get_inverter_control_settings() + # With disabled peak shaving, no limit should be applied + self.assertEqual(result.limit_battery_charge_rate, -1) + + +class TestLogicFactory(unittest.TestCase): + """Test logic factory type selection.""" + + def setUp(self): + CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=10000, + ) + + def test_default_type(self): + """type: default → DefaultLogic.""" + config = {'battery_control': {'type': 'default'}} + logic = Logic.create_logic(config, datetime.timezone.utc) + self.assertIsInstance(logic, DefaultLogic) + + def test_next_type(self): + """type: next → NextLogic.""" + config = {'battery_control': {'type': 'next'}} + logic = Logic.create_logic(config, datetime.timezone.utc) + self.assertIsInstance(logic, NextLogic) + + def test_missing_type_defaults_to_default(self): + """No type key → DefaultLogic.""" + config = {} + logic = Logic.create_logic(config, datetime.timezone.utc) + self.assertIsInstance(logic, DefaultLogic) + + def test_unknown_type_raises(self): + """Unknown type → RuntimeError.""" + config = {'battery_control': {'type': 'unknown'}} + with self.assertRaises(RuntimeError): + Logic.create_logic(config, datetime.timezone.utc) + + def test_expert_tuning_applied_to_next(self): + """Expert tuning attributes applied to NextLogic.""" + config = { + 'battery_control': {'type': 'next'}, + 'battery_control_expert': { + 'round_price_digits': 2, + 'charge_rate_multiplier': 1.5, + }, + } + logic = Logic.create_logic(config, datetime.timezone.utc) + self.assertIsInstance(logic, NextLogic) + self.assertEqual(logic.round_price_digits, 2) + + +class TestCalculationParametersPeakShaving(unittest.TestCase): + """Test CalculationParameters peak shaving fields.""" + + def test_defaults(self): + """Without peak shaving args → defaults.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + ) + self.assertFalse(params.peak_shaving_enabled) + self.assertEqual(params.peak_shaving_allow_full_after, 14) + + def test_explicit_values(self): + """With explicit peak shaving args → stored.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=16, + ) + self.assertTrue(params.peak_shaving_enabled) + self.assertEqual(params.peak_shaving_allow_full_after, 16) + + def test_invalid_allow_full_after_too_high(self): + """allow_full_battery_after > 23 raises ValueError.""" + with self.assertRaises(ValueError): + CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_allow_full_after=25, + ) + + def test_invalid_allow_full_after_negative(self): + """allow_full_battery_after < 0 raises ValueError.""" + with self.assertRaises(ValueError): + CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_allow_full_after=-1, + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/batcontrol/test_evcc_mode.py b/tests/batcontrol/test_evcc_mode.py new file mode 100644 index 00000000..3ea526bc --- /dev/null +++ b/tests/batcontrol/test_evcc_mode.py @@ -0,0 +1,203 @@ +"""Tests for EVCC mode and connected topic handling for peak shaving. + +Tests cover: +- Topic derivation from loadpoint /charging topics +- handle_mode_message parsing +- handle_connected_message parsing +- evcc_ev_expects_pv_surplus property logic +- Multi-loadpoint scenarios +""" +import logging +import unittest +from unittest.mock import MagicMock, patch + +logging.basicConfig(level=logging.DEBUG) + + +class MockMessage: + """Minimal MQTT message mock.""" + + def __init__(self, topic: str, payload: bytes): + self.topic = topic + self.payload = payload + + +class TestEvccModeConnected(unittest.TestCase): + """Tests for mode/connected EVCC topic handling.""" + + def _create_evcc_api(self, loadpoint_topics=None): + """Create an EvccApi instance with mocked MQTT client.""" + if loadpoint_topics is None: + loadpoint_topics = ['evcc/loadpoints/1/charging'] + + config = { + 'broker': 'localhost', + 'port': 1883, + 'status_topic': 'evcc/status', + 'loadpoint_topic': loadpoint_topics, + 'block_battery_while_charging': True, + 'tls': False, + } + + with patch('batcontrol.evcc_api.mqtt.Client') as mock_mqtt: + mock_client = MagicMock() + mock_mqtt.return_value = mock_client + from batcontrol.evcc_api import EvccApi + api = EvccApi(config) + + return api + + # ---- Topic derivation ---- + + def test_topic_derivation_single(self): + """charging topic → mode and connected topics derived.""" + api = self._create_evcc_api(['evcc/loadpoints/1/charging']) + self.assertIn('evcc/loadpoints/1/mode', api.list_topics_mode) + self.assertIn('evcc/loadpoints/1/connected', api.list_topics_connected) + + def test_topic_derivation_multiple(self): + """Multiple loadpoints → all mode/connected topics derived.""" + api = self._create_evcc_api([ + 'evcc/loadpoints/1/charging', + 'evcc/loadpoints/2/charging', + ]) + self.assertEqual(len(api.list_topics_mode), 2) + self.assertEqual(len(api.list_topics_connected), 2) + self.assertIn('evcc/loadpoints/2/mode', api.list_topics_mode) + self.assertIn('evcc/loadpoints/2/connected', + api.list_topics_connected) + + def test_non_standard_topic_warning(self): + """Topic not ending in /charging → warning, no mode/connected sub.""" + with self.assertLogs('batcontrol.evcc_api', level='WARNING') as cm: + api = self._create_evcc_api(['evcc/loadpoints/1/status']) + self.assertEqual(len(api.list_topics_mode), 0) + self.assertEqual(len(api.list_topics_connected), 0) + self.assertTrue(any('does not end in /charging' in msg + for msg in cm.output)) + + # ---- handle_mode_message ---- + + def test_handle_mode_message_pv(self): + """Parse mode 'pv' correctly.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/mode', b'pv') + api.handle_mode_message(msg) + self.assertEqual( + api.evcc_loadpoint_mode['evcc/loadpoints/1'], 'pv') + + def test_handle_mode_message_now(self): + """Parse mode 'now' correctly.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/mode', b'now') + api.handle_mode_message(msg) + self.assertEqual( + api.evcc_loadpoint_mode['evcc/loadpoints/1'], 'now') + + def test_handle_mode_message_case_insensitive(self): + """Mode is converted to lowercase.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/mode', b'PV') + api.handle_mode_message(msg) + self.assertEqual( + api.evcc_loadpoint_mode['evcc/loadpoints/1'], 'pv') + + # ---- handle_connected_message ---- + + def test_handle_connected_true(self): + """Parse connected=true correctly.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/connected', b'true') + api.handle_connected_message(msg) + self.assertTrue( + api.evcc_loadpoint_connected['evcc/loadpoints/1']) + + def test_handle_connected_false(self): + """Parse connected=false correctly.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/connected', b'false') + api.handle_connected_message(msg) + self.assertFalse( + api.evcc_loadpoint_connected['evcc/loadpoints/1']) + + def test_handle_connected_case_insensitive(self): + """Connected parsing is case-insensitive.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/connected', b'True') + api.handle_connected_message(msg) + self.assertTrue( + api.evcc_loadpoint_connected['evcc/loadpoints/1']) + + # ---- evcc_ev_expects_pv_surplus ---- + + def test_expects_pv_surplus_connected_pv_mode(self): + """connected=true + mode=pv → True.""" + api = self._create_evcc_api() + api.evcc_loadpoint_connected['evcc/loadpoints/1'] = True + api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'pv' + self.assertTrue(api.evcc_ev_expects_pv_surplus) + + def test_expects_pv_surplus_connected_now_mode(self): + """connected=true + mode=now → False.""" + api = self._create_evcc_api() + api.evcc_loadpoint_connected['evcc/loadpoints/1'] = True + api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'now' + self.assertFalse(api.evcc_ev_expects_pv_surplus) + + def test_expects_pv_surplus_disconnected_pv_mode(self): + """connected=false + mode=pv → False.""" + api = self._create_evcc_api() + api.evcc_loadpoint_connected['evcc/loadpoints/1'] = False + api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'pv' + self.assertFalse(api.evcc_ev_expects_pv_surplus) + + def test_expects_pv_surplus_no_data(self): + """No data received → False.""" + api = self._create_evcc_api() + self.assertFalse(api.evcc_ev_expects_pv_surplus) + + def test_multi_loadpoint_one_pv(self): + """Multi-loadpoint: one connected+pv is enough → True.""" + api = self._create_evcc_api([ + 'evcc/loadpoints/1/charging', + 'evcc/loadpoints/2/charging', + ]) + api.evcc_loadpoint_connected['evcc/loadpoints/1'] = False + api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'off' + api.evcc_loadpoint_connected['evcc/loadpoints/2'] = True + api.evcc_loadpoint_mode['evcc/loadpoints/2'] = 'pv' + self.assertTrue(api.evcc_ev_expects_pv_surplus) + + def test_mode_change_pv_to_now(self): + """Mode change from pv to now → evcc_ev_expects_pv_surplus changes to False.""" + api = self._create_evcc_api() + api.evcc_loadpoint_connected['evcc/loadpoints/1'] = True + api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'pv' + self.assertTrue(api.evcc_ev_expects_pv_surplus) + + # Mode changes + msg = MockMessage('evcc/loadpoints/1/mode', b'now') + api.handle_mode_message(msg) + self.assertFalse(api.evcc_ev_expects_pv_surplus) + + # ---- Message dispatching ---- + + def test_dispatch_mode_message(self): + """_handle_message dispatches mode topic correctly.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/mode', b'pv') + api._handle_message(None, None, msg) + self.assertEqual( + api.evcc_loadpoint_mode['evcc/loadpoints/1'], 'pv') + + def test_dispatch_connected_message(self): + """_handle_message dispatches connected topic correctly.""" + api = self._create_evcc_api() + msg = MockMessage('evcc/loadpoints/1/connected', b'true') + api._handle_message(None, None, msg) + self.assertTrue( + api.evcc_loadpoint_connected['evcc/loadpoints/1']) + + +if __name__ == '__main__': + unittest.main() From ab99e76d49f38fa4cfd39c3b7328b2a2a110d5fa Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 15:26:46 +0100 Subject: [PATCH 21/35] iteration 2 --- PLAN.md | 71 +++++++- docs/WIKI_peak_shaving.md | 7 +- src/batcontrol/core.py | 3 +- src/batcontrol/logic/next.py | 18 +- tests/batcontrol/logic/test_peak_shaving.py | 12 ++ tests/batcontrol/test_core.py | 189 ++++++++++++++++++++ 6 files changed, 286 insertions(+), 14 deletions(-) diff --git a/PLAN.md b/PLAN.md index c0ddbb80..6c74ae0d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -126,6 +126,13 @@ def evcc_ev_expects_pv_surplus(self) -> bool: **`shutdown`:** Unsubscribe from mode and connected topics. +**EVCC offline reset:** When EVCC goes offline (status message received), mode and connected state are reset to prevent stale values: +```python +for root in list(self.evcc_loadpoint_mode.keys()): + self.evcc_loadpoint_mode[root] = None + self.evcc_loadpoint_connected[root] = False +``` + ### 2.4 Backward Compatibility - Topics not ending in `/charging`: warning logged, no mode/connected sub, existing behavior unchanged @@ -254,14 +261,23 @@ return inverter_control_settings **`_apply_peak_shaving()`:** +Peak shaving uses MODE 8 (`limit_battery_charge_rate` with `allow_discharge=True`). It is only applied when the main logic already allows discharge — meaning no upcoming high-price slots require preserving battery energy. This is the **price-based skip condition**: when the main logic set `allow_discharge=False`, the battery is being held for profitable future slots, and PV charging should not be throttled. + ```python def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): """Limit PV charge rate to spread battery charging until target hour. + Peak shaving uses MODE 8 (limit_battery_charge_rate with + allow_discharge=True). It is only applied when the main logic + already allows discharge — meaning no upcoming high-price slots + require preserving battery energy. + Skipped when: + - No production right now (nighttime) - Past the target hour (allow_full_battery_after) - Battery is in always_allow_discharge region (high SOC) - Force charge from grid is active (MODE -1) + - Discharge not allowed (battery preserved for high-price hours) Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. """ @@ -284,6 +300,12 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): 'grid charging takes priority') return settings + # Battery preserved for high-price hours — don't limit PV charging + if not settings.allow_discharge: + logger.debug('[PeakShaving] Skipped: discharge not allowed, ' + 'battery preserved for high-price hours') + return settings + charge_limit = self._calculate_peak_shaving_charge_limit( calc_input, calc_timestamp) @@ -297,6 +319,9 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): settings.limit_battery_charge_rate = min( settings.limit_battery_charge_rate, charge_limit) + # Note: allow_discharge is already True here (checked above). + # MODE 8 requires allow_discharge=True to work correctly. + logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', settings.limit_battery_charge_rate, self.calculation_parameters.peak_shaving_allow_full_after) @@ -319,6 +344,13 @@ class CalculationParameters: # Peak shaving parameters peak_shaving_enabled: bool = False peak_shaving_allow_full_after: int = 14 # Hour (0-23) + + def __post_init__(self): + if not 0 <= self.peak_shaving_allow_full_after <= 23: + raise ValueError( + f"peak_shaving_allow_full_after must be 0-23, " + f"got {self.peak_shaving_allow_full_after}" + ) ``` **No changes needed to `CalculationInput`** — the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. @@ -332,17 +364,29 @@ The factory gains a new type `next`: ```python @staticmethod def create_logic(config: dict, timezone) -> LogicInterface: - request_type = config.get('type', 'default').lower() + battery_control = config.get('battery_control', {}) + request_type = battery_control.get('type', 'default').lower() interval_minutes = config.get('time_resolution_minutes', 60) if request_type == 'default': logic = DefaultLogic(timezone, interval_minutes=interval_minutes) - # ... existing expert tuning ... elif request_type == 'next': logic = NextLogic(timezone, interval_minutes=interval_minutes) - # ... same expert tuning as default ... else: - raise RuntimeError(f'[Logic] Unknown logic type {config["type"]}') + raise RuntimeError(f'[Logic] Unknown logic type {request_type}') + + # Apply expert tuning attributes (shared between default and next) + if config.get('battery_control_expert', None) is not None: + battery_control_expert = config.get('battery_control_expert', {}) + attribute_list = [ + 'soften_price_difference_on_charging', + 'soften_price_difference_on_charging_factor', + 'round_price_digits', + 'charge_rate_multiplier', + ] + for attribute in attribute_list: + if attribute in battery_control_expert: + setattr(logic, attribute, battery_control_expert[attribute]) return logic ``` @@ -418,7 +462,9 @@ Home Assistant discovery: - `peak_shaving/allow_full_battery_after` → number entity (min: 0, max: 23, step: 1) - `peak_shaving/charge_limit` → sensor entity (unit: W) -QoS: 1 for all topics (consistent with existing MQTT API). +QoS: default (0) for all topics (consistent with existing MQTT API). + +`charge_limit` is only published when peak shaving is enabled, to avoid unnecessary MQTT traffic. --- @@ -439,6 +485,7 @@ QoS: 1 for all topics (consistent with existing MQTT API). - `peak_shaving_enabled = False` → no change to settings - Current production = 0 (nighttime) → peak shaving skipped - `charge_from_grid = True` → peak shaving skipped, warning logged +- `allow_discharge = False` (battery preserved for high-price hours) → peak shaving skipped - Battery in always_allow_discharge region → peak shaving skipped - Before target hour, limit calculated → `limit_battery_charge_rate` set - After target hour → no change @@ -458,7 +505,14 @@ QoS: 1 for all topics (consistent with existing MQTT API). - Multi-loadpoint: one connected+pv is enough to return True - Mode change from pv to now → `evcc_ev_expects_pv_surplus` changes to False -### 7.3 Config Tests +### 7.3 Core EVCC Guard Tests (`tests/batcontrol/test_core.py`) + +- EVCC actively charging + charge limit active → limit cleared to -1 +- EV connected in PV mode + charge limit active → limit cleared to -1 +- EVCC not charging and no PV mode → charge limit preserved +- No charge limit active (-1) + EVCC charging → no change (stays -1) + +### 7.4 Config Tests - `type: next` → creates `NextLogic` instance - `type: default` → creates `DefaultLogic` instance (unchanged) @@ -494,7 +548,8 @@ QoS: 1 for all topics (consistent with existing MQTT API). | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | | `tests/batcontrol/logic/test_peak_shaving.py` | New — algorithm + decision tests | | `tests/batcontrol/test_evcc_mode.py` | New — mode/connected topic tests | -| `docs/peak_shaving.md` | New — feature documentation | +| `tests/batcontrol/test_core.py` | Add EVCC peak shaving guard tests | +| `docs/WIKI_peak_shaving.md` | New — feature documentation | **Not modified:** `default.py` (untouched — peak shaving is in `next.py`) @@ -514,6 +569,8 @@ QoS: 1 for all topics (consistent with existing MQTT API). 6. **Algorithm uses net PV surplus:** The charge limit calculation uses `production - consumption` (positive only), not raw production. This prevents over-throttling when household consumption absorbs most of the PV. +7. **Price-based skip (discharge not allowed):** When the main logic set `allow_discharge=False`, the battery is being preserved for upcoming high-price slots. In this case peak shaving is skipped — the battery needs to charge from PV as fast as possible. This also means `_apply_peak_shaving` does not need to force `allow_discharge=True`; it only applies when discharge is already allowed. + ## 11. Known Limitations (v1) 1. **Flat charge distribution:** The charge rate limit is uniform across all slots, but PV production peaks midday. The battery may not reach exactly 100% by the target hour. Acceptable for v1. diff --git a/docs/WIKI_peak_shaving.md b/docs/WIKI_peak_shaving.md index 18483143..ee7d696e 100644 --- a/docs/WIKI_peak_shaving.md +++ b/docs/WIKI_peak_shaving.md @@ -53,7 +53,7 @@ if pv_surplus > free_capacity: charge_limit = free_capacity / slots_remaining (Wh per slot, converted to W) ``` -The charge limit is applied using **MODE 8** (`limit_battery_charge_rate`), which limits PV charging while still allowing battery discharge. +The charge limit is applied using **MODE 8** (`limit_battery_charge_rate`), which limits PV charging while still allowing battery discharge. Peak shaving only applies when discharge is already allowed by the main price-based logic. ### Skip Conditions @@ -63,8 +63,9 @@ Peak shaving is automatically skipped when: 2. **Past the target hour** — battery is allowed to be full 3. **Battery in always_allow_discharge region** — SOC is already high 4. **Grid charging active (MODE -1)** — force charge takes priority -5. **EVCC is actively charging** — EV consumes the excess PV -6. **EV connected in PV mode** — EVCC will absorb PV surplus +5. **Discharge not allowed** — battery is being preserved for upcoming high-price hours; PV charging should not be throttled so the battery can charge as fast as possible +6. **EVCC is actively charging** — EV consumes the excess PV +7. **EV connected in PV mode** — EVCC will absorb PV surplus ### EVCC Interaction diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 5a3012c0..b87203f5 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -569,7 +569,8 @@ def run(self): inverter_settings.limit_battery_charge_rate = -1 # Publish peak shaving charge limit (after EVCC guard may have cleared it) - if self.mqtt_api is not None: + peak_shaving_enabled = peak_shaving_config.get('enabled', False) + if self.mqtt_api is not None and peak_shaving_enabled: self.mqtt_api.publish_peak_shaving_charge_limit( inverter_settings.limit_battery_charge_rate) diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index f5478a50..cb410cd6 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -212,11 +212,17 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, ) -> InverterControlSettings: """Limit PV charge rate to spread battery charging until target hour. + Peak shaving uses MODE 8 (limit_battery_charge_rate with + allow_discharge=True). It is only applied when the main logic + already allows discharge — meaning no upcoming high-price slots + require preserving battery energy. + Skipped when: + - No production right now (nighttime) - Past the target hour (allow_full_battery_after) - Battery is in always_allow_discharge region (high SOC) - Force charge from grid is active (MODE -1) - - No production right now (nighttime) + - Discharge not allowed (battery preserved for high-price hours) Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. @@ -240,6 +246,12 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, 'grid charging takes priority') return settings + # Battery preserved for high-price hours — don't limit PV charging + if not settings.allow_discharge: + logger.debug('[PeakShaving] Skipped: discharge not allowed, ' + 'battery preserved for high-price hours') + return settings + charge_limit = self._calculate_peak_shaving_charge_limit( calc_input, calc_timestamp) @@ -257,8 +269,8 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, settings.limit_battery_charge_rate = min( settings.limit_battery_charge_rate, charge_limit) - # Ensure discharge is allowed alongside the charge limit (MODE 8) - settings.allow_discharge = True + # Note: allow_discharge is already True here (checked above). + # MODE 8 requires allow_discharge=True to work correctly. logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', settings.limit_battery_charge_rate, diff --git a/tests/batcontrol/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py index af0e3505..c035562d 100644 --- a/tests/batcontrol/logic/test_peak_shaving.py +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -318,6 +318,18 @@ def test_peak_shaving_limit_tighter(self): result = self.logic._apply_peak_shaving(settings, calc_input, ts) self.assertEqual(result.limit_battery_charge_rate, 500) + def test_discharge_not_allowed_skips_peak_shaving(self): + """Discharge not allowed (battery preserved for high-price hours) → skip.""" + settings = self._make_settings(allow_discharge=False) + calc_input = self._make_input( + [5000] * 8, [500] * 8, + stored_energy=5000, free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + self.assertFalse(result.allow_discharge) + class TestPeakShavingDisabled(unittest.TestCase): """Test that peak shaving does nothing when disabled.""" diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 4482b8b4..32db4b5d 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -287,5 +287,194 @@ def test_api_set_limit_applies_immediately_in_mode_8( mock_inverter.set_mode_limit_battery_charge.assert_called_once_with(2000) +class TestEvccPeakShavingGuard: + """Test EVCC peak shaving guard in core.py run loop.""" + + @pytest.fixture + def mock_config(self): + """Provide a minimal config for testing.""" + return { + 'timezone': 'Europe/Berlin', + 'time_resolution_minutes': 60, + 'inverter': { + 'type': 'dummy', + 'max_grid_charge_rate': 5000, + 'max_pv_charge_rate': 3000, + 'min_pv_charge_rate': 100 + }, + 'utility': { + 'type': 'tibber', + 'token': 'test_token' + }, + 'pvinstallations': [], + 'consumption_forecast': { + 'type': 'simple', + 'value': 500 + }, + 'battery_control': { + 'max_charging_from_grid_limit': 0.8, + 'min_price_difference': 0.05 + }, + 'peak_shaving': { + 'enabled': True, + 'allow_full_battery_after': 14 + }, + 'mqtt': { + 'enabled': False + } + } + + def _create_bc(self, mock_config, mock_inverter_factory, mock_tariff, + mock_solar, mock_consumption): + """Helper to create a Batcontrol with mocked dependencies.""" + mock_inverter = MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.set_mode_limit_battery_charge = MagicMock() + mock_inverter.get_max_capacity = MagicMock(return_value=10000) + mock_inverter_factory.return_value = mock_inverter + + mock_tariff.return_value = MagicMock() + mock_solar.return_value = MagicMock() + mock_consumption.return_value = MagicMock() + + bc = Batcontrol(mock_config) + return bc + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_evcc_charging_clears_charge_limit( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """When EVCC is actively charging, peak shaving charge limit is cleared.""" + bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, + mock_solar, mock_consumption) + + # Simulate EVCC API + mock_evcc = MagicMock() + mock_evcc.evcc_is_charging = True + mock_evcc.evcc_ev_expects_pv_surplus = False + bc.evcc_api = mock_evcc + + # Simulate inverter_settings with peak shaving limit active + from batcontrol.logic.logic_interface import InverterControlSettings + settings = InverterControlSettings( + allow_discharge=True, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=500 + ) + + # Apply the EVCC guard logic (same block as in core.py run loop) + evcc_disable_peak_shaving = ( + bc.evcc_api.evcc_is_charging or + bc.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: + settings.limit_battery_charge_rate = -1 + + assert settings.limit_battery_charge_rate == -1 + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_evcc_pv_mode_clears_charge_limit( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """When EV connected in PV mode, peak shaving charge limit is cleared.""" + bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, + mock_solar, mock_consumption) + + mock_evcc = MagicMock() + mock_evcc.evcc_is_charging = False + mock_evcc.evcc_ev_expects_pv_surplus = True + bc.evcc_api = mock_evcc + + from batcontrol.logic.logic_interface import InverterControlSettings + settings = InverterControlSettings( + allow_discharge=True, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=500 + ) + + evcc_disable_peak_shaving = ( + bc.evcc_api.evcc_is_charging or + bc.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: + settings.limit_battery_charge_rate = -1 + + assert settings.limit_battery_charge_rate == -1 + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_evcc_not_charging_preserves_charge_limit( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """When EVCC is not charging and no PV mode, charge limit is preserved.""" + bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, + mock_solar, mock_consumption) + + mock_evcc = MagicMock() + mock_evcc.evcc_is_charging = False + mock_evcc.evcc_ev_expects_pv_surplus = False + bc.evcc_api = mock_evcc + + from batcontrol.logic.logic_interface import InverterControlSettings + settings = InverterControlSettings( + allow_discharge=True, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=500 + ) + + evcc_disable_peak_shaving = ( + bc.evcc_api.evcc_is_charging or + bc.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: + settings.limit_battery_charge_rate = -1 + + assert settings.limit_battery_charge_rate == 500 + + @patch('batcontrol.core.tariff_factory.create_tarif_provider') + @patch('batcontrol.core.inverter_factory.create_inverter') + @patch('batcontrol.core.solar_factory.create_solar_provider') + @patch('batcontrol.core.consumption_factory.create_consumption') + def test_evcc_no_limit_active_no_change( + self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, + mock_config): + """When no charge limit is active (=-1), EVCC guard doesn't modify it.""" + bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, + mock_solar, mock_consumption) + + mock_evcc = MagicMock() + mock_evcc.evcc_is_charging = True + mock_evcc.evcc_ev_expects_pv_surplus = False + bc.evcc_api = mock_evcc + + from batcontrol.logic.logic_interface import InverterControlSettings + settings = InverterControlSettings( + allow_discharge=True, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=-1 + ) + + evcc_disable_peak_shaving = ( + bc.evcc_api.evcc_is_charging or + bc.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: + settings.limit_battery_charge_rate = -1 + + assert settings.limit_battery_charge_rate == -1 + + if __name__ == '__main__': pytest.main([__file__, '-v']) From 14b5342f9ce3db6980d33776890469935d4a4cf6 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 15:55:23 +0100 Subject: [PATCH 22/35] 3rd iteration --- PLAN.md | 178 ++++++++++++--- config/batcontrol_config_dummy.yaml | 6 + docs/WIKI_peak_shaving.md | 53 +++-- src/batcontrol/core.py | 1 + src/batcontrol/logic/logic_interface.py | 13 +- src/batcontrol/logic/next.py | 138 +++++++++-- tests/batcontrol/logic/test_peak_shaving.py | 239 +++++++++++++++++++- 7 files changed, 557 insertions(+), 71 deletions(-) diff --git a/PLAN.md b/PLAN.md index 6c74ae0d..81f2005c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -25,6 +25,7 @@ Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging peak_shaving: enabled: false allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour + price_limit: 0.05 # Euro/kWh — keep free capacity for slots at or below this price ``` **`allow_full_battery_after`** — Target hour for the battery to be full: @@ -32,6 +33,12 @@ peak_shaving: - **At/after this hour:** No PV charge limit. Battery is allowed to be 100% full. PV overflow to grid is acceptable (e.g., EV arrives home and the charger absorbs excess). - **During EV charging or EV connected in PV mode:** Peak shaving disabled entirely. +**`price_limit`** (optional) — Keep free battery capacity reserved for upcoming cheap-price time slots: +- When set, the algorithm identifies upcoming slots where `price <= price_limit` and reserves enough free capacity to absorb the full PV surplus during those cheap hours. +- **During a cheap slot** (`price[0] <= price_limit`): No charge limit — absorb as much PV as possible. +- **When `price_limit` is not set (default):** Peak shaving is **disabled entirely**. This means peak shaving only activates when a `price_limit` is explicitly configured, making the price-based algorithm the primary enabler. +- When both `price_limit` and `allow_full_battery_after` are configured, the stricter limit ( lower W) wins. + ### 1.2 Logic Type Selection ```yaml @@ -156,29 +163,51 @@ This keeps `DefaultLogic` completely untouched and allows the `next` logic to ev ### 3.2 Core Algorithm -The algorithm spreads battery charging over time so the battery reaches full at the target hour: +The algorithm has two components that both compute a PV charge rate limit in W. The stricter (lower non-negative) limit wins. + +#### Component 1: Price-Based (Primary) + +The primary driver. The idea: before cheap-price slots arrive, keep the battery partially empty so those slots' PV surplus fills the battery completely rather than spilling to the grid. ``` -slots_remaining = slots from now until allow_full_battery_after -free_capacity = battery free capacity in Wh -expected_pv_surplus = sum of (production - consumption) for those slots, only positive values (Wh) +cheap_slots = upcoming slots where price <= price_limit +target_reserve_wh = min(sum of PV surplus in cheap slots, max_capacity) +additional_charging_allowed_wh = free_capacity - target_reserve_wh + +if additional_charging_allowed <= 0: + block PV charging (rate = 0) +else: + spread additional_charging_allowed over slots_before_cheap_window + charge_rate = additional_charging_allowed / slots_before_cheap / interval_hours ``` -If expected **PV surplus** (production minus consumption) exceeds free capacity, PV would fill the battery too early. We calculate the **maximum PV charge rate** that fills the battery evenly: +When `price_limit` is not configured: this component returns -1 (no limit), effectively **disabling peak shaving entirely** since both components must be configured for any limit to apply. + +When the current slot is cheap (`prices[0] <= price_limit`): no limit — absorb as much PV as possible. + +#### Component 2: Time-Based (Secondary) + +Spreads remaining battery free capacity evenly until `allow_full_battery_after`. Only triggers if the expected PV surplus would fill the battery before the target hour: ``` -ideal_charge_rate_wh = free_capacity / slots_remaining # Wh per slot -ideal_charge_rate_w = ideal_charge_rate_wh * (60 / interval_minutes) # Convert to W +slots_remaining = slots from now until allow_full_battery_after +free_capacity = battery free capacity in Wh +pv_surplus = sum of max(production - consumption, 0) for remaining slots (Wh) + +if pv_surplus > free_capacity: + charge_limit = free_capacity / slots_remaining (Wh per slot → W) ``` -Set `limit_battery_charge_rate = ideal_charge_rate_w` → MODE 8. +#### Combining Both Limits -If expected PV surplus is less than free capacity, no limit needed (battery won't fill early). +Both limits are computed independently. The final limit is `min(price_limit_w, time_limit_w)` where only non-negative values are considered. If only one component produces a limit, that limit is used. -**Note:** The charge limit is distributed evenly across slots. This is a simplification — PV production peaks midday while the limit is flat. This means the limit may have no effect in low-PV morning slots and may clip excess in high-PV midday slots. The battery may not reach exactly 100% by the target hour. This is acceptable for v1; a PV-weighted distribution could be added later. +**Note:** When `price_limit` is not set, the price-based component returns -1 (no limit) and the time-based component is also bypassed — peak shaving is fully disabled. This design ensures peak shaving only activates when `price_limit` is explicitly configured, giving operators control over when the feature is active. ### 3.3 Algorithm Implementation +#### Time-Based: `_calculate_peak_shaving_charge_limit()` + ```python def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): """Calculate PV charge rate limit to fill battery by target hour. @@ -228,6 +257,54 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): return int(charge_rate_w) ``` +#### Price-Based: `_calculate_peak_shaving_charge_limit_price_based()` + +```python +def _calculate_peak_shaving_charge_limit_price_based(self, calc_input): + """Reserve free capacity for upcoming cheap-price PV slots. + + Returns: int — charge rate limit in W, or -1 if no limit needed. + """ + price_limit = self.calculation_parameters.peak_shaving_price_limit + prices = calc_input.prices + interval_hours = self.interval_minutes / 60.0 + + # Find cheap slots + cheap_slots = [i for i, p in enumerate(prices) + if p is not None and p <= price_limit] + if not cheap_slots: + return -1 # No cheap slots ahead + + first_cheap_slot = cheap_slots[0] + if first_cheap_slot == 0: + return -1 # Already in cheap slot + + # Sum expected PV surplus during cheap slots + total_cheap_surplus_wh = 0.0 + for i in cheap_slots: + if i < len(calc_input.production) and i < len(calc_input.consumption): + surplus = float(calc_input.production[i]) - float(calc_input.consumption[i]) + if surplus > 0: + total_cheap_surplus_wh += surplus * interval_hours + + if total_cheap_surplus_wh <= 0: + return -1 # No PV surplus expected during cheap slots + + # Reserve capacity (capped at full battery capacity) + target_reserve_wh = min(total_cheap_surplus_wh, self.common.max_capacity) + + additional_charging_allowed = calc_input.free_capacity - target_reserve_wh + + if additional_charging_allowed <= 0: + return 0 # Block PV charging — battery already too full + + # Spread allowed charging evenly over slots before cheap window + wh_per_slot = additional_charging_allowed / first_cheap_slot + charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + + return int(charge_rate_w) +``` + ### 3.4 Always-Allow-Discharge Region Skips Peak Shaving When `stored_energy >= max_capacity * always_allow_discharge_limit`, the battery is in the "always allow discharge" region. In this region, peak shaving is **not applied** — the system is already at high SOC and the normal discharge logic takes over. This also avoids toggling issues when SOC fluctuates near 100%. @@ -265,67 +342,67 @@ Peak shaving uses MODE 8 (`limit_battery_charge_rate` with `allow_discharge=True ```python def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): - """Limit PV charge rate to spread battery charging until target hour. - - Peak shaving uses MODE 8 (limit_battery_charge_rate with - allow_discharge=True). It is only applied when the main logic - already allows discharge — meaning no upcoming high-price slots - require preserving battery energy. + """Limit PV charge rate using price-based and time-based algorithms. Skipped when: + - price_limit is None (primary disable condition) - No production right now (nighttime) + - Currently in a cheap slot (prices[0] <= price_limit) — charge freely - Past the target hour (allow_full_battery_after) - Battery is in always_allow_discharge region (high SOC) - Force charge from grid is active (MODE -1) - Discharge not allowed (battery preserved for high-price hours) - Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. + Note: EVCC checks (charging, connected+pv mode) are handled in core.py. """ - # No production right now: skip calculation (avoid unnecessary work at night) + price_limit = self.calculation_parameters.peak_shaving_price_limit + + # price_limit not configured → peak shaving disabled entirely + if price_limit is None: + return settings + + # No production right now: skip calculation if calc_input.production[0] <= 0: return settings + # Currently in cheap slot: charge freely to absorb PV surplus + if calc_input.prices[0] <= price_limit: + logger.debug('[PeakShaving] Skipped: currently in cheap slot (price=%.4f <= limit=%.4f)', + calc_input.prices[0], price_limit) + return settings + # After target hour: no limit if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: return settings # In always_allow_discharge region: skip peak shaving if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): - logger.debug('[PeakShaving] Skipped: battery in always_allow_discharge region') return settings # Force charge takes priority over peak shaving if settings.charge_from_grid: - logger.warning('[PeakShaving] Skipped: force_charge (MODE -1) active, ' - 'grid charging takes priority') + logger.warning('[PeakShaving] Skipped: force_charge (MODE -1) active') return settings # Battery preserved for high-price hours — don't limit PV charging if not settings.allow_discharge: - logger.debug('[PeakShaving] Skipped: discharge not allowed, ' - 'battery preserved for high-price hours') + logger.debug('[PeakShaving] Skipped: discharge not allowed') return settings - charge_limit = self._calculate_peak_shaving_charge_limit( - calc_input, calc_timestamp) + # Calculate both limits; take the stricter (lower non-negative) one + price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) + time_limit_w = self._calculate_peak_shaving_charge_limit(calc_input, calc_timestamp) + + candidates = [v for v in (price_limit_w, time_limit_w) if v >= 0] + charge_limit = min(candidates) if candidates else -1 if charge_limit >= 0: - # Apply PV charge rate limit if settings.limit_battery_charge_rate < 0: - # No existing limit — apply peak shaving limit settings.limit_battery_charge_rate = charge_limit else: - # Keep the more restrictive limit settings.limit_battery_charge_rate = min( settings.limit_battery_charge_rate, charge_limit) - # Note: allow_discharge is already True here (checked above). - # MODE 8 requires allow_discharge=True to work correctly. - - logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', - settings.limit_battery_charge_rate, - self.calculation_parameters.peak_shaving_allow_full_after) - return settings ``` @@ -344,6 +421,7 @@ class CalculationParameters: # Peak shaving parameters peak_shaving_enabled: bool = False peak_shaving_allow_full_after: int = 14 # Hour (0-23) + peak_shaving_price_limit: Optional[float] = None # Euro/kWh; None → peak shaving disabled def __post_init__(self): if not 0 <= self.peak_shaving_allow_full_after <= 23: @@ -351,6 +429,11 @@ class CalculationParameters: f"peak_shaving_allow_full_after must be 0-23, " f"got {self.peak_shaving_allow_full_after}" ) + if self.peak_shaving_price_limit is not None and self.peak_shaving_price_limit < 0: + raise ValueError( + f"peak_shaving_price_limit must be >= 0, " + f"got {self.peak_shaving_price_limit}" + ) ``` **No changes needed to `CalculationInput`** — the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. @@ -414,6 +497,7 @@ calc_parameters = CalculationParameters( self.get_max_capacity(), peak_shaving_enabled=peak_shaving_config.get('enabled', False), peak_shaving_allow_full_after=peak_shaving_config.get('allow_full_battery_after', 14), + peak_shaving_price_limit=peak_shaving_config.get('price_limit', None), ) ``` @@ -483,7 +567,9 @@ QoS: default (0) for all topics (consistent with existing MQTT API). **Decision tests (`_apply_peak_shaving`):** - `peak_shaving_enabled = False` → no change to settings +- `price_limit = None` → peak shaving disabled entirely - Current production = 0 (nighttime) → peak shaving skipped +- Currently in cheap slot (`prices[0] <= price_limit`) → no charge limit applied - `charge_from_grid = True` → peak shaving skipped, warning logged - `allow_discharge = False` (battery preserved for high-price hours) → peak shaving skipped - Battery in always_allow_discharge region → peak shaving skipped @@ -492,6 +578,22 @@ QoS: default (0) for all topics (consistent with existing MQTT API). - Existing tighter limit from other logic → kept (more restrictive wins) - Peak shaving limit tighter than existing → peak shaving limit applied +**Price-based algorithm tests (`_calculate_peak_shaving_charge_limit_price_based`):** +- No cheap slots → -1 +- Currently in cheap slot → -1 +- PV surplus in cheap slots ≤ 0 → -1 +- Cheap-slot surplus exceeds free capacity → block (0) +- Partial reserve spreads over remaining slots → rate calculation +- Free capacity well above reserve → charge rate returned +- Consumption reduces cheap-slot surplus +- Both price-based and time-based configured → stricter limit wins + +**`CalculationParameters` tests:** +- `peak_shaving_price_limit` defaults to `None` +- Explicit float value stored correctly +- Zero allowed (free price slots) +- Negative value raises `ValueError` + ### 7.2 EVCC Tests (`tests/batcontrol/test_evcc_mode.py`) - Topic derivation: `evcc/loadpoints/1/charging` → mode: `evcc/loadpoints/1/mode`, connected: `evcc/loadpoints/1/connected` @@ -569,7 +671,11 @@ QoS: default (0) for all topics (consistent with existing MQTT API). 6. **Algorithm uses net PV surplus:** The charge limit calculation uses `production - consumption` (positive only), not raw production. This prevents over-throttling when household consumption absorbs most of the PV. -7. **Price-based skip (discharge not allowed):** When the main logic set `allow_discharge=False`, the battery is being preserved for upcoming high-price slots. In this case peak shaving is skipped — the battery needs to charge from PV as fast as possible. This also means `_apply_peak_shaving` does not need to force `allow_discharge=True`; it only applies when discharge is already allowed. +7. **Price-based algorithm is the primary driver:** Peak shaving is **disabled** when `price_limit` is `None`. This makes the operator opt in explicitly by setting a price threshold. The price-based algorithm identifies upcoming cheap-price slots and reserves enough free capacity to absorb their full PV surplus. This is the economically motivated core of peak shaving: buy cheap energy via full PV absorption, not by throttling charging arbitrarily. + +8. **Currently-in-cheap-slot skip:** When the current slot is cheap (`prices[0] <= price_limit`), no charge limit is applied — the battery should absorb as much PV as possible during this window. This is checked in `_apply_peak_shaving` before calling either sub-algorithm. + +9. **Two-limit combination (stricter wins):** The price-based and time-based components are independent. When both are configured, the final limit is `min(price_limit_w, time_limit_w)` over non-negative values. This ensures neither algorithm can inadvertently allow more charging than the other intends. ## 11. Known Limitations (v1) diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index cc48766e..f83f4cdf 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -38,10 +38,16 @@ battery_control_expert: # Manages PV battery charging rate so the battery fills up gradually, # reaching full capacity by a target hour (allow_full_battery_after). # Requires logic type 'next' in battery_control section. +# +# price_limit: slots where price (Euro/kWh) is at or below this value are +# treated as cheap PV windows. Battery capacity is reserved so the PV +# surplus during those cheap slots can be fully absorbed. When price_limit +# is not set (commented out), peak shaving is disabled. #-------------------------- peak_shaving: enabled: false allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour + price_limit: 0.05 # Euro/kWh — keep battery empty for slots at or below this price #-------------------------- # Inverter diff --git a/docs/WIKI_peak_shaving.md b/docs/WIKI_peak_shaving.md index ee7d696e..526a8564 100644 --- a/docs/WIKI_peak_shaving.md +++ b/docs/WIKI_peak_shaving.md @@ -25,6 +25,7 @@ battery_control: peak_shaving: enabled: false allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour + price_limit: 0.05 # Euro/kWh — keep free capacity for cheap-price slots ``` ### Parameters @@ -33,39 +34,65 @@ peak_shaving: |-----------|------|---------|-------------| | `enabled` | bool | `false` | Enable/disable peak shaving | | `allow_full_battery_after` | int | `14` | Target hour (0-23) for the battery to be full | +| `price_limit` | float | `null` | Price threshold (€/kWh); slots at or below this price are "cheap". **Required** — peak shaving is disabled when not set | **`allow_full_battery_after`** controls when the battery is allowed to be 100% full: -- **Before this hour:** PV charge rate is limited to spread charging evenly +- **Before this hour:** PV charge rate may be limited to prevent early fill - **At/after this hour:** No PV charge limit, battery is allowed to reach full charge +**`price_limit`** controls when cheap slots are recognised: +- **Not set (default):** Peak shaving is completely disabled — no charge limit is ever applied +- **During a cheap slot** (`current price <= price_limit`): No limit is applied — absorb as much PV as possible during these valuable hours +- **Before cheap slots:** PV charging is throttled so the battery has free capacity ready to absorb the cheap-slot PV surplus + ## How It Works ### Algorithm -The algorithm calculates the expected PV surplus (production minus consumption) for all time slots until the target hour. If the expected surplus would fill the battery before the target hour, it calculates a charge rate limit: +Peak shaving uses two independent components that each compute a PV charge rate limit. The **stricter (lower non-negative)** limit wins. + +**Component 1: Price-Based (primary)** + +This is the main driver. Before cheap-price hours arrive, the battery is kept partially empty so the cheap slots' PV surplus fills it completely: + +``` +cheap_slots = slots where price <= price_limit +target_reserve = min(sum of PV surplus in cheap slots, battery max capacity) +additional_allowed = free_capacity - target_reserve + +if additional_allowed <= 0: → block PV charging (rate = 0) +else: → spread additional_allowed over slots before cheap window +``` + +Example: prices = [10, 10, 5, 3, 0, 0], production peak at slots 4 and 5 → reserve free capacity now so slots 4 and 5 can fill the battery completely from PV. + +**Component 2: Time-Based (secondary)** + +Ensures the battery does not fill before `allow_full_battery_after`, independent of pricing: ``` -slots_remaining = slots from now until allow_full_battery_after -free_capacity = battery free capacity in Wh +slots_remaining = slots until allow_full_battery_after pv_surplus = sum of max(production - consumption, 0) for remaining slots if pv_surplus > free_capacity: - charge_limit = free_capacity / slots_remaining (Wh per slot, converted to W) + charge_limit = free_capacity / slots_remaining (Wh/slot → W) ``` -The charge limit is applied using **MODE 8** (`limit_battery_charge_rate`), which limits PV charging while still allowing battery discharge. Peak shaving only applies when discharge is already allowed by the main price-based logic. +Both limits are computed and the stricter one is applied using **MODE 8** (`limit_battery_charge_rate`). Peak shaving only applies when discharge is already allowed by the main price-based logic. ### Skip Conditions Peak shaving is automatically skipped when: -1. **No PV production** — nighttime, no action needed -2. **Past the target hour** — battery is allowed to be full -3. **Battery in always_allow_discharge region** — SOC is already high -4. **Grid charging active (MODE -1)** — force charge takes priority -5. **Discharge not allowed** — battery is being preserved for upcoming high-price hours; PV charging should not be throttled so the battery can charge as fast as possible -6. **EVCC is actively charging** — EV consumes the excess PV -7. **EV connected in PV mode** — EVCC will absorb PV surplus +1. **`price_limit` not configured** — peak shaving is disabled entirely (primary disable condition) +2. **Currently in a cheap slot** (`prices[0] <= price_limit`) — battery should absorb PV freely +3. **No PV production** — nighttime, no action needed +4. **Past the target hour** — battery is allowed to be full +5. **Battery in always_allow_discharge region** — SOC is already high +6. **Grid charging active (MODE -1)** — force charge takes priority +7. **Discharge not allowed** — battery is being preserved for upcoming high-price hours; PV charging should not be throttled so the battery can charge as fast as possible +8. **EVCC is actively charging** — EV consumes the excess PV +9. **EV connected in PV mode** — EVCC will absorb PV surplus ### EVCC Interaction diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index b87203f5..50d4a7ae 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -528,6 +528,7 @@ def run(self): peak_shaving_enabled=peak_shaving_config.get('enabled', False), peak_shaving_allow_full_after=peak_shaving_config.get( 'allow_full_battery_after', 14), + peak_shaving_price_limit=peak_shaving_config.get('price_limit', None), ) self.last_logic_instance = this_logic_run diff --git a/src/batcontrol/logic/logic_interface.py b/src/batcontrol/logic/logic_interface.py index 13b5e891..4ef56b60 100644 --- a/src/batcontrol/logic/logic_interface.py +++ b/src/batcontrol/logic/logic_interface.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Optional import datetime import numpy as np @@ -23,6 +24,10 @@ class CalculationParameters: # Peak shaving parameters peak_shaving_enabled: bool = False peak_shaving_allow_full_after: int = 14 # Hour (0-23) + # Slots where price <= this limit (€/kWh) are treated as cheap PV windows. + # Battery capacity is reserved so those slots can be absorbed fully. + # When None, peak shaving is disabled regardless of the enabled flag. + peak_shaving_price_limit: Optional[float] = None def __post_init__(self): if not 0 <= self.peak_shaving_allow_full_after <= 23: @@ -30,6 +35,12 @@ def __post_init__(self): f"peak_shaving_allow_full_after must be 0-23, " f"got {self.peak_shaving_allow_full_after}" ) + if (self.peak_shaving_price_limit is not None + and self.peak_shaving_price_limit < 0): + raise ValueError( + f"peak_shaving_price_limit must be >= 0, " + f"got {self.peak_shaving_price_limit}" + ) @dataclass class CalculationOutput: diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index cb410cd6..e0fe38d0 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -217,8 +217,17 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, already allows discharge — meaning no upcoming high-price slots require preserving battery energy. + The algorithm has two components that are combined (stricter wins): + - Price-based: reserve battery capacity for upcoming cheap-price PV + slots so they can be absorbed fully (primary driver). Requires + peak_shaving_price_limit to be configured; disabled when None. + - Time-based: spread remaining capacity over slots until + allow_full_battery_after (secondary constraint). + Skipped when: + - price_limit is not configured (peak shaving disabled) - No production right now (nighttime) + - Currently in a cheap slot (price <= price_limit) — charge freely - Past the target hour (allow_full_battery_after) - Battery is in always_allow_discharge region (high SOC) - Force charge from grid is active (MODE -1) @@ -227,10 +236,24 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. """ + price_limit = self.calculation_parameters.peak_shaving_price_limit + + # price_limit not configured → peak shaving is disabled + if price_limit is None: + logger.debug('[PeakShaving] Skipped: price_limit not configured') + return settings + # No production right now: skip calculation (avoid unnecessary work at night) if calc_input.production[0] <= 0: return settings + # Currently in a cheap slot — charge battery freely + if calc_input.prices[0] <= price_limit: + logger.debug('[PeakShaving] Skipped: currently in cheap-price slot ' + '(price %.3f <= limit %.3f)', + calc_input.prices[0], price_limit) + return settings + # After target hour: no limit if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: return settings @@ -252,32 +275,111 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, 'battery preserved for high-price hours') return settings - charge_limit = self._calculate_peak_shaving_charge_limit( - calc_input, calc_timestamp) + # Compute both limits, take stricter (lower non-negative value) + price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) + time_limit_w = self._calculate_peak_shaving_charge_limit(calc_input, calc_timestamp) - if charge_limit < 0: - logger.debug('[PeakShaving] Evaluated: no limit needed (surplus within capacity)') + if price_limit_w < 0 and time_limit_w < 0: + logger.debug('[PeakShaving] Evaluated: no limit needed') return settings - if charge_limit >= 0: - # Apply PV charge rate limit - if settings.limit_battery_charge_rate < 0: - # No existing limit — apply peak shaving limit - settings.limit_battery_charge_rate = charge_limit - else: - # Keep the more restrictive limit - settings.limit_battery_charge_rate = min( - settings.limit_battery_charge_rate, charge_limit) + if price_limit_w >= 0 and time_limit_w >= 0: + charge_limit = min(price_limit_w, time_limit_w) + elif price_limit_w >= 0: + charge_limit = price_limit_w + else: + charge_limit = time_limit_w - # Note: allow_discharge is already True here (checked above). - # MODE 8 requires allow_discharge=True to work correctly. + # Apply PV charge rate limit + if settings.limit_battery_charge_rate < 0: + # No existing limit — apply peak shaving limit + settings.limit_battery_charge_rate = charge_limit + else: + # Keep the more restrictive limit + settings.limit_battery_charge_rate = min( + settings.limit_battery_charge_rate, charge_limit) - logger.info('[PeakShaving] PV charge limit: %d W (battery full by %d:00)', - settings.limit_battery_charge_rate, - self.calculation_parameters.peak_shaving_allow_full_after) + # Note: allow_discharge is already True here (checked above). + # MODE 8 requires allow_discharge=True to work correctly. + + logger.info('[PeakShaving] PV charge limit: %d W ' + '(price-based=%s W, time-based=%s W, full by %d:00)', + settings.limit_battery_charge_rate, + price_limit_w if price_limit_w >= 0 else 'off', + time_limit_w if time_limit_w >= 0 else 'off', + self.calculation_parameters.peak_shaving_allow_full_after) return settings + def _calculate_peak_shaving_charge_limit_price_based( + self, calc_input: CalculationInput) -> int: + """Reserve battery free capacity for upcoming cheap-price PV slots. + + Finds upcoming slots where price <= peak_shaving_price_limit and + calculates a charge rate limit that keeps enough free capacity to + absorb the expected PV surplus during those cheap slots fully. + + Algorithm: + 1. Find upcoming cheap slots (price <= price_limit). + 2. Sum PV surplus during cheap slots → target_reserve_wh. + 3. additional_charging_allowed = free_capacity - target_reserve_wh + 4. If additional_charging_allowed <= 0: block PV charging (return 0). + 5. Spread additional_charging_allowed evenly over slots before + the cheap window starts. + + Returns: + int: charge rate limit in W, or -1 if no limit needed. + """ + price_limit = self.calculation_parameters.peak_shaving_price_limit + prices = calc_input.prices + interval_hours = self.interval_minutes / 60.0 + + # Find all cheap slots + cheap_slots = [i for i, p in enumerate(prices) + if p is not None and p <= price_limit] + if not cheap_slots: + return -1 # No cheap slots ahead + + first_cheap_slot = cheap_slots[0] + if first_cheap_slot == 0: + return -1 # Already in cheap slot (caller checks this too) + + # Calculate expected PV surplus during cheap slots + total_cheap_surplus_wh = 0.0 + for i in cheap_slots: + if i < len(calc_input.production) and i < len(calc_input.consumption): + surplus = float(calc_input.production[i]) - float(calc_input.consumption[i]) + if surplus > 0: + total_cheap_surplus_wh += surplus * interval_hours + + if total_cheap_surplus_wh <= 0: + return -1 # No PV surplus expected during cheap slots + + # Reserve capacity (capped at full battery capacity) + target_reserve_wh = min(total_cheap_surplus_wh, self.common.max_capacity) + + # How much more can we charge before the cheap window starts? + additional_charging_allowed = calc_input.free_capacity - target_reserve_wh + + if additional_charging_allowed <= 0: + logger.debug( + '[PeakShaving] Price-based: battery full relative to cheap-window ' + 'reserve (free=%.0f Wh, reserve=%.0f Wh), blocking PV charge', + calc_input.free_capacity, target_reserve_wh) + return 0 + + # Spread allowed charging evenly over slots before cheap window + wh_per_slot = additional_charging_allowed / first_cheap_slot + charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + + logger.debug( + '[PeakShaving] Price-based: cheap window at slot %d, ' + 'reserve=%.0f Wh, allowed=%.0f Wh → limit=%d W', + first_cheap_slot, target_reserve_wh, additional_charging_allowed, + int(charge_rate_w)) + + return int(charge_rate_w) + def _calculate_peak_shaving_charge_limit(self, calc_input: CalculationInput, calc_timestamp: datetime.datetime) -> int: """Calculate PV charge rate limit to fill battery by target hour. diff --git a/tests/batcontrol/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py index c035562d..95e06a7a 100644 --- a/tests/batcontrol/logic/test_peak_shaving.py +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -210,6 +210,7 @@ def setUp(self): max_capacity=self.max_capacity, peak_shaving_enabled=True, peak_shaving_allow_full_after=14, + peak_shaving_price_limit=0.05, # required; tests use high prices so no cheap slots ) self.logic.set_calculation_parameters(self.params) @@ -224,7 +225,8 @@ def _make_settings(self, allow_discharge=True, charge_from_grid=False, def _make_input(self, production, consumption, stored_energy, free_capacity): - prices = np.zeros(len(production)) + # Use high prices (10.0) so no slot is "cheap" — only time-based limit applies. + prices = np.ones(len(production)) * 10.0 return CalculationInput( production=np.array(production, dtype=float), consumption=np.array(consumption, dtype=float), @@ -330,6 +332,43 @@ def test_discharge_not_allowed_skips_peak_shaving(self): self.assertEqual(result.limit_battery_charge_rate, -1) self.assertFalse(result.allow_discharge) + def test_price_limit_none_disables_peak_shaving(self): + """price_limit=None → peak shaving disabled entirely.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + peak_shaving_price_limit=None, + ) + self.logic.set_calculation_parameters(params) + settings = self._make_settings() + calc_input = self._make_input([5000] * 8, [500] * 8, + stored_energy=5000, free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + + def test_currently_in_cheap_slot_no_limit(self): + """Current slot is cheap (price <= price_limit) → charge freely.""" + settings = self._make_settings() + prices = np.zeros(8) # all slots cheap (price=0 <= 0.05) + calc_input = CalculationInput( + production=np.array([5000] * 8, dtype=float), + consumption=np.array([500] * 8, dtype=float), + prices=prices, + stored_energy=5000, + stored_usable_energy=4500, + free_capacity=5000, + ) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + class TestPeakShavingDisabled(unittest.TestCase): """Test that peak shaving does nothing when disabled.""" @@ -471,6 +510,200 @@ def test_invalid_allow_full_after_negative(self): peak_shaving_allow_full_after=-1, ) + def test_price_limit_default_is_none(self): + """peak_shaving_price_limit defaults to None.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + ) + self.assertIsNone(params.peak_shaving_price_limit) -if __name__ == '__main__': - unittest.main() + def test_price_limit_explicit_value(self): + """Explicit price_limit is stored.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_price_limit=0.05, + ) + self.assertEqual(params.peak_shaving_price_limit, 0.05) + + def test_price_limit_zero_allowed(self): + """price_limit=0 is valid (only free/negative prices count as cheap).""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_price_limit=0.0, + ) + self.assertEqual(params.peak_shaving_price_limit, 0.0) + + def test_price_limit_negative_raises(self): + """Negative price_limit raises ValueError.""" + with self.assertRaises(ValueError): + CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_price_limit=-0.01, + ) + + +class TestPeakShavingPriceBased(unittest.TestCase): + """Tests for _calculate_peak_shaving_charge_limit_price_based.""" + + def setUp(self): + self.max_capacity = 10000 + self.interval_minutes = 60 + self.logic = NextLogic(timezone=datetime.timezone.utc, + interval_minutes=self.interval_minutes) + self.common = CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=self.max_capacity, + ) + self.params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + peak_shaving_price_limit=0.05, + ) + self.logic.set_calculation_parameters(self.params) + + def _make_input(self, production, prices, free_capacity, consumption=None): + """Helper to build CalculationInput for price-based tests.""" + n = len(production) + if consumption is None: + consumption = [0.0] * n + return CalculationInput( + production=np.array(production, dtype=float), + consumption=np.array(consumption, dtype=float), + prices=np.array(prices, dtype=float), + stored_energy=self.max_capacity - free_capacity, + stored_usable_energy=(self.max_capacity - free_capacity) * 0.95, + free_capacity=free_capacity, + ) + + def test_surplus_exceeds_free_capacity_blocks_charging(self): + """ + Cheap slots at index 5 and 6 (price=0 <= 0.05). + Surplus in cheap slots: 3000+3000 = 6000 Wh (interval=1h, consumption=0). + target_reserve = min(6000, 10000) = 6000 Wh. + free_capacity = 4000 Wh. + additional_allowed = 4000 - 6000 = -2000 → block charging (return 0). + """ + prices = [10, 10, 10, 8, 3, 0, 0, 1] + production = [500, 500, 500, 500, 500, 3000, 3000, 500] + calc_input = self._make_input(production, prices, free_capacity=4000) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, 0) + + def test_partial_reserve_spread_over_slots(self): + """ + 2 cheap slots with 3000 Wh PV surplus each = 6000 Wh total. + Battery has 8000 Wh free, target_reserve = min(6000, 10000) = 6000 Wh. + additional_allowed = 8000 - 6000 = 2000 Wh. + first_cheap_slot = 4 (4 slots before cheap window). + wh_per_slot = 2000 / 4 = 500 Wh → rate = 500 W (60 min intervals). + """ + prices = [10, 10, 10, 10, 0, 0, 1, 2] + # cheap surplus slots 4,5: 3000W each, interval=1h → 3000 Wh each + production = [500, 500, 500, 500, 3000, 3000, 500, 500] + calc_input = self._make_input(production, prices, free_capacity=8000) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, 500) + + def test_no_cheap_slots_returns_minus_one(self): + """No cheap slots in prices → -1.""" + prices = [10, 10, 10, 10, 10, 10] + production = [3000] * 6 + calc_input = self._make_input(production, prices, free_capacity=5000) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, -1) + + def test_currently_in_cheap_slot_returns_minus_one(self): + """first_cheap_slot = 0 (current slot is cheap) → -1.""" + prices = [0, 0, 10, 10] + production = [5000, 5000, 500, 500] + calc_input = self._make_input(production, prices, free_capacity=5000) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, -1) + + def test_zero_pv_surplus_in_cheap_slots_returns_minus_one(self): + """Cheap slots have no PV surplus (consumption >= production) → -1.""" + prices = [10, 10, 0, 0] + production = [500, 500, 200, 200] + consumption = [500, 500, 300, 300] # net = 0 or negative in cheap slots + calc_input = self._make_input(production, prices, free_capacity=5000, + consumption=consumption) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, -1) + + def test_free_capacity_well_above_reserve_gives_rate(self): + """ + cheap surplus = 1000 Wh, target_reserve = 1000 Wh. + free_capacity = 6000 Wh → additional_allowed = 5000 Wh. + first_cheap_slot = 5 → wh_per_slot = 1000 W. + """ + prices = [10, 10, 10, 10, 10, 0, 10] + production = [500, 500, 500, 500, 500, 1000, 500] + calc_input = self._make_input(production, prices, free_capacity=6000) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, 1000) + + def test_consumption_reduces_cheap_surplus(self): + """ + Cheap slot: production=5000W, consumption=3000W → surplus=2000 Wh. + target_reserve = 2000, free=5000 → additional=3000 Wh. + first_cheap_slot=2 → rate = 3000/2 = 1500 W. + """ + prices = [10, 10, 0, 10] + production = [500, 500, 5000, 500] + consumption = [200, 200, 3000, 200] + calc_input = self._make_input(production, prices, free_capacity=5000, + consumption=consumption) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, 1500) + + def test_combine_price_and_time_limits_stricter_wins(self): + """ + Both limits active: time-based and price-based give different rates. + The stricter (lower) limit must be applied. + Setup at 08:00, target 14:00 (6 slots remaining). + High prices except slot 4 (cheap). + price-based: cheap surplus=4000Wh at 4, free=3000 → allowed=reserved-capped + Just check final result <= both individual limits. + """ + logic = NextLogic(timezone=datetime.timezone.utc, interval_minutes=60) + logic.set_calculation_parameters(self.params) + + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, tzinfo=datetime.timezone.utc) + # prices: slots 0-3 high, slot 4 cheap, slots 5-7 high again + prices = np.array([10, 10, 10, 10, 0, 10, 10, 10], dtype=float) + production = np.array([500, 500, 500, 500, 5000, 5000, 500, 500], dtype=float) + calc_input = CalculationInput( + production=production, + consumption=np.ones(8) * 500, + prices=prices, + stored_energy=7000, + stored_usable_energy=6500, + free_capacity=3000, + ) + settings = InverterControlSettings( + allow_discharge=True, charge_from_grid=False, + charge_rate=0, limit_battery_charge_rate=-1) + result = logic._apply_peak_shaving(settings, calc_input, ts) + + # Both limits should be considered; combined must be <= each individually + price_lim = logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + time_lim = logic._calculate_peak_shaving_charge_limit(calc_input, ts) + expected = min(x for x in [price_lim, time_lim] if x >= 0) + self.assertEqual(result.limit_battery_charge_rate, expected) From eaa94cd76f2e2a22678d6ad48a06e73074b428d2 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 15:59:43 +0100 Subject: [PATCH 23/35] Add instruction about ascii characters --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fb62280a..29ccd03f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,6 +4,7 @@ This is a Python based repository providing an application for controlling batte ### Required Before Each Commit +- Only use ASCII characters in code, log messages, and documentation. Avoid non-ASCII characters to ensure compatibility and readability across different environments. - Remove excessive whitespaces. - Follow PEP8 standards. Use autopep8 for that. - Check against pylint. Target score is like 9.0-9.5, if you can achieve 10, do it. From 68cfddde5638b7a06a38fdfbad64e73a9ad658fa Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 16:15:54 +0100 Subject: [PATCH 24/35] Iteration 4 --- PLAN.md | 361 ++++++++++---------- config/batcontrol_config_dummy.yaml | 19 +- docs/WIKI_peak_shaving.md | 94 ++--- src/batcontrol/core.py | 1 + src/batcontrol/logic/logic_interface.py | 23 +- src/batcontrol/logic/next.py | 139 ++++---- tests/batcontrol/logic/test_peak_shaving.py | 263 ++++++++++---- 7 files changed, 544 insertions(+), 356 deletions(-) diff --git a/PLAN.md b/PLAN.md index 81f2005c..abf76995 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,14 +1,14 @@ -# Peak Shaving Feature — Implementation Plan +# Peak Shaving Feature - Implementation Plan ## Overview Add peak shaving to batcontrol: manage PV battery charging rate so the battery fills up gradually, reaching full capacity by a target hour (`allow_full_battery_after`). This prevents the battery from being full too early in the day. -**Problem:** All PV systems in the country produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest and — for newer installations — feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible into the battery, rather than feeding it into the grid during peak hours. +**Problem:** All PV systems in the country produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest and - for newer installations - feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible into the battery, rather than feeding it into the grid during peak hours. **EVCC interaction:** -- When an EV is actively charging (`charging=true`), peak shaving is disabled — the EV consumes the excess PV. -- When an EV is connected and the loadpoint mode is `pv`, peak shaving is also disabled — EVCC+EV will naturally absorb excessive PV energy once the surplus threshold is reached. +- When an EV is actively charging (`charging=true`), peak shaving is disabled - the EV consumes the excess PV. +- When an EV is connected and the loadpoint mode is `pv`, peak shaving is also disabled - EVCC+EV will naturally absorb excessive PV energy once the surplus threshold is reached. - If the EV disconnects or the mode changes away from `pv`, peak shaving is re-enabled. Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging. @@ -24,20 +24,27 @@ Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging ```yaml peak_shaving: enabled: false - allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour - price_limit: 0.05 # Euro/kWh — keep free capacity for slots at or below this price + mode: combined # 'time' | 'price' | 'combined' + allow_full_battery_after: 14 # Hour (0-23) - battery should be full by this hour + price_limit: 0.05 # Euro/kWh - keep free capacity for slots at or below this price ``` -**`allow_full_battery_after`** — Target hour for the battery to be full: +**`mode`** - selects which algorithm components are active: +- **`time`** - time-based only: spread free capacity evenly until `allow_full_battery_after`. `price_limit` is not required and is ignored. +- **`price`** - price-based only: reserve capacity for cheap-price PV slots. Requires `price_limit`. +- **`combined`** (default) - both components active; stricter limit wins. Requires `price_limit`. + +**`allow_full_battery_after`** - Target hour for the battery to be full: - **Before this hour:** PV charge rate is limited to spread charging evenly. The battery fills gradually instead of reaching 100% early and overflowing PV to grid. - **At/after this hour:** No PV charge limit. Battery is allowed to be 100% full. PV overflow to grid is acceptable (e.g., EV arrives home and the charger absorbs excess). - **During EV charging or EV connected in PV mode:** Peak shaving disabled entirely. -**`price_limit`** (optional) — Keep free battery capacity reserved for upcoming cheap-price time slots: +**`price_limit`** (optional for `mode: time`, required for `price`/`combined`) - Keep free battery capacity reserved for upcoming cheap-price time slots: - When set, the algorithm identifies upcoming slots where `price <= price_limit` and reserves enough free capacity to absorb the full PV surplus during those cheap hours. -- **During a cheap slot** (`price[0] <= price_limit`): No charge limit — absorb as much PV as possible. -- **When `price_limit` is not set (default):** Peak shaving is **disabled entirely**. This means peak shaving only activates when a `price_limit` is explicitly configured, making the price-based algorithm the primary enabler. -- When both `price_limit` and `allow_full_battery_after` are configured, the stricter limit ( lower W) wins. +- **During a cheap slot** and surplus > free capacity: charging is spread evenly over the remaining cheap slots. +- **During a cheap slot** and surplus <= free capacity: no limit - absorb freely. +- Accepts any numeric value including -1 (which disables cheap-slot detection since no price <= -1); `None` disables the price component. +- When both `price_limit` and `allow_full_battery_after` are configured (mode `combined`), the stricter limit wins. ### 1.2 Logic Type Selection @@ -50,23 +57,23 @@ The `type: next` logic includes all existing `DefaultLogic` behavior plus peak s --- -## 2. EVCC Integration — Loadpoint Mode & Connected State +## 2. EVCC Integration - Loadpoint Mode & Connected State ### 2.1 Approach Peak shaving is disabled when **any** of the following EVCC conditions are true: -1. **`charging = true`** — EV is actively charging (already tracked) -2. **`connected = true` AND `mode = pv`** — EV is plugged in and waiting for PV surplus +1. **`charging = true`** - EV is actively charging (already tracked) +2. **`connected = true` AND `mode = pv`** - EV is plugged in and waiting for PV surplus The EVCC check is handled in `core.py`, **not** in the logic layer. EVCC is an external integration concern, same pattern as `discharge_blocked`. -### 2.2 New EVCC Topics — Derived from `loadpoint_topic` +### 2.2 New EVCC Topics - Derived from `loadpoint_topic` The `mode` and `connected` topics are derived from the existing `loadpoint_topic` config by stripping `/charging` and appending the relevant suffix: ``` -evcc/loadpoints/1/charging → evcc/loadpoints/1/mode - → evcc/loadpoints/1/connected +evcc/loadpoints/1/charging -> evcc/loadpoints/1/mode + -> evcc/loadpoints/1/connected ``` Topics not ending in `/charging`: log warning, skip mode/connected subscription. @@ -75,8 +82,8 @@ Topics not ending in `/charging`: log warning, skip mode/connected subscription. **New state:** ```python -self.evcc_loadpoint_mode = {} # topic_root → mode string ("pv", "now", "minpv", "off") -self.evcc_loadpoint_connected = {} # topic_root → bool +self.evcc_loadpoint_mode = {} # topic_root -> mode string ("pv", "now", "minpv", "off") +self.evcc_loadpoint_connected = {} # topic_root -> bool self.list_topics_mode = [] # derived mode topics self.list_topics_connected = [] # derived connected topics ``` @@ -106,7 +113,7 @@ def handle_mode_message(self, message): mode = message.payload.decode('utf-8').strip().lower() old_mode = self.evcc_loadpoint_mode.get(root) if old_mode != mode: - logger.info('Loadpoint %s mode changed: %s → %s', root, old_mode, mode) + logger.info('Loadpoint %s mode changed: %s -> %s', root, old_mode, mode) self.evcc_loadpoint_mode[root] = mode def handle_connected_message(self, message): @@ -148,7 +155,7 @@ for root in list(self.evcc_loadpoint_mode.keys()): --- -## 3. New Logic Class — `NextLogic` +## 3. New Logic Class - `NextLogic` ### 3.1 Architecture @@ -183,7 +190,7 @@ else: When `price_limit` is not configured: this component returns -1 (no limit), effectively **disabling peak shaving entirely** since both components must be configured for any limit to apply. -When the current slot is cheap (`prices[0] <= price_limit`): no limit — absorb as much PV as possible. +When the current slot is cheap (`prices[0] <= price_limit`): no limit - absorb as much PV as possible. #### Component 2: Time-Based (Secondary) @@ -195,14 +202,14 @@ free_capacity = battery free capacity in Wh pv_surplus = sum of max(production - consumption, 0) for remaining slots (Wh) if pv_surplus > free_capacity: - charge_limit = free_capacity / slots_remaining (Wh per slot → W) + charge_limit = free_capacity / slots_remaining (Wh per slot -> W) ``` #### Combining Both Limits Both limits are computed independently. The final limit is `min(price_limit_w, time_limit_w)` where only non-negative values are considered. If only one component produces a limit, that limit is used. -**Note:** When `price_limit` is not set, the price-based component returns -1 (no limit) and the time-based component is also bypassed — peak shaving is fully disabled. This design ensures peak shaving only activates when `price_limit` is explicitly configured, giving operators control over when the feature is active. +**Note:** When `price_limit` is not set, the price-based component returns -1 (no limit) and the time-based component is also bypassed - peak shaving is fully disabled. This design ensures peak shaving only activates when `price_limit` is explicitly configured, giving operators control over when the feature is active. ### 3.3 Algorithm Implementation @@ -212,7 +219,7 @@ Both limits are computed independently. The final limit is `min(price_limit_w, t def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): """Calculate PV charge rate limit to fill battery by target hour. - Returns: int — charge rate limit in W, or -1 if no limit needed + Returns: int - charge rate limit in W, or -1 if no limit needed """ slot_start = calc_timestamp.replace( minute=(calc_timestamp.minute // self.interval_minutes) * self.interval_minutes, @@ -234,7 +241,7 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): if slots_remaining <= 0: return -1 - # Calculate PV surplus per slot (only count positive surplus — when PV > consumption) + # Calculate PV surplus per slot (only count positive surplus - when PV > consumption) pv_surplus = calc_input.production[:slots_remaining] - calc_input.consumption[:slots_remaining] pv_surplus = np.clip(pv_surplus, 0, None) # Only positive surplus counts @@ -252,7 +259,7 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): # Spread charging evenly across remaining slots wh_per_slot = free_capacity / slots_remaining - charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot -> W return int(charge_rate_w) ``` @@ -263,55 +270,51 @@ def _calculate_peak_shaving_charge_limit(self, calc_input, calc_timestamp): def _calculate_peak_shaving_charge_limit_price_based(self, calc_input): """Reserve free capacity for upcoming cheap-price PV slots. - Returns: int — charge rate limit in W, or -1 if no limit needed. + When inside cheap window (first_cheap_slot == 0): + If surplus > free capacity: spread free_capacity over cheap slots. + If surplus <= free capacity: return -1 (no limit needed). + + When before cheap window: + Reserve capacity so the window can be absorbed fully. + additional_allowed = free_capacity - target_reserve_wh. + Spread additional_allowed evenly over slots before the window. + If additional_allowed <= 0: block charging (return 0). + + Returns: int - charge rate limit in W, or -1 if no limit needed. """ price_limit = self.calculation_parameters.peak_shaving_price_limit - prices = calc_input.prices - interval_hours = self.interval_minutes / 60.0 - - # Find cheap slots - cheap_slots = [i for i, p in enumerate(prices) - if p is not None and p <= price_limit] + ... + cheap_slots = [i for i, p in enumerate(prices) if p is not None and p <= price_limit] if not cheap_slots: - return -1 # No cheap slots ahead + return -1 first_cheap_slot = cheap_slots[0] - if first_cheap_slot == 0: - return -1 # Already in cheap slot - - # Sum expected PV surplus during cheap slots - total_cheap_surplus_wh = 0.0 - for i in cheap_slots: - if i < len(calc_input.production) and i < len(calc_input.consumption): - surplus = float(calc_input.production[i]) - float(calc_input.consumption[i]) - if surplus > 0: - total_cheap_surplus_wh += surplus * interval_hours - - if total_cheap_surplus_wh <= 0: - return -1 # No PV surplus expected during cheap slots - - # Reserve capacity (capped at full battery capacity) - target_reserve_wh = min(total_cheap_surplus_wh, self.common.max_capacity) - - additional_charging_allowed = calc_input.free_capacity - target_reserve_wh - - if additional_charging_allowed <= 0: - return 0 # Block PV charging — battery already too full - # Spread allowed charging evenly over slots before cheap window - wh_per_slot = additional_charging_allowed / first_cheap_slot - charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + if first_cheap_slot == 0: # Inside cheap window + total_surplus = sum_pv_surplus(cheap_slots) + if total_surplus <= calc_input.free_capacity: + return -1 # Battery can absorb everything + # Spread free capacity evenly over cheap slots + return int(calc_input.free_capacity / len(cheap_slots) / interval_hours) - return int(charge_rate_w) + # Before cheap window + total_surplus = sum_pv_surplus(cheap_slots) + if total_surplus <= 0: + return -1 + target_reserve = min(total_surplus, max_capacity) + additional_allowed = calc_input.free_capacity - target_reserve + if additional_allowed <= 0: + return 0 # Block charging + return int(additional_allowed / first_cheap_slot / interval_hours) ``` ### 3.4 Always-Allow-Discharge Region Skips Peak Shaving -When `stored_energy >= max_capacity * always_allow_discharge_limit`, the battery is in the "always allow discharge" region. In this region, peak shaving is **not applied** — the system is already at high SOC and the normal discharge logic takes over. This also avoids toggling issues when SOC fluctuates near 100%. +When `stored_energy >= max_capacity * always_allow_discharge_limit`, the battery is in the "always allow discharge" region. In this region, peak shaving is **not applied** - the system is already at high SOC and the normal discharge logic takes over. This also avoids toggling issues when SOC fluctuates near 100%. ### 3.5 Force Charge (MODE -1) Takes Priority Over Peak Shaving -If the price-based logic decides to force charge from grid (`charge_from_grid=True`), this overrides peak shaving. The force charge decision means energy is cheap enough to justify grid charging — peak shaving should not interfere. +If the price-based logic decides to force charge from grid (`charge_from_grid=True`), this overrides peak shaving. The force charge decision means energy is cheap enough to justify grid charging - peak shaving should not interfere. When this occurs, a warning is logged: ``` @@ -338,75 +341,82 @@ return inverter_control_settings **`_apply_peak_shaving()`:** -Peak shaving uses MODE 8 (`limit_battery_charge_rate` with `allow_discharge=True`). It is only applied when the main logic already allows discharge — meaning no upcoming high-price slots require preserving battery energy. This is the **price-based skip condition**: when the main logic set `allow_discharge=False`, the battery is being held for profitable future slots, and PV charging should not be throttled. +Peak shaving uses MODE 8 (`limit_battery_charge_rate` with `allow_discharge=True`). It is only applied when the main logic already allows discharge - meaning no upcoming high-price slots require preserving battery energy. + +The method dispatches to the appropriate sub-algorithms based on `peak_shaving_mode`. The **target-hour check** (`allow_full_battery_after`) applies to all modes and is checked early so no computation occurs past that hour. ```python def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): - """Limit PV charge rate using price-based and time-based algorithms. + """Limit PV charge rate based on the configured peak shaving mode. + + Mode behaviour (peak_shaving_mode): + 'time' - spread remaining capacity until allow_full_battery_after + 'price' - reserve capacity for upcoming cheap-price PV slots; + inside cheap window, spread if surplus > free capacity + 'combined' - both limits active, stricter one wins Skipped when: - - price_limit is None (primary disable condition) - - No production right now (nighttime) - - Currently in a cheap slot (prices[0] <= price_limit) — charge freely - - Past the target hour (allow_full_battery_after) - - Battery is in always_allow_discharge region (high SOC) - - Force charge from grid is active (MODE -1) + - 'price'/'combined' mode and price_limit is not configured + - No PV production right now (nighttime) + - Past allow_full_battery_after hour (all modes) + - Battery in always_allow_discharge region (high SOC) + - Force-charge from grid active (MODE -1) - Discharge not allowed (battery preserved for high-price hours) Note: EVCC checks (charging, connected+pv mode) are handled in core.py. """ + mode = self.calculation_parameters.peak_shaving_mode price_limit = self.calculation_parameters.peak_shaving_price_limit - # price_limit not configured → peak shaving disabled entirely - if price_limit is None: + # Price component needs price_limit configured + if mode in ('price', 'combined') and price_limit is None: return settings - # No production right now: skip calculation + # No production right now: skip if calc_input.production[0] <= 0: return settings - # Currently in cheap slot: charge freely to absorb PV surplus - if calc_input.prices[0] <= price_limit: - logger.debug('[PeakShaving] Skipped: currently in cheap slot (price=%.4f <= limit=%.4f)', - calc_input.prices[0], price_limit) - return settings - - # After target hour: no limit + # Past target hour: skip (applies to all modes) if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: return settings - # In always_allow_discharge region: skip peak shaving + # In always_allow_discharge region: skip if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): return settings - # Force charge takes priority over peak shaving + # Force charge takes priority if settings.charge_from_grid: - logger.warning('[PeakShaving] Skipped: force_charge (MODE -1) active') return settings - # Battery preserved for high-price hours — don't limit PV charging + # Battery preserved for high-price hours if not settings.allow_discharge: - logger.debug('[PeakShaving] Skipped: discharge not allowed') return settings - # Calculate both limits; take the stricter (lower non-negative) one - price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) - time_limit_w = self._calculate_peak_shaving_charge_limit(calc_input, calc_timestamp) + # Compute limits according to mode; price-based handles both before-cheap + # and in-cheap-window-overflow cases + price_limit_w = -1 + time_limit_w = -1 + + if mode in ('price', 'combined'): + price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) + if mode in ('time', 'combined'): + time_limit_w = self._calculate_peak_shaving_charge_limit(calc_input, calc_timestamp) candidates = [v for v in (price_limit_w, time_limit_w) if v >= 0] - charge_limit = min(candidates) if candidates else -1 + if not candidates: + return settings - if charge_limit >= 0: - if settings.limit_battery_charge_rate < 0: - settings.limit_battery_charge_rate = charge_limit - else: - settings.limit_battery_charge_rate = min( - settings.limit_battery_charge_rate, charge_limit) + charge_limit = min(candidates) + if settings.limit_battery_charge_rate < 0: + settings.limit_battery_charge_rate = charge_limit + else: + settings.limit_battery_charge_rate = min( + settings.limit_battery_charge_rate, charge_limit) return settings ``` -### 3.7 Data Flow — Extended `CalculationParameters` +### 3.7 Data Flow - Extended `CalculationParameters` Peak shaving configuration is passed via the existing `CalculationParameters` dataclass (consistent with existing interface pattern): @@ -421,26 +431,26 @@ class CalculationParameters: # Peak shaving parameters peak_shaving_enabled: bool = False peak_shaving_allow_full_after: int = 14 # Hour (0-23) - peak_shaving_price_limit: Optional[float] = None # Euro/kWh; None → peak shaving disabled + # 'time': target hour only | 'price': cheap-slot reservation | 'combined': both + peak_shaving_mode: str = 'combined' + peak_shaving_price_limit: Optional[float] = None # Euro/kWh; any numeric or None def __post_init__(self): if not 0 <= self.peak_shaving_allow_full_after <= 23: - raise ValueError( - f"peak_shaving_allow_full_after must be 0-23, " - f"got {self.peak_shaving_allow_full_after}" - ) - if self.peak_shaving_price_limit is not None and self.peak_shaving_price_limit < 0: - raise ValueError( - f"peak_shaving_price_limit must be >= 0, " - f"got {self.peak_shaving_price_limit}" - ) + raise ValueError(...) + valid_modes = ('time', 'price', 'combined') + if self.peak_shaving_mode not in valid_modes: + raise ValueError(...) + if (self.peak_shaving_price_limit is not None + and not isinstance(self.peak_shaving_price_limit, (int, float))): + raise ValueError(...) # must be numeric or None ``` -**No changes needed to `CalculationInput`** — the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. +**No changes needed to `CalculationInput`** - the existing fields (`production`, `consumption`, `free_capacity`, `stored_energy`) provide all data the algorithm needs. --- -## 4. Logic Factory — `logic.py` +## 4. Logic Factory - `logic.py` The factory gains a new type `next`: @@ -477,7 +487,7 @@ The `NextLogic` supports the same expert tuning attributes as `DefaultLogic`. --- -## 5. Core Integration — `core.py` +## 5. Core Integration - `core.py` ### 5.1 Init @@ -497,6 +507,7 @@ calc_parameters = CalculationParameters( self.get_max_capacity(), peak_shaving_enabled=peak_shaving_config.get('enabled', False), peak_shaving_allow_full_after=peak_shaving_config.get('allow_full_battery_after', 14), + peak_shaving_mode=peak_shaving_config.get('mode', 'combined'), peak_shaving_price_limit=peak_shaving_config.get('price_limit', None), ) ``` @@ -526,25 +537,25 @@ if self.evcc_api is not None: ### 5.4 Mode Selection -In the mode selection block (after `logic.calculate()`), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed — peak shaving sets the limit in `InverterControlSettings` and the existing dispatch handles it. +In the mode selection block (after `logic.calculate()`), `limit_battery_charge_rate >= 0` already triggers MODE 8 via `self.limit_battery_charge_rate()`. No changes needed - peak shaving sets the limit in `InverterControlSettings` and the existing dispatch handles it. --- -## 6. MQTT API — Publish Peak Shaving State +## 6. MQTT API - Publish Peak Shaving State Publish topics: -- `{base}/peak_shaving/enabled` — boolean (`true`/`false`, plain text, retained) -- `{base}/peak_shaving/allow_full_battery_after` — integer hour 0-23 (plain text, retained) -- `{base}/peak_shaving/charge_limit` — current calculated charge limit in W (plain text, not retained, -1 if inactive) +- `{base}/peak_shaving/enabled` - boolean (`true`/`false`, plain text, retained) +- `{base}/peak_shaving/allow_full_battery_after` - integer hour 0-23 (plain text, retained) +- `{base}/peak_shaving/charge_limit` - current calculated charge limit in W (plain text, not retained, -1 if inactive) Settable topics: -- `{base}/peak_shaving/enabled/set` — accepts `true`/`false` -- `{base}/peak_shaving/allow_full_battery_after/set` — accepts integer 0-23 +- `{base}/peak_shaving/enabled/set` - accepts `true`/`false` +- `{base}/peak_shaving/allow_full_battery_after/set` - accepts integer 0-23 Home Assistant discovery: -- `peak_shaving/enabled` → switch entity -- `peak_shaving/allow_full_battery_after` → number entity (min: 0, max: 23, step: 1) -- `peak_shaving/charge_limit` → sensor entity (unit: W) +- `peak_shaving/enabled` -> switch entity +- `peak_shaving/allow_full_battery_after` -> number entity (min: 0, max: 23, step: 1) +- `peak_shaving/charge_limit` -> sensor entity (unit: W) QoS: default (0) for all topics (consistent with existing MQTT API). @@ -557,36 +568,36 @@ QoS: default (0) for all topics (consistent with existing MQTT API). ### 7.1 Logic Tests (`tests/batcontrol/logic/test_peak_shaving.py`) **Algorithm tests (`_calculate_peak_shaving_charge_limit`):** -- High PV surplus, small free capacity → low charge limit -- Low PV surplus, large free capacity → no limit (-1) -- PV surplus exactly matches free capacity → no limit (-1) -- Battery full (`free_capacity = 0`) → charge limit = 0 -- Past target hour → no limit (-1) -- 1 slot remaining → rate for that single slot -- Consumption reduces effective PV — e.g., 3kW PV, 2kW consumption = 1kW surplus +- High PV surplus, small free capacity -> low charge limit +- Low PV surplus, large free capacity -> no limit (-1) +- PV surplus exactly matches free capacity -> no limit (-1) +- Battery full (`free_capacity = 0`) -> charge limit = 0 +- Past target hour -> no limit (-1) +- 1 slot remaining -> rate for that single slot +- Consumption reduces effective PV - e.g., 3kW PV, 2kW consumption = 1kW surplus **Decision tests (`_apply_peak_shaving`):** -- `peak_shaving_enabled = False` → no change to settings -- `price_limit = None` → peak shaving disabled entirely -- Current production = 0 (nighttime) → peak shaving skipped -- Currently in cheap slot (`prices[0] <= price_limit`) → no charge limit applied -- `charge_from_grid = True` → peak shaving skipped, warning logged -- `allow_discharge = False` (battery preserved for high-price hours) → peak shaving skipped -- Battery in always_allow_discharge region → peak shaving skipped -- Before target hour, limit calculated → `limit_battery_charge_rate` set -- After target hour → no change -- Existing tighter limit from other logic → kept (more restrictive wins) -- Peak shaving limit tighter than existing → peak shaving limit applied +- `peak_shaving_enabled = False` -> no change to settings +- `price_limit = None` -> peak shaving disabled entirely +- Current production = 0 (nighttime) -> peak shaving skipped +- Currently in cheap slot (`prices[0] <= price_limit`) -> no charge limit applied +- `charge_from_grid = True` -> peak shaving skipped, warning logged +- `allow_discharge = False` (battery preserved for high-price hours) -> peak shaving skipped +- Battery in always_allow_discharge region -> peak shaving skipped +- Before target hour, limit calculated -> `limit_battery_charge_rate` set +- After target hour -> no change +- Existing tighter limit from other logic -> kept (more restrictive wins) +- Peak shaving limit tighter than existing -> peak shaving limit applied **Price-based algorithm tests (`_calculate_peak_shaving_charge_limit_price_based`):** -- No cheap slots → -1 -- Currently in cheap slot → -1 -- PV surplus in cheap slots ≤ 0 → -1 -- Cheap-slot surplus exceeds free capacity → block (0) -- Partial reserve spreads over remaining slots → rate calculation -- Free capacity well above reserve → charge rate returned +- No cheap slots -> -1 +- Currently in cheap slot -> -1 +- PV surplus in cheap slots <= 0 -> -1 +- Cheap-slot surplus exceeds free capacity -> block (0) +- Partial reserve spreads over remaining slots -> rate calculation +- Free capacity well above reserve -> charge rate returned - Consumption reduces cheap-slot surplus -- Both price-based and time-based configured → stricter limit wins +- Both price-based and time-based configured -> stricter limit wins **`CalculationParameters` tests:** - `peak_shaving_price_limit` defaults to `None` @@ -596,44 +607,44 @@ QoS: default (0) for all topics (consistent with existing MQTT API). ### 7.2 EVCC Tests (`tests/batcontrol/test_evcc_mode.py`) -- Topic derivation: `evcc/loadpoints/1/charging` → mode: `evcc/loadpoints/1/mode`, connected: `evcc/loadpoints/1/connected` -- Non-standard topic (not ending in `/charging`) → warning, no mode/connected sub +- Topic derivation: `evcc/loadpoints/1/charging` -> mode: `evcc/loadpoints/1/mode`, connected: `evcc/loadpoints/1/connected` +- Non-standard topic (not ending in `/charging`) -> warning, no mode/connected sub - `handle_mode_message` parses mode string correctly - `handle_connected_message` parses boolean correctly -- `evcc_ev_expects_pv_surplus`: connected=true + mode=pv → True -- `evcc_ev_expects_pv_surplus`: connected=true + mode=now → False -- `evcc_ev_expects_pv_surplus`: connected=false + mode=pv → False -- `evcc_ev_expects_pv_surplus`: no data received → False +- `evcc_ev_expects_pv_surplus`: connected=true + mode=pv -> True +- `evcc_ev_expects_pv_surplus`: connected=true + mode=now -> False +- `evcc_ev_expects_pv_surplus`: connected=false + mode=pv -> False +- `evcc_ev_expects_pv_surplus`: no data received -> False - Multi-loadpoint: one connected+pv is enough to return True -- Mode change from pv to now → `evcc_ev_expects_pv_surplus` changes to False +- Mode change from pv to now -> `evcc_ev_expects_pv_surplus` changes to False ### 7.3 Core EVCC Guard Tests (`tests/batcontrol/test_core.py`) -- EVCC actively charging + charge limit active → limit cleared to -1 -- EV connected in PV mode + charge limit active → limit cleared to -1 -- EVCC not charging and no PV mode → charge limit preserved -- No charge limit active (-1) + EVCC charging → no change (stays -1) +- EVCC actively charging + charge limit active -> limit cleared to -1 +- EV connected in PV mode + charge limit active -> limit cleared to -1 +- EVCC not charging and no PV mode -> charge limit preserved +- No charge limit active (-1) + EVCC charging -> no change (stays -1) ### 7.4 Config Tests -- `type: next` → creates `NextLogic` instance -- `type: default` → creates `DefaultLogic` instance (unchanged) -- With `peak_shaving` section → `CalculationParameters` fields set correctly -- Without `peak_shaving` section → `peak_shaving_enabled = False` (default) +- `type: next` -> creates `NextLogic` instance +- `type: default` -> creates `DefaultLogic` instance (unchanged) +- With `peak_shaving` section -> `CalculationParameters` fields set correctly +- Without `peak_shaving` section -> `peak_shaving_enabled = False` (default) --- ## 8. Implementation Order -1. **Config** — Add `peak_shaving` section to dummy config, add `type: next` option -2. **Data model** — Extend `CalculationParameters` with peak shaving fields -3. **EVCC** — Add mode and connected topic subscriptions, `evcc_ev_expects_pv_surplus` property -4. **NextLogic** — New file `next.py`: copy DefaultLogic, add `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` -5. **Logic factory** — Add `type: next` → `NextLogic` in `logic.py` -6. **Core** — Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard -7. **MQTT** — Publish topics + settable topics + HA discovery +1. **Config** - Add `peak_shaving` section to dummy config, add `type: next` option +2. **Data model** - Extend `CalculationParameters` with peak shaving fields +3. **EVCC** - Add mode and connected topic subscriptions, `evcc_ev_expects_pv_surplus` property +4. **NextLogic** - New file `next.py`: copy DefaultLogic, add `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` +5. **Logic factory** - Add `type: next` -> `NextLogic` in `logic.py` +6. **Core** - Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard +7. **MQTT** - Publish topics + settable topics + HA discovery 8. **Tests** -9. **Documentation** — Write `docs/peak_shaving.md` covering feature overview, configuration, EVCC interaction, algorithm explanation, and known limitations +9. **Documentation** - Write `docs/peak_shaving.md` covering feature overview, configuration, EVCC interaction, algorithm explanation, and known limitations --- @@ -643,17 +654,17 @@ QoS: default (0) for all topics (consistent with existing MQTT API). |------|--------| | `config/batcontrol_config_dummy.yaml` | Add `peak_shaving` section | | `src/batcontrol/logic/logic_interface.py` | Add peak shaving fields to `CalculationParameters` | -| `src/batcontrol/logic/next.py` | **New** — `NextLogic` class with peak shaving | -| `src/batcontrol/logic/logic.py` | Add `type: next` → `NextLogic` | +| `src/batcontrol/logic/next.py` | **New** - `NextLogic` class with peak shaving | +| `src/batcontrol/logic/logic.py` | Add `type: next` -> `NextLogic` | | `src/batcontrol/evcc_api.py` | Add mode + connected topic subscriptions, `evcc_ev_expects_pv_surplus` | | `src/batcontrol/core.py` | Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard | | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | -| `tests/batcontrol/logic/test_peak_shaving.py` | New — algorithm + decision tests | -| `tests/batcontrol/test_evcc_mode.py` | New — mode/connected topic tests | +| `tests/batcontrol/logic/test_peak_shaving.py` | New - algorithm + decision tests | +| `tests/batcontrol/test_evcc_mode.py` | New - mode/connected topic tests | | `tests/batcontrol/test_core.py` | Add EVCC peak shaving guard tests | -| `docs/WIKI_peak_shaving.md` | New — feature documentation | +| `docs/WIKI_peak_shaving.md` | New - feature documentation | -**Not modified:** `default.py` (untouched — peak shaving is in `next.py`) +**Not modified:** `default.py` (untouched - peak shaving is in `next.py`) --- @@ -661,7 +672,7 @@ QoS: default (0) for all topics (consistent with existing MQTT API). 1. **New independent logic class:** Peak shaving lives in `NextLogic` (`type: next`), not as a modification to `DefaultLogic`. This keeps the stable default path untouched and allows the next logic to evolve independently. `NextLogic` is a full copy of `DefaultLogic` with peak shaving added. -2. **EVCC integration:** Handled in `core.py` (not logic layer). Peak shaving is disabled when `evcc_is_charging` OR (`connected=true` AND `mode=pv`). The mode and connected topics are derived from the existing `loadpoint_topic` config by stripping `/charging`. No `chargePower` subscription — it was reported as unreliable. +2. **EVCC integration:** Handled in `core.py` (not logic layer). Peak shaving is disabled when `evcc_is_charging` OR (`connected=true` AND `mode=pv`). The mode and connected topics are derived from the existing `loadpoint_topic` config by stripping `/charging`. No `chargePower` subscription - it was reported as unreliable. 3. **Grid charging interaction (MODE -1 vs MODE 8):** `force_charge` takes priority. If price logic triggers grid charging during peak shaving hours, a warning is logged but grid charging proceeds. @@ -673,7 +684,7 @@ QoS: default (0) for all topics (consistent with existing MQTT API). 7. **Price-based algorithm is the primary driver:** Peak shaving is **disabled** when `price_limit` is `None`. This makes the operator opt in explicitly by setting a price threshold. The price-based algorithm identifies upcoming cheap-price slots and reserves enough free capacity to absorb their full PV surplus. This is the economically motivated core of peak shaving: buy cheap energy via full PV absorption, not by throttling charging arbitrarily. -8. **Currently-in-cheap-slot skip:** When the current slot is cheap (`prices[0] <= price_limit`), no charge limit is applied — the battery should absorb as much PV as possible during this window. This is checked in `_apply_peak_shaving` before calling either sub-algorithm. +8. **Currently-in-cheap-slot skip:** When the current slot is cheap (`prices[0] <= price_limit`), no charge limit is applied - the battery should absorb as much PV as possible during this window. This is checked in `_apply_peak_shaving` before calling either sub-algorithm. 9. **Two-limit combination (stricter wins):** The price-based and time-based components are independent. When both are configured, the final limit is `min(price_limit_w, time_limit_w)` over non-negative values. This ensures neither algorithm can inadvertently allow more charging than the other intends. diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index f83f4cdf..f83a60c7 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -35,19 +35,26 @@ battery_control_expert: #-------------------------- # Peak Shaving -# Manages PV battery charging rate so the battery fills up gradually, -# reaching full capacity by a target hour (allow_full_battery_after). +# Manages PV battery charging rate to limit PV charging before cheap-price +# or high-production hours so the battery can absorb as much PV as possible. # Requires logic type 'next' in battery_control section. # +# mode: +# 'time' - limit by target hour only (allow_full_battery_after) +# 'price' - reserve capacity for cheap-price slots (price_limit required) +# 'combined' - both active, stricter limit wins [default] +# # price_limit: slots where price (Euro/kWh) is at or below this value are # treated as cheap PV windows. Battery capacity is reserved so the PV -# surplus during those cheap slots can be fully absorbed. When price_limit -# is not set (commented out), peak shaving is disabled. +# surplus during those cheap slots can be fully absorbed. +# Use -1 to disable the price component without changing mode. +# Required for mode 'price' and 'combined'; ignored for mode 'time'. #-------------------------- peak_shaving: enabled: false - allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour - price_limit: 0.05 # Euro/kWh — keep battery empty for slots at or below this price + mode: combined # 'time' | 'price' | 'combined' + allow_full_battery_after: 14 # Hour (0-23) - battery should be full by this hour + price_limit: 0.05 # Euro/kWh - keep battery empty for slots at or below this price #-------------------------- # Inverter diff --git a/docs/WIKI_peak_shaving.md b/docs/WIKI_peak_shaving.md index 526a8564..9182ed46 100644 --- a/docs/WIKI_peak_shaving.md +++ b/docs/WIKI_peak_shaving.md @@ -4,7 +4,7 @@ Peak shaving manages PV battery charging rate so the battery fills up gradually, reaching full capacity by a configurable target hour (`allow_full_battery_after`). This prevents the battery from being full too early in the day. -**Problem:** All PV systems produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest — and for newer installations, feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible. +**Problem:** All PV systems produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest - and for newer installations, feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible. ## Configuration @@ -24,8 +24,9 @@ battery_control: ```yaml peak_shaving: enabled: false - allow_full_battery_after: 14 # Hour (0-23) — battery should be full by this hour - price_limit: 0.05 # Euro/kWh — keep free capacity for cheap-price slots + mode: combined # 'time' | 'price' | 'combined' + allow_full_battery_after: 14 # Hour (0-23) - battery should be full by this hour + price_limit: 0.05 # Euro/kWh - keep battery empty for slots at or below this price ``` ### Parameters @@ -33,73 +34,84 @@ peak_shaving: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `enabled` | bool | `false` | Enable/disable peak shaving | +| `mode` | string | `combined` | Algorithm mode: `time`, `price`, or `combined` | | `allow_full_battery_after` | int | `14` | Target hour (0-23) for the battery to be full | -| `price_limit` | float | `null` | Price threshold (€/kWh); slots at or below this price are "cheap". **Required** — peak shaving is disabled when not set | +| `price_limit` | float | `null` | Price threshold (Euro/kWh); required for modes `price` and `combined` | -**`allow_full_battery_after`** controls when the battery is allowed to be 100% full: -- **Before this hour:** PV charge rate may be limited to prevent early fill -- **At/after this hour:** No PV charge limit, battery is allowed to reach full charge +**`mode`** selects which algorithm components are active: +- **`time`** - time-based only: spread free capacity evenly until `allow_full_battery_after`. `price_limit` not required. +- **`price`** - price-based only: reserve capacity for cheap-price slots (in-window surplus overflow handled). Requires `price_limit`. +- **`combined`** (default) - both components; stricter limit wins. Requires `price_limit`. -**`price_limit`** controls when cheap slots are recognised: -- **Not set (default):** Peak shaving is completely disabled — no charge limit is ever applied -- **During a cheap slot** (`current price <= price_limit`): No limit is applied — absorb as much PV as possible during these valuable hours -- **Before cheap slots:** PV charging is throttled so the battery has free capacity ready to absorb the cheap-slot PV surplus +**`allow_full_battery_after`** controls when the battery is allowed to be 100% full: +- **Before this hour:** PV charge rate may be limited +- **At/after this hour:** No limit for all modes (target-hour check applies globally) ## How It Works ### Algorithm -Peak shaving uses two independent components that each compute a PV charge rate limit. The **stricter (lower non-negative)** limit wins. +Peak shaving uses one or two components depending on `mode`. The stricter (lower non-negative) limit wins when both are active. -**Component 1: Price-Based (primary)** +**Component 1: Time-Based** (modes `time` and `combined`) -This is the main driver. Before cheap-price hours arrive, the battery is kept partially empty so the cheap slots' PV surplus fills it completely: +Spreads remaining free capacity evenly until `allow_full_battery_after`: ``` -cheap_slots = slots where price <= price_limit -target_reserve = min(sum of PV surplus in cheap slots, battery max capacity) -additional_allowed = free_capacity - target_reserve +slots_remaining = slots until allow_full_battery_after +pv_surplus = sum of max(production - consumption, 0) for remaining slots -if additional_allowed <= 0: → block PV charging (rate = 0) -else: → spread additional_allowed over slots before cheap window +if pv_surplus > free_capacity: + charge_limit = free_capacity / slots_remaining (Wh/slot -> W) ``` -Example: prices = [10, 10, 5, 3, 0, 0], production peak at slots 4 and 5 → reserve free capacity now so slots 4 and 5 can fill the battery completely from PV. +**Component 2: Price-Based** (modes `price` and `combined`) -**Component 2: Time-Based (secondary)** +Before cheap window - reserves free capacity so cheap-slot PV surplus fills battery completely: -Ensures the battery does not fill before `allow_full_battery_after`, independent of pricing: +``` +cheap_slots = slots where price <= price_limit +target_reserve = min(sum of PV surplus in cheap slots, max_capacity) +additional_allowed = free_capacity - target_reserve +if additional_allowed <= 0: -> block charging (rate = 0) +else: -> spread additional_allowed over slots before window ``` -slots_remaining = slots until allow_full_battery_after -pv_surplus = sum of max(production - consumption, 0) for remaining slots -if pv_surplus > free_capacity: - charge_limit = free_capacity / slots_remaining (Wh/slot → W) +Inside cheap window - if total PV surplus in the window exceeds free capacity, the battery cannot fully absorb everything. Charging is spread evenly over the cheap slots so the battery fills gradually instead of hitting 100% in the first slot: + +``` +if total_cheap_surplus > free_capacity: + charge_limit = free_capacity / num_cheap_slots (Wh/slot -> W) +else: + no limit (-1) ``` -Both limits are computed and the stricter one is applied using **MODE 8** (`limit_battery_charge_rate`). Peak shaving only applies when discharge is already allowed by the main price-based logic. +The charge limit is applied using **MODE 8** (`limit_battery_charge_rate`). Peak shaving only applies when discharge is already allowed by the main price-based logic. ### Skip Conditions Peak shaving is automatically skipped when: -1. **`price_limit` not configured** — peak shaving is disabled entirely (primary disable condition) -2. **Currently in a cheap slot** (`prices[0] <= price_limit`) — battery should absorb PV freely -3. **No PV production** — nighttime, no action needed -4. **Past the target hour** — battery is allowed to be full -5. **Battery in always_allow_discharge region** — SOC is already high -6. **Grid charging active (MODE -1)** — force charge takes priority -7. **Discharge not allowed** — battery is being preserved for upcoming high-price hours; PV charging should not be throttled so the battery can charge as fast as possible -8. **EVCC is actively charging** — EV consumes the excess PV -9. **EV connected in PV mode** — EVCC will absorb PV surplus +1. **`price_limit` not configured** for mode `price` or `combined` - price component disabled +2. **No PV production** - nighttime, no action needed +3. **Past the target hour** (`allow_full_battery_after`) - applies to all modes; no limit +4. **Battery in always_allow_discharge region** - SOC is already high +5. **Grid charging active (MODE -1)** - force charge takes priority +6. **Discharge not allowed** - battery is being preserved for upcoming high-price hours +7. **EVCC is actively charging** - EV consumes the excess PV +8. **EV connected in PV mode** - EVCC will absorb PV surplus + +The price-based component also returns no limit when: +- No cheap slots exist in the forecast +- Inside cheap window and total surplus fits in free capacity (absorb freely) ### EVCC Interaction When an EV charger is managed by EVCC: -- **EV actively charging** (`charging=true`): Peak shaving is disabled — the EV consumes the excess PV -- **EV connected in PV mode** (`connected=true` AND `mode=pv`): Peak shaving is disabled — EVCC will naturally absorb surplus PV when the threshold is reached +- **EV actively charging** (`charging=true`): Peak shaving is disabled - the EV consumes the excess PV +- **EV connected in PV mode** (`connected=true` AND `mode=pv`): Peak shaving is disabled - EVCC will naturally absorb surplus PV when the threshold is reached - **EV disconnects or mode changes**: Peak shaving is re-enabled The EVCC integration derives `mode` and `connected` topics automatically from the configured `loadpoint_topic` by replacing `/charging` with `/mode` and `/connected`. @@ -125,9 +137,9 @@ The EVCC integration derives `mode` and `connected` topics automatically from th The following HA entities are automatically created: -- **Peak Shaving Enabled** — switch entity -- **Peak Shaving Allow Full After** — number entity (0-23, step 1) -- **Peak Shaving Charge Limit** — sensor entity (unit: W) +- **Peak Shaving Enabled** - switch entity +- **Peak Shaving Allow Full After** - number entity (0-23, step 1) +- **Peak Shaving Charge Limit** - sensor entity (unit: W) ## Known Limitations diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 50d4a7ae..6f0e4f3b 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -528,6 +528,7 @@ def run(self): peak_shaving_enabled=peak_shaving_config.get('enabled', False), peak_shaving_allow_full_after=peak_shaving_config.get( 'allow_full_battery_after', 14), + peak_shaving_mode=peak_shaving_config.get('mode', 'combined'), peak_shaving_price_limit=peak_shaving_config.get('price_limit', None), ) diff --git a/src/batcontrol/logic/logic_interface.py b/src/batcontrol/logic/logic_interface.py index 4ef56b60..9febeca2 100644 --- a/src/batcontrol/logic/logic_interface.py +++ b/src/batcontrol/logic/logic_interface.py @@ -24,9 +24,14 @@ class CalculationParameters: # Peak shaving parameters peak_shaving_enabled: bool = False peak_shaving_allow_full_after: int = 14 # Hour (0-23) - # Slots where price <= this limit (€/kWh) are treated as cheap PV windows. - # Battery capacity is reserved so those slots can be absorbed fully. - # When None, peak shaving is disabled regardless of the enabled flag. + # Operating mode: + # 'time' - limit by target hour only (allow_full_battery_after) + # 'price' - limit by cheap-slot reservation only (price_limit required) + # 'combined' - both limits active, stricter one wins + peak_shaving_mode: str = 'combined' + # Slots where price (Euro/kWh) is at or below this value are treated as + # cheap PV windows. -1 or any numeric value accepted; None disables + # price-based component. peak_shaving_price_limit: Optional[float] = None def __post_init__(self): @@ -35,11 +40,17 @@ def __post_init__(self): f"peak_shaving_allow_full_after must be 0-23, " f"got {self.peak_shaving_allow_full_after}" ) + valid_modes = ('time', 'price', 'combined') + if self.peak_shaving_mode not in valid_modes: + raise ValueError( + f"peak_shaving_mode must be one of {valid_modes}, " + f"got '{self.peak_shaving_mode}'" + ) if (self.peak_shaving_price_limit is not None - and self.peak_shaving_price_limit < 0): + and not isinstance(self.peak_shaving_price_limit, (int, float))): raise ValueError( - f"peak_shaving_price_limit must be >= 0, " - f"got {self.peak_shaving_price_limit}" + f"peak_shaving_price_limit must be numeric or None, " + f"got {type(self.peak_shaving_price_limit).__name__}" ) @dataclass diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index e0fe38d0..a103907b 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -1,4 +1,4 @@ -"""NextLogic — Extended battery control logic with peak shaving. +"""NextLogic - Extended battery control logic with peak shaving. This module provides the NextLogic class, which extends the DefaultLogic behavior with a peak shaving post-processing step. Peak shaving manages @@ -210,55 +210,42 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, calc_input: CalculationInput, calc_timestamp: datetime.datetime ) -> InverterControlSettings: - """Limit PV charge rate to spread battery charging until target hour. + """Limit PV charge rate based on the configured peak shaving mode. - Peak shaving uses MODE 8 (limit_battery_charge_rate with - allow_discharge=True). It is only applied when the main logic - already allows discharge — meaning no upcoming high-price slots - require preserving battery energy. - - The algorithm has two components that are combined (stricter wins): - - Price-based: reserve battery capacity for upcoming cheap-price PV - slots so they can be absorbed fully (primary driver). Requires - peak_shaving_price_limit to be configured; disabled when None. - - Time-based: spread remaining capacity over slots until - allow_full_battery_after (secondary constraint). + Mode behaviour (peak_shaving_mode): + 'time' - spread remaining capacity until allow_full_battery_after + 'price' - reserve capacity for upcoming cheap-price PV slots; + inside cheap window, spread if surplus > free capacity + 'combined' - both limits active, stricter one wins Skipped when: - - price_limit is not configured (peak shaving disabled) - - No production right now (nighttime) - - Currently in a cheap slot (price <= price_limit) — charge freely - - Past the target hour (allow_full_battery_after) - - Battery is in always_allow_discharge region (high SOC) - - Force charge from grid is active (MODE -1) + - 'price'/'combined' mode and price_limit is not configured + - No PV production right now (nighttime) + - Past allow_full_battery_after hour (all modes) + - Battery in always_allow_discharge region (high SOC) + - Force-charge from grid active (MODE -1) - Discharge not allowed (battery preserved for high-price hours) Note: EVCC checks (charging, connected+pv mode) are handled in core.py, not here. """ + mode = self.calculation_parameters.peak_shaving_mode price_limit = self.calculation_parameters.peak_shaving_price_limit - # price_limit not configured → peak shaving is disabled - if price_limit is None: - logger.debug('[PeakShaving] Skipped: price_limit not configured') + # Price component needs price_limit configured + if mode in ('price', 'combined') and price_limit is None: + logger.debug('[PeakShaving] Skipped: price_limit not configured for mode %s', mode) return settings - # No production right now: skip calculation (avoid unnecessary work at night) + # No production right now: skip if calc_input.production[0] <= 0: return settings - # Currently in a cheap slot — charge battery freely - if calc_input.prices[0] <= price_limit: - logger.debug('[PeakShaving] Skipped: currently in cheap-price slot ' - '(price %.3f <= limit %.3f)', - calc_input.prices[0], price_limit) - return settings - - # After target hour: no limit + # Past target hour: skip (applies to all modes) if calc_timestamp.hour >= self.calculation_parameters.peak_shaving_allow_full_after: return settings - # In always_allow_discharge region: skip peak shaving + # In always_allow_discharge region: skip if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): logger.debug('[PeakShaving] Skipped: battery in always_allow_discharge region') return settings @@ -269,42 +256,41 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, 'grid charging takes priority') return settings - # Battery preserved for high-price hours — don't limit PV charging + # Battery preserved for high-price hours -- don't limit PV charging if not settings.allow_discharge: logger.debug('[PeakShaving] Skipped: discharge not allowed, ' 'battery preserved for high-price hours') return settings - # Compute both limits, take stricter (lower non-negative value) - price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) - time_limit_w = self._calculate_peak_shaving_charge_limit(calc_input, calc_timestamp) + # Compute limits according to mode + price_limit_w = -1 + time_limit_w = -1 + + if mode in ('price', 'combined'): + price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) + if mode in ('time', 'combined'): + time_limit_w = self._calculate_peak_shaving_charge_limit(calc_input, calc_timestamp) - if price_limit_w < 0 and time_limit_w < 0: + candidates = [v for v in (price_limit_w, time_limit_w) if v >= 0] + if not candidates: logger.debug('[PeakShaving] Evaluated: no limit needed') return settings - if price_limit_w >= 0 and time_limit_w >= 0: - charge_limit = min(price_limit_w, time_limit_w) - elif price_limit_w >= 0: - charge_limit = price_limit_w - else: - charge_limit = time_limit_w + charge_limit = min(candidates) - # Apply PV charge rate limit + # Apply charge rate limit (keep more restrictive if one already exists) if settings.limit_battery_charge_rate < 0: - # No existing limit — apply peak shaving limit settings.limit_battery_charge_rate = charge_limit else: - # Keep the more restrictive limit settings.limit_battery_charge_rate = min( settings.limit_battery_charge_rate, charge_limit) # Note: allow_discharge is already True here (checked above). # MODE 8 requires allow_discharge=True to work correctly. - logger.info('[PeakShaving] PV charge limit: %d W ' + logger.info('[PeakShaving] mode=%s, PV limit: %d W ' '(price-based=%s W, time-based=%s W, full by %d:00)', - settings.limit_battery_charge_rate, + mode, settings.limit_battery_charge_rate, price_limit_w if price_limit_w >= 0 else 'off', time_limit_w if time_limit_w >= 0 else 'off', self.calculation_parameters.peak_shaving_allow_full_after) @@ -315,17 +301,17 @@ def _calculate_peak_shaving_charge_limit_price_based( self, calc_input: CalculationInput) -> int: """Reserve battery free capacity for upcoming cheap-price PV slots. - Finds upcoming slots where price <= peak_shaving_price_limit and - calculates a charge rate limit that keeps enough free capacity to - absorb the expected PV surplus during those cheap slots fully. + When currently inside a cheap window (first cheap slot == 0): + If total PV surplus in the window exceeds free capacity, spread + the free capacity evenly over all remaining cheap slots so the + battery fills gradually rather than hitting 100% in the first slot. + If surplus <= free capacity no limit is needed. - Algorithm: - 1. Find upcoming cheap slots (price <= price_limit). - 2. Sum PV surplus during cheap slots → target_reserve_wh. - 3. additional_charging_allowed = free_capacity - target_reserve_wh - 4. If additional_charging_allowed <= 0: block PV charging (return 0). - 5. Spread additional_charging_allowed evenly over slots before - the cheap window starts. + When before the cheap window: + 1. Sum PV surplus during cheap slots -> target_reserve_wh. + 2. additional_allowed = free_capacity - target_reserve_wh. + 3. If additional_allowed <= 0: block PV charging (return 0). + 4. Spread additional_allowed evenly over slots before the window. Returns: int: charge rate limit in W, or -1 if no limit needed. @@ -334,17 +320,37 @@ def _calculate_peak_shaving_charge_limit_price_based( prices = calc_input.prices interval_hours = self.interval_minutes / 60.0 - # Find all cheap slots cheap_slots = [i for i, p in enumerate(prices) if p is not None and p <= price_limit] if not cheap_slots: - return -1 # No cheap slots ahead + return -1 # No cheap slots in the forecast first_cheap_slot = cheap_slots[0] + + # -- Currently inside cheap window -------------------------------- # if first_cheap_slot == 0: - return -1 # Already in cheap slot (caller checks this too) + total_cheap_surplus_wh = 0.0 + for i in cheap_slots: + if i < len(calc_input.production) and i < len(calc_input.consumption): + surplus = (float(calc_input.production[i]) + - float(calc_input.consumption[i])) + if surplus > 0: + total_cheap_surplus_wh += surplus * interval_hours + + if total_cheap_surplus_wh <= calc_input.free_capacity: + return -1 # Battery can absorb everything, no limit needed + + # Surplus exceeds free capacity: spread evenly over cheap slots + charge_rate_w = (calc_input.free_capacity + / len(cheap_slots) / interval_hours) + logger.debug( + '[PeakShaving] In cheap window: surplus %.0f Wh > free %.0f Wh, ' + 'spreading over %d slots -> %d W', + total_cheap_surplus_wh, calc_input.free_capacity, + len(cheap_slots), int(charge_rate_w)) + return int(charge_rate_w) - # Calculate expected PV surplus during cheap slots + # -- Before cheap window: reserve capacity for it ----------------- # total_cheap_surplus_wh = 0.0 for i in cheap_slots: if i < len(calc_input.production) and i < len(calc_input.consumption): @@ -358,23 +364,22 @@ def _calculate_peak_shaving_charge_limit_price_based( # Reserve capacity (capped at full battery capacity) target_reserve_wh = min(total_cheap_surplus_wh, self.common.max_capacity) - # How much more can we charge before the cheap window starts? additional_charging_allowed = calc_input.free_capacity - target_reserve_wh if additional_charging_allowed <= 0: logger.debug( - '[PeakShaving] Price-based: battery full relative to cheap-window ' + '[PeakShaving] Price-based: battery too full for cheap-window ' 'reserve (free=%.0f Wh, reserve=%.0f Wh), blocking PV charge', calc_input.free_capacity, target_reserve_wh) return 0 # Spread allowed charging evenly over slots before cheap window wh_per_slot = additional_charging_allowed / first_cheap_slot - charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + charge_rate_w = wh_per_slot / interval_hours # Wh/slot -> W logger.debug( '[PeakShaving] Price-based: cheap window at slot %d, ' - 'reserve=%.0f Wh, allowed=%.0f Wh → limit=%d W', + 'reserve=%.0f Wh, allowed=%.0f Wh -> %d W', first_cheap_slot, target_reserve_wh, additional_charging_allowed, int(charge_rate_w)) @@ -426,7 +431,7 @@ def _calculate_peak_shaving_charge_limit(self, calc_input: CalculationInput, # Spread charging evenly across remaining slots wh_per_slot = free_capacity / slots_remaining - charge_rate_w = wh_per_slot / interval_hours # Convert Wh/slot → W + charge_rate_w = wh_per_slot / interval_hours # Wh/slot -> W return int(charge_rate_w) diff --git a/tests/batcontrol/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py index 95e06a7a..80d7dbb6 100644 --- a/tests/batcontrol/logic/test_peak_shaving.py +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -60,12 +60,12 @@ def _make_input(self, production, consumption, stored_energy, ) def test_high_surplus_small_free_capacity(self): - """High PV surplus, small free capacity → low charge limit.""" + """High PV surplus, small free capacity -> low charge limit.""" # 8 hours until 14:00 starting from 06:00 - # 5000 W PV per slot, 500 W consumption → 4500 W surplus per slot + # 5000 W PV per slot, 500 W consumption -> 4500 W surplus per slot # 8 slots * 4500 Wh = 36000 Wh surplus total # free_capacity = 2000 Wh - # charge limit = 2000 / 8 = 250 Wh/slot → 250 W (60 min intervals) + # charge limit = 2000 / 8 = 250 Wh/slot -> 250 W (60 min intervals) production = [5000] * 8 + [0] * 4 consumption = [500] * 8 + [0] * 4 calc_input = self._make_input(production, consumption, @@ -78,10 +78,10 @@ def test_high_surplus_small_free_capacity(self): self.assertEqual(limit, 250) def test_low_surplus_large_free_capacity(self): - """Low PV surplus, large free capacity → no limit (-1).""" - # 1000 W PV, 800 W consumption → 200 W surplus + """Low PV surplus, large free capacity -> no limit (-1).""" + # 1000 W PV, 800 W consumption -> 200 W surplus # 8 slots * 200 Wh = 1600 Wh surplus - # free_capacity = 5000 Wh → surplus < free → no limit + # free_capacity = 5000 Wh -> surplus < free -> no limit production = [1000] * 8 + [0] * 4 consumption = [800] * 8 + [0] * 4 calc_input = self._make_input(production, consumption, @@ -94,7 +94,7 @@ def test_low_surplus_large_free_capacity(self): self.assertEqual(limit, -1) def test_surplus_equals_free_capacity(self): - """PV surplus exactly matches free capacity → no limit (-1).""" + """PV surplus exactly matches free capacity -> no limit (-1).""" production = [3000] * 8 + [0] * 4 consumption = [1000] * 8 + [0] * 4 # surplus per slot = 2000 W, 8 slots = 16000 Wh @@ -108,7 +108,7 @@ def test_surplus_equals_free_capacity(self): self.assertEqual(limit, -1) def test_battery_full(self): - """Battery full (free_capacity = 0) → charge limit = 0.""" + """Battery full (free_capacity = 0) -> charge limit = 0.""" production = [5000] * 8 + [0] * 4 consumption = [500] * 8 + [0] * 4 calc_input = self._make_input(production, consumption, @@ -121,7 +121,7 @@ def test_battery_full(self): self.assertEqual(limit, 0) def test_past_target_hour(self): - """Past target hour → no limit (-1).""" + """Past target hour -> no limit (-1).""" production = [5000] * 8 consumption = [500] * 8 calc_input = self._make_input(production, consumption, @@ -134,10 +134,10 @@ def test_past_target_hour(self): self.assertEqual(limit, -1) def test_one_slot_remaining(self): - """1 slot remaining → rate for that single slot.""" - # Target is 14:00, current time is 13:00 → 1 slot - # PV surplus: 5000 - 500 = 4500 W → 4500 Wh > free_cap 1000 - # limit = 1000 / 1 = 1000 Wh/slot → 1000 W + """1 slot remaining -> rate for that single slot.""" + # Target is 14:00, current time is 13:00 -> 1 slot + # PV surplus: 5000 - 500 = 4500 W -> 4500 Wh > free_cap 1000 + # limit = 1000 / 1 = 1000 Wh/slot -> 1000 W production = [5000] * 2 consumption = [500] * 2 calc_input = self._make_input(production, consumption, @@ -151,10 +151,10 @@ def test_one_slot_remaining(self): def test_consumption_reduces_surplus(self): """High consumption reduces effective PV surplus.""" - # 3000 W PV, 2000 W consumption → 1000 W surplus + # 3000 W PV, 2000 W consumption -> 1000 W surplus # 8 slots * 1000 Wh = 8000 Wh surplus - # free_capacity = 4000 Wh → surplus > free - # limit = 4000 / 8 = 500 Wh/slot → 500 W + # free_capacity = 4000 Wh -> surplus > free + # limit = 4000 / 8 = 500 Wh/slot -> 500 W production = [3000] * 8 + [0] * 4 consumption = [2000] * 8 + [0] * 4 calc_input = self._make_input(production, consumption, @@ -172,11 +172,11 @@ def test_15min_intervals(self): interval_minutes=15) logic_15.set_calculation_parameters(self.params) - # Target 14:00, current 13:00 → 4 slots of 15 min + # Target 14:00, current 13:00 -> 4 slots of 15 min # surplus = 4000 W per slot, interval_hours = 0.25 # surplus Wh per slot = 4000 * 0.25 = 1000 Wh # total surplus = 4 * 1000 = 4000 Wh - # free_capacity = 1000 Wh → surplus > free + # free_capacity = 1000 Wh -> surplus > free # wh_per_slot = 1000 / 4 = 250 Wh # charge_rate_w = 250 / 0.25 = 1000 W production = [4500] * 4 @@ -210,6 +210,7 @@ def setUp(self): max_capacity=self.max_capacity, peak_shaving_enabled=True, peak_shaving_allow_full_after=14, + peak_shaving_mode='combined', peak_shaving_price_limit=0.05, # required; tests use high prices so no cheap slots ) self.logic.set_calculation_parameters(self.params) @@ -225,7 +226,7 @@ def _make_settings(self, allow_discharge=True, charge_from_grid=False, def _make_input(self, production, consumption, stored_energy, free_capacity): - # Use high prices (10.0) so no slot is "cheap" — only time-based limit applies. + # Use high prices (10.0) so no slot is "cheap" - only time-based limit applies. prices = np.ones(len(production)) * 10.0 return CalculationInput( production=np.array(production, dtype=float), @@ -237,7 +238,7 @@ def _make_input(self, production, consumption, stored_energy, ) def test_nighttime_no_production(self): - """No production (nighttime) → peak shaving skipped.""" + """No production (nighttime) -> peak shaving skipped.""" settings = self._make_settings() calc_input = self._make_input( [0, 0, 0, 0], [500, 500, 500, 500], @@ -248,7 +249,7 @@ def test_nighttime_no_production(self): self.assertEqual(result.limit_battery_charge_rate, -1) def test_after_target_hour(self): - """After target hour → no change.""" + """After target hour -> no change.""" settings = self._make_settings() calc_input = self._make_input( [5000, 5000], [500, 500], @@ -259,7 +260,7 @@ def test_after_target_hour(self): self.assertEqual(result.limit_battery_charge_rate, -1) def test_force_charge_takes_priority(self): - """Force charge (MODE -1) → peak shaving skipped.""" + """Force charge (MODE -1) -> peak shaving skipped.""" settings = self._make_settings( allow_discharge=False, charge_from_grid=True, charge_rate=3000) calc_input = self._make_input( @@ -272,7 +273,7 @@ def test_force_charge_takes_priority(self): self.assertEqual(result.limit_battery_charge_rate, -1) def test_always_allow_discharge_region(self): - """Battery in always_allow_discharge region → skip peak shaving.""" + """Battery in always_allow_discharge region -> skip peak shaving.""" settings = self._make_settings() # stored_energy=9500 > 10000 * 0.9 = 9000 calc_input = self._make_input( @@ -284,9 +285,9 @@ def test_always_allow_discharge_region(self): self.assertEqual(result.limit_battery_charge_rate, -1) def test_peak_shaving_applies_limit(self): - """Before target hour, limit calculated → limit set.""" + """Before target hour, limit calculated -> limit set.""" settings = self._make_settings() - # 6 slots (6..14), 5000W PV, 500W consumption → 4500W surplus + # 6 slots (6..14), 5000W PV, 500W consumption -> 4500W surplus # surplus Wh = 6 * 4500 = 27000 > free 3000 # limit = 3000 / 6 = 500 W calc_input = self._make_input( @@ -299,7 +300,7 @@ def test_peak_shaving_applies_limit(self): self.assertTrue(result.allow_discharge) def test_existing_tighter_limit_kept(self): - """Existing limit is tighter → keep existing.""" + """Existing limit is tighter -> keep existing.""" settings = self._make_settings(limit_battery_charge_rate=200) calc_input = self._make_input( [5000] * 8, [500] * 8, @@ -310,7 +311,7 @@ def test_existing_tighter_limit_kept(self): self.assertEqual(result.limit_battery_charge_rate, 200) def test_peak_shaving_limit_tighter(self): - """Peak shaving limit is tighter than existing → peak shaving limit applied.""" + """Peak shaving limit is tighter than existing -> peak shaving limit applied.""" settings = self._make_settings(limit_battery_charge_rate=5000) calc_input = self._make_input( [5000] * 8, [500] * 8, @@ -321,7 +322,7 @@ def test_peak_shaving_limit_tighter(self): self.assertEqual(result.limit_battery_charge_rate, 500) def test_discharge_not_allowed_skips_peak_shaving(self): - """Discharge not allowed (battery preserved for high-price hours) → skip.""" + """Discharge not allowed (battery preserved for high-price hours) -> skip.""" settings = self._make_settings(allow_discharge=False) calc_input = self._make_input( [5000] * 8, [500] * 8, @@ -333,7 +334,7 @@ def test_discharge_not_allowed_skips_peak_shaving(self): self.assertFalse(result.allow_discharge) def test_price_limit_none_disables_peak_shaving(self): - """price_limit=None → peak shaving disabled entirely.""" + """price_limit=None with mode='combined' -> peak shaving disabled entirely.""" params = CalculationParameters( max_charging_from_grid_limit=0.79, min_price_difference=0.05, @@ -341,6 +342,7 @@ def test_price_limit_none_disables_peak_shaving(self): max_capacity=self.max_capacity, peak_shaving_enabled=True, peak_shaving_allow_full_after=14, + peak_shaving_mode='combined', peak_shaving_price_limit=None, ) self.logic.set_calculation_parameters(params) @@ -353,12 +355,13 @@ def test_price_limit_none_disables_peak_shaving(self): self.assertEqual(result.limit_battery_charge_rate, -1) def test_currently_in_cheap_slot_no_limit(self): - """Current slot is cheap (price <= price_limit) → charge freely.""" + """In cheap slot, surplus fits in battery -> no limit applied.""" settings = self._make_settings() prices = np.zeros(8) # all slots cheap (price=0 <= 0.05) + # production=200W, surplus=1600 Wh total < free=5000 Wh -> no limit calc_input = CalculationInput( - production=np.array([5000] * 8, dtype=float), - consumption=np.array([500] * 8, dtype=float), + production=np.array([200] * 8, dtype=float), + consumption=np.zeros(8, dtype=float), prices=prices, stored_energy=5000, stored_usable_energy=4500, @@ -369,6 +372,78 @@ def test_currently_in_cheap_slot_no_limit(self): result = self.logic._apply_peak_shaving(settings, calc_input, ts) self.assertEqual(result.limit_battery_charge_rate, -1) + def test_currently_in_cheap_slot_surplus_overflow(self): + """In cheap slot, surplus > free capacity -> spread evenly over cheap slots. + + prices all 0 (cheap), production=3000W, consumption=0, 8 slots. + Total surplus = 8 * 3000 = 24000 Wh > free=5000 Wh. + Price-based: spread 5000 / 8 slots = 625 W. + Time-based (mode=combined): 6 slots to target, 6*3000=18000>5000 -> 5000/6=833 W. + min(625, 833) = 625. + """ + settings = self._make_settings() + prices = np.zeros(8) + calc_input = CalculationInput( + production=np.array([3000] * 8, dtype=float), + consumption=np.zeros(8, dtype=float), + prices=prices, + stored_energy=5000, + stored_usable_energy=4500, + free_capacity=5000, + ) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, 625) + + def test_mode_time_only_ignores_price_limit(self): + """Mode 'time': price_limit=None does not disable peak shaving.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + peak_shaving_mode='time', + peak_shaving_price_limit=None, # not needed for 'time' mode + ) + self.logic.set_calculation_parameters(params) + settings = self._make_settings() + calc_input = self._make_input([5000] * 8, [500] * 8, + stored_energy=7000, free_capacity=3000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + # time-based: 6 slots, surplus=6*4500=27000>3000 -> limit=3000/6=500 W + self.assertEqual(result.limit_battery_charge_rate, 500) + + def test_mode_price_only_no_time_limit(self): + """Mode 'price': only price-based component fires. + + With no cheap slots ahead (prices all 10 > 0.05), price-based + returns -1 and no limit is applied even if time-based would fire. + """ + params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + peak_shaving_mode='price', + peak_shaving_price_limit=0.05, + ) + self.logic.set_calculation_parameters(params) + settings = self._make_settings() + # High prices -> no cheap slots -> price-based returns -1 -> no limit + calc_input = self._make_input([5000] * 8, [500] * 8, + stored_energy=7000, free_capacity=3000) + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, + tzinfo=datetime.timezone.utc) + result = self.logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + class TestPeakShavingDisabled(unittest.TestCase): """Test that peak shaving does nothing when disabled.""" @@ -393,7 +468,7 @@ def setUp(self): self.logic.set_calculation_parameters(self.params) def test_disabled_no_limit(self): - """peak_shaving_enabled=False → no change to settings.""" + """peak_shaving_enabled=False -> no change to settings.""" production = np.array([5000] * 8, dtype=float) consumption = np.array([500] * 8, dtype=float) prices = np.zeros(8) @@ -424,25 +499,25 @@ def setUp(self): ) def test_default_type(self): - """type: default → DefaultLogic.""" + """type: default -> DefaultLogic.""" config = {'battery_control': {'type': 'default'}} logic = Logic.create_logic(config, datetime.timezone.utc) self.assertIsInstance(logic, DefaultLogic) def test_next_type(self): - """type: next → NextLogic.""" + """type: next -> NextLogic.""" config = {'battery_control': {'type': 'next'}} logic = Logic.create_logic(config, datetime.timezone.utc) self.assertIsInstance(logic, NextLogic) def test_missing_type_defaults_to_default(self): - """No type key → DefaultLogic.""" + """No type key -> DefaultLogic.""" config = {} logic = Logic.create_logic(config, datetime.timezone.utc) self.assertIsInstance(logic, DefaultLogic) def test_unknown_type_raises(self): - """Unknown type → RuntimeError.""" + """Unknown type -> RuntimeError.""" config = {'battery_control': {'type': 'unknown'}} with self.assertRaises(RuntimeError): Logic.create_logic(config, datetime.timezone.utc) @@ -465,7 +540,7 @@ class TestCalculationParametersPeakShaving(unittest.TestCase): """Test CalculationParameters peak shaving fields.""" def test_defaults(self): - """Without peak shaving args → defaults.""" + """Without peak shaving args -> defaults.""" params = CalculationParameters( max_charging_from_grid_limit=0.8, min_price_difference=0.05, @@ -474,9 +549,10 @@ def test_defaults(self): ) self.assertFalse(params.peak_shaving_enabled) self.assertEqual(params.peak_shaving_allow_full_after, 14) + self.assertEqual(params.peak_shaving_mode, 'combined') def test_explicit_values(self): - """With explicit peak shaving args → stored.""" + """With explicit peak shaving args -> stored.""" params = CalculationParameters( max_charging_from_grid_limit=0.8, min_price_difference=0.05, @@ -542,15 +618,59 @@ def test_price_limit_zero_allowed(self): ) self.assertEqual(params.peak_shaving_price_limit, 0.0) - def test_price_limit_negative_raises(self): - """Negative price_limit raises ValueError.""" + def test_price_limit_negative_one_allowed(self): + """price_limit=-1 is valid (effectively disables cheap-slot detection).""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_price_limit=-1, + ) + self.assertEqual(params.peak_shaving_price_limit, -1) + + def test_price_limit_arbitrary_negative_allowed(self): + """Arbitrary negative price_limit is accepted (only numeric check).""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_price_limit=-0.5, + ) + self.assertEqual(params.peak_shaving_price_limit, -0.5) + + def test_mode_default_is_combined(self): + """peak_shaving_mode defaults to 'combined'.""" + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + ) + self.assertEqual(params.peak_shaving_mode, 'combined') + + def test_mode_valid_values(self): + """'time', 'price', 'combined' are all accepted.""" + for mode in ('time', 'price', 'combined'): + params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.1, + max_capacity=10000, + peak_shaving_mode=mode, + ) + self.assertEqual(params.peak_shaving_mode, mode) + + def test_mode_invalid_raises(self): + """Unknown mode string raises ValueError.""" with self.assertRaises(ValueError): CalculationParameters( max_charging_from_grid_limit=0.8, min_price_difference=0.05, min_price_difference_rel=0.1, max_capacity=10000, - peak_shaving_price_limit=-0.01, + peak_shaving_mode='invalid', ) @@ -574,6 +694,7 @@ def setUp(self): max_capacity=self.max_capacity, peak_shaving_enabled=True, peak_shaving_allow_full_after=14, + peak_shaving_mode='price', peak_shaving_price_limit=0.05, ) self.logic.set_calculation_parameters(self.params) @@ -598,7 +719,7 @@ def test_surplus_exceeds_free_capacity_blocks_charging(self): Surplus in cheap slots: 3000+3000 = 6000 Wh (interval=1h, consumption=0). target_reserve = min(6000, 10000) = 6000 Wh. free_capacity = 4000 Wh. - additional_allowed = 4000 - 6000 = -2000 → block charging (return 0). + additional_allowed = 4000 - 6000 = -2000 -> block charging (return 0). """ prices = [10, 10, 10, 8, 3, 0, 0, 1] production = [500, 500, 500, 500, 500, 3000, 3000, 500] @@ -612,33 +733,47 @@ def test_partial_reserve_spread_over_slots(self): Battery has 8000 Wh free, target_reserve = min(6000, 10000) = 6000 Wh. additional_allowed = 8000 - 6000 = 2000 Wh. first_cheap_slot = 4 (4 slots before cheap window). - wh_per_slot = 2000 / 4 = 500 Wh → rate = 500 W (60 min intervals). + wh_per_slot = 2000 / 4 = 500 Wh -> rate = 500 W (60 min intervals). """ prices = [10, 10, 10, 10, 0, 0, 1, 2] - # cheap surplus slots 4,5: 3000W each, interval=1h → 3000 Wh each + # cheap surplus slots 4,5: 3000W each, interval=1h -> 3000 Wh each production = [500, 500, 500, 500, 3000, 3000, 500, 500] calc_input = self._make_input(production, prices, free_capacity=8000) result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) self.assertEqual(result, 500) def test_no_cheap_slots_returns_minus_one(self): - """No cheap slots in prices → -1.""" + """No cheap slots in prices -> -1.""" prices = [10, 10, 10, 10, 10, 10] production = [3000] * 6 calc_input = self._make_input(production, prices, free_capacity=5000) result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) self.assertEqual(result, -1) - def test_currently_in_cheap_slot_returns_minus_one(self): - """first_cheap_slot = 0 (current slot is cheap) → -1.""" + def test_currently_in_cheap_slot_fits_no_limit(self): + """first_cheap_slot = 0, surplus fits in battery -> -1 (no limit).""" prices = [0, 0, 10, 10] - production = [5000, 5000, 500, 500] + # surplus per cheap slot = 200W * 1h = 200 Wh each; total = 400 Wh < free 5000 + production = [200, 200, 500, 500] calc_input = self._make_input(production, prices, free_capacity=5000) result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) self.assertEqual(result, -1) + def test_currently_in_cheap_slot_surplus_overflow_spreads(self): + """first_cheap_slot = 0, surplus > free capacity -> spread over cheap slots. + + cheap slots: [0, 1], production=4000W each, consumption=0, interval=1h. + total_surplus = 2 * 4000 = 8000 Wh > free = 5000 Wh. + charge_rate = 5000 / 2 / 1h = 2500 W. + """ + prices = [0, 0, 10, 10] + production = [4000, 4000, 500, 500] + calc_input = self._make_input(production, prices, free_capacity=5000) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, 2500) + def test_zero_pv_surplus_in_cheap_slots_returns_minus_one(self): - """Cheap slots have no PV surplus (consumption >= production) → -1.""" + """Cheap slots have no PV surplus (consumption >= production) -> -1.""" prices = [10, 10, 0, 0] production = [500, 500, 200, 200] consumption = [500, 500, 300, 300] # net = 0 or negative in cheap slots @@ -650,8 +785,8 @@ def test_zero_pv_surplus_in_cheap_slots_returns_minus_one(self): def test_free_capacity_well_above_reserve_gives_rate(self): """ cheap surplus = 1000 Wh, target_reserve = 1000 Wh. - free_capacity = 6000 Wh → additional_allowed = 5000 Wh. - first_cheap_slot = 5 → wh_per_slot = 1000 W. + free_capacity = 6000 Wh -> additional_allowed = 5000 Wh. + first_cheap_slot = 5 -> wh_per_slot = 1000 W. """ prices = [10, 10, 10, 10, 10, 0, 10] production = [500, 500, 500, 500, 500, 1000, 500] @@ -661,9 +796,9 @@ def test_free_capacity_well_above_reserve_gives_rate(self): def test_consumption_reduces_cheap_surplus(self): """ - Cheap slot: production=5000W, consumption=3000W → surplus=2000 Wh. - target_reserve = 2000, free=5000 → additional=3000 Wh. - first_cheap_slot=2 → rate = 3000/2 = 1500 W. + Cheap slot: production=5000W, consumption=3000W -> surplus=2000 Wh. + target_reserve = 2000, free=5000 -> additional=3000 Wh. + first_cheap_slot=2 -> rate = 3000/2 = 1500 W. """ prices = [10, 10, 0, 10] production = [500, 500, 5000, 500] @@ -675,18 +810,24 @@ def test_consumption_reduces_cheap_surplus(self): def test_combine_price_and_time_limits_stricter_wins(self): """ - Both limits active: time-based and price-based give different rates. - The stricter (lower) limit must be applied. + Both limits active (mode='combined'): stricter limit wins. Setup at 08:00, target 14:00 (6 slots remaining). High prices except slot 4 (cheap). - price-based: cheap surplus=4000Wh at 4, free=3000 → allowed=reserved-capped - Just check final result <= both individual limits. """ + params_combined = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self.max_capacity, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + peak_shaving_mode='combined', + peak_shaving_price_limit=0.05, + ) logic = NextLogic(timezone=datetime.timezone.utc, interval_minutes=60) - logic.set_calculation_parameters(self.params) + logic.set_calculation_parameters(params_combined) ts = datetime.datetime(2025, 6, 20, 8, 0, 0, tzinfo=datetime.timezone.utc) - # prices: slots 0-3 high, slot 4 cheap, slots 5-7 high again prices = np.array([10, 10, 10, 10, 0, 10, 10, 10], dtype=float) production = np.array([500, 500, 500, 500, 5000, 5000, 500, 500], dtype=float) calc_input = CalculationInput( From 4da76d736f8b815b800b21288d838b6637ba471c Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 16:25:58 +0100 Subject: [PATCH 25/35] Add more logging and fix EVCC / evcc spelling --- PLAN.md | 48 +++++++++---------- docs/15-min-transform.md | 16 +++---- docs/WIKI_peak_shaving.md | 12 ++--- src/batcontrol/core.py | 25 ++++++---- src/batcontrol/dynamictariff/evcc.py | 6 +-- src/batcontrol/evcc_api.py | 6 +-- src/batcontrol/forecastsolar/evcc_solar.py | 2 +- src/batcontrol/logic/next.py | 2 +- .../dynamictariff/test_baseclass.py | 8 ++-- tests/batcontrol/dynamictariff/test_evcc.py | 6 +-- tests/batcontrol/test_core.py | 12 ++--- tests/batcontrol/test_evcc_mode.py | 4 +- 12 files changed, 77 insertions(+), 70 deletions(-) diff --git a/PLAN.md b/PLAN.md index abf76995..9b97a592 100644 --- a/PLAN.md +++ b/PLAN.md @@ -6,9 +6,9 @@ Add peak shaving to batcontrol: manage PV battery charging rate so the battery f **Problem:** All PV systems in the country produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest and - for newer installations - feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible into the battery, rather than feeding it into the grid during peak hours. -**EVCC interaction:** +**evcc interaction:** - When an EV is actively charging (`charging=true`), peak shaving is disabled - the EV consumes the excess PV. -- When an EV is connected and the loadpoint mode is `pv`, peak shaving is also disabled - EVCC+EV will naturally absorb excessive PV energy once the surplus threshold is reached. +- When an EV is connected and the loadpoint mode is `pv`, peak shaving is also disabled - evcc+EV will naturally absorb excessive PV energy once the surplus threshold is reached. - If the EV disconnects or the mode changes away from `pv`, peak shaving is re-enabled. Uses the existing **MODE 8 (limit_battery_charge_rate)** to throttle PV charging. @@ -57,17 +57,17 @@ The `type: next` logic includes all existing `DefaultLogic` behavior plus peak s --- -## 2. EVCC Integration - Loadpoint Mode & Connected State +## 2. evcc Integration - Loadpoint Mode & Connected State ### 2.1 Approach -Peak shaving is disabled when **any** of the following EVCC conditions are true: +Peak shaving is disabled when **any** of the following evcc conditions are true: 1. **`charging = true`** - EV is actively charging (already tracked) 2. **`connected = true` AND `mode = pv`** - EV is plugged in and waiting for PV surplus -The EVCC check is handled in `core.py`, **not** in the logic layer. EVCC is an external integration concern, same pattern as `discharge_blocked`. +The evcc check is handled in `core.py`, **not** in the logic layer. evcc is an external integration concern, same pattern as `discharge_blocked`. -### 2.2 New EVCC Topics - Derived from `loadpoint_topic` +### 2.2 New evcc Topics - Derived from `loadpoint_topic` The `mode` and `connected` topics are derived from the existing `loadpoint_topic` config by stripping `/charging` and appending the relevant suffix: @@ -140,7 +140,7 @@ def evcc_ev_expects_pv_surplus(self) -> bool: **`shutdown`:** Unsubscribe from mode and connected topics. -**EVCC offline reset:** When EVCC goes offline (status message received), mode and connected state are reset to prevent stale values: +**evcc offline reset:** When evcc goes offline (status message received), mode and connected state are reset to prevent stale values: ```python for root in list(self.evcc_loadpoint_mode.keys()): self.evcc_loadpoint_mode[root] = None @@ -363,7 +363,7 @@ def _apply_peak_shaving(self, settings, calc_input, calc_timestamp): - Force-charge from grid active (MODE -1) - Discharge not allowed (battery preserved for high-price hours) - Note: EVCC checks (charging, connected+pv mode) are handled in core.py. + Note: evcc checks (charging, connected+pv mode) are handled in core.py. """ mode = self.calculation_parameters.peak_shaving_mode price_limit = self.calculation_parameters.peak_shaving_price_limit @@ -512,14 +512,14 @@ calc_parameters = CalculationParameters( ) ``` -### 5.3 EVCC Peak Shaving Guard +### 5.3 evcc Peak Shaving Guard -The EVCC check is handled in `core.py`, keeping EVCC concerns out of the logic layer. This follows the same pattern as `discharge_blocked` (line 536-541 in current code). +The evcc check is handled in `core.py`, keeping evcc concerns out of the logic layer. This follows the same pattern as `discharge_blocked` (line 536-541 in current code). -After `logic.calculate()` returns and before mode dispatch, peak shaving is overridden if EVCC conditions require it: +After `logic.calculate()` returns and before mode dispatch, peak shaving is overridden if evcc conditions require it: ```python -# EVCC disables peak shaving (handled in core, not logic) +# evcc disables peak shaving (handled in core, not logic) if self.evcc_api is not None: evcc_disable_peak_shaving = ( self.evcc_api.evcc_is_charging or @@ -527,7 +527,7 @@ if self.evcc_api is not None: ) if evcc_disable_peak_shaving and inverter_settings.limit_battery_charge_rate >= 0: if self.evcc_api.evcc_is_charging: - logger.debug('[PeakShaving] Disabled: EVCC is actively charging') + logger.debug('[PeakShaving] Disabled: evcc is actively charging') else: logger.debug('[PeakShaving] Disabled: EV connected in PV mode') inverter_settings.limit_battery_charge_rate = -1 @@ -605,7 +605,7 @@ QoS: default (0) for all topics (consistent with existing MQTT API). - Zero allowed (free price slots) - Negative value raises `ValueError` -### 7.2 EVCC Tests (`tests/batcontrol/test_evcc_mode.py`) +### 7.2 evcc Tests (`tests/batcontrol/test_evcc_mode.py`) - Topic derivation: `evcc/loadpoints/1/charging` -> mode: `evcc/loadpoints/1/mode`, connected: `evcc/loadpoints/1/connected` - Non-standard topic (not ending in `/charging`) -> warning, no mode/connected sub @@ -618,12 +618,12 @@ QoS: default (0) for all topics (consistent with existing MQTT API). - Multi-loadpoint: one connected+pv is enough to return True - Mode change from pv to now -> `evcc_ev_expects_pv_surplus` changes to False -### 7.3 Core EVCC Guard Tests (`tests/batcontrol/test_core.py`) +### 7.3 Core evcc Guard Tests (`tests/batcontrol/test_core.py`) -- EVCC actively charging + charge limit active -> limit cleared to -1 +- evcc actively charging + charge limit active -> limit cleared to -1 - EV connected in PV mode + charge limit active -> limit cleared to -1 -- EVCC not charging and no PV mode -> charge limit preserved -- No charge limit active (-1) + EVCC charging -> no change (stays -1) +- evcc not charging and no PV mode -> charge limit preserved +- No charge limit active (-1) + evcc charging -> no change (stays -1) ### 7.4 Config Tests @@ -638,13 +638,13 @@ QoS: default (0) for all topics (consistent with existing MQTT API). 1. **Config** - Add `peak_shaving` section to dummy config, add `type: next` option 2. **Data model** - Extend `CalculationParameters` with peak shaving fields -3. **EVCC** - Add mode and connected topic subscriptions, `evcc_ev_expects_pv_surplus` property +3. **evcc** - Add mode and connected topic subscriptions, `evcc_ev_expects_pv_surplus` property 4. **NextLogic** - New file `next.py`: copy DefaultLogic, add `_calculate_peak_shaving_charge_limit()`, `_apply_peak_shaving()` 5. **Logic factory** - Add `type: next` -> `NextLogic` in `logic.py` -6. **Core** - Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard +6. **Core** - Wire peak shaving config into `CalculationParameters`, evcc peak shaving guard 7. **MQTT** - Publish topics + settable topics + HA discovery 8. **Tests** -9. **Documentation** - Write `docs/peak_shaving.md` covering feature overview, configuration, EVCC interaction, algorithm explanation, and known limitations +9. **Documentation** - Write `docs/peak_shaving.md` covering feature overview, configuration, evcc interaction, algorithm explanation, and known limitations --- @@ -657,11 +657,11 @@ QoS: default (0) for all topics (consistent with existing MQTT API). | `src/batcontrol/logic/next.py` | **New** - `NextLogic` class with peak shaving | | `src/batcontrol/logic/logic.py` | Add `type: next` -> `NextLogic` | | `src/batcontrol/evcc_api.py` | Add mode + connected topic subscriptions, `evcc_ev_expects_pv_surplus` | -| `src/batcontrol/core.py` | Wire peak shaving config into `CalculationParameters`, EVCC peak shaving guard | +| `src/batcontrol/core.py` | Wire peak shaving config into `CalculationParameters`, evcc peak shaving guard | | `src/batcontrol/mqtt_api.py` | Peak shaving MQTT topics + HA discovery | | `tests/batcontrol/logic/test_peak_shaving.py` | New - algorithm + decision tests | | `tests/batcontrol/test_evcc_mode.py` | New - mode/connected topic tests | -| `tests/batcontrol/test_core.py` | Add EVCC peak shaving guard tests | +| `tests/batcontrol/test_core.py` | Add evcc peak shaving guard tests | | `docs/WIKI_peak_shaving.md` | New - feature documentation | **Not modified:** `default.py` (untouched - peak shaving is in `next.py`) @@ -672,7 +672,7 @@ QoS: default (0) for all topics (consistent with existing MQTT API). 1. **New independent logic class:** Peak shaving lives in `NextLogic` (`type: next`), not as a modification to `DefaultLogic`. This keeps the stable default path untouched and allows the next logic to evolve independently. `NextLogic` is a full copy of `DefaultLogic` with peak shaving added. -2. **EVCC integration:** Handled in `core.py` (not logic layer). Peak shaving is disabled when `evcc_is_charging` OR (`connected=true` AND `mode=pv`). The mode and connected topics are derived from the existing `loadpoint_topic` config by stripping `/charging`. No `chargePower` subscription - it was reported as unreliable. +2. **evcc integration:** Handled in `core.py` (not logic layer). Peak shaving is disabled when `evcc_is_charging` OR (`connected=true` AND `mode=pv`). The mode and connected topics are derived from the existing `loadpoint_topic` config by stripping `/charging`. No `chargePower` subscription - it was reported as unreliable. 3. **Grid charging interaction (MODE -1 vs MODE 8):** `force_charge` takes priority. If price logic triggers grid charging during peak shaving hours, a warning is logged but grid charging proceeds. diff --git a/docs/15-min-transform.md b/docs/15-min-transform.md index e5d7668c..f10c2aab 100644 --- a/docs/15-min-transform.md +++ b/docs/15-min-transform.md @@ -533,7 +533,7 @@ class FCSolar(ForecastSolarBase): from .baseclass import ForecastSolarBase class EvccSolar(ForecastSolarBase): - """EVCC solar provider - returns 15-minute data.""" + """evcc solar provider - returns 15-minute data.""" def __init__(self, config: dict): super().__init__(config) @@ -541,7 +541,7 @@ class EvccSolar(ForecastSolarBase): # ... rest of init ... def _fetch_forecast(self) -> Dict[int, float]: - """Fetch 15-minute forecast from EVCC API.""" + """Fetch 15-minute forecast from evcc API.""" # Existing API call logic # Returns: {0: 250, 1: 300, 2: 350, ...} # Wh per 15-min response = self._call_evcc_api() @@ -712,7 +712,7 @@ from .baseclass import ForecastSolarBase class EvccSolar(ForecastSolarBase): def __init__(self, config: dict): super().__init__(config) - self.native_resolution = 15 # EVCC provides 15-min data + self.native_resolution = 15 # evcc provides 15-min data def _fetch_forecast(self) -> dict[int, float]: # Return native 15-minute data @@ -976,10 +976,10 @@ class Tibber(DynamicTariffBase): class EvccTariff(DynamicTariffBase): def __init__(self, config: dict): super().__init__(config) - self.native_resolution = 15 # EVCC provides 15-min data + self.native_resolution = 15 # evcc provides 15-min data def _fetch_prices(self) -> Dict[int, float]: - """Fetch 15-minute prices from EVCC API.""" + """Fetch 15-minute prices from evcc API.""" # Existing API logic # Old code averaged to hourly - now returns native 15-min return self._parse_15min_data() @@ -1032,7 +1032,7 @@ class Energyforecast(DynamicTariffBase): **API Documentation**: - **Awattar API**: https://www.awattar.de/services/api - **Tibber API**: https://developer.tibber.com/docs/guides/pricing -- **EVCC API**: https://docs.evcc.io/docs/reference/configuration/messaging#grid-tariff +- **evcc API**: https://docs.evcc.io/docs/reference/configuration/messaging#grid-tariff - **Energyforecast API**: https://www.energyforecast.de/api/v1/predictions/next_48_hours **Price Handling Note**: @@ -2328,7 +2328,7 @@ def upsample_forecast(hourly_data: dict, interval_minutes: int, - **Q**: Which electricity tariff provider(s) do you currently use? - [ ] Awattar (Germany/Austria) - [ ] Tibber (Nordic countries, Germany, Netherlands) - - [ ] EVCC integration + - [ ] evcc integration - [ ] Other: _______________ - **Q**: What is your tariff's price update frequency? @@ -2754,7 +2754,7 @@ Instead of each provider implementing upsampling/downsampling logic, we use a ** │ │ │ ├─────┬─────┬───┼───┬───────┬───┼────┬────┐ │ │ │ │ │ │ │ │ │ -FCSolar │ EVCC│ Awattar Tibber EVCC│ CSV │ HA +FCSolar │ evcc│ Awattar Tibber evcc│ CSV │ HA Prognose Solar Tariff Profile Forecast ``` diff --git a/docs/WIKI_peak_shaving.md b/docs/WIKI_peak_shaving.md index 9182ed46..648355da 100644 --- a/docs/WIKI_peak_shaving.md +++ b/docs/WIKI_peak_shaving.md @@ -99,22 +99,22 @@ Peak shaving is automatically skipped when: 4. **Battery in always_allow_discharge region** - SOC is already high 5. **Grid charging active (MODE -1)** - force charge takes priority 6. **Discharge not allowed** - battery is being preserved for upcoming high-price hours -7. **EVCC is actively charging** - EV consumes the excess PV -8. **EV connected in PV mode** - EVCC will absorb PV surplus +7. **evcc is actively charging** - EV consumes the excess PV +8. **EV connected in PV mode** - evcc will absorb PV surplus The price-based component also returns no limit when: - No cheap slots exist in the forecast - Inside cheap window and total surplus fits in free capacity (absorb freely) -### EVCC Interaction +### evcc Interaction -When an EV charger is managed by EVCC: +When an EV charger is managed by evcc: - **EV actively charging** (`charging=true`): Peak shaving is disabled - the EV consumes the excess PV -- **EV connected in PV mode** (`connected=true` AND `mode=pv`): Peak shaving is disabled - EVCC will naturally absorb surplus PV when the threshold is reached +- **EV connected in PV mode** (`connected=true` AND `mode=pv`): Peak shaving is disabled - evcc will naturally absorb surplus PV when the threshold is reached - **EV disconnects or mode changes**: Peak shaving is re-enabled -The EVCC integration derives `mode` and `connected` topics automatically from the configured `loadpoint_topic` by replacing `/charging` with `/mode` and `/connected`. +The evcc integration derives `mode` and `connected` topics automatically from the configured `loadpoint_topic` by replacing `/charging` with `/mode` and `/connected`. ## MQTT API diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 6f0e4f3b..0371d4a2 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -61,6 +61,7 @@ def __init__(self, configdict: dict): self.last_mode = None self.last_charge_rate = 0 self._limit_battery_charge_rate = -1 # Dynamic battery charge rate limit (-1 = no limit) + self._evcc_peak_shaving_disabled = False # Tracks last evcc-disable state for log messages self.last_prices = None self.last_consumption = None self.last_production = None @@ -557,20 +558,26 @@ def run(self): logger.debug('Discharge blocked due to external lock') inverter_settings.allow_discharge = False - # EVCC disables peak shaving (handled in core, not logic) + # evcc disables peak shaving (handled in core, not logic) if self.evcc_api is not None: evcc_disable_peak_shaving = ( self.evcc_api.evcc_is_charging or self.evcc_api.evcc_ev_expects_pv_surplus ) - if evcc_disable_peak_shaving and inverter_settings.limit_battery_charge_rate >= 0: - if self.evcc_api.evcc_is_charging: - logger.debug('[PeakShaving] Disabled: EVCC is actively charging') - else: - logger.debug('[PeakShaving] Disabled: EV connected in PV mode') - inverter_settings.limit_battery_charge_rate = -1 - - # Publish peak shaving charge limit (after EVCC guard may have cleared it) + if evcc_disable_peak_shaving: + if inverter_settings.limit_battery_charge_rate >= 0: + if self.evcc_api.evcc_is_charging: + logger.debug('[PeakShaving] Disabled: evcc is actively charging') + else: + logger.debug('[PeakShaving] Disabled: EV connected in PV mode') + inverter_settings.limit_battery_charge_rate = -1 + else: + if self._evcc_peak_shaving_disabled: + logger.debug('[PeakShaving] Re-enabled: evcc no longer blocking ' + '(EV disconnected or mode changed away from pv)') + self._evcc_peak_shaving_disabled = evcc_disable_peak_shaving + + # Publish peak shaving charge limit (after evcc guard may have cleared it) peak_shaving_enabled = peak_shaving_config.get('enabled', False) if self.mqtt_api is not None and peak_shaving_enabled: self.mqtt_api.publish_peak_shaving_charge_limit( diff --git a/src/batcontrol/dynamictariff/evcc.py b/src/batcontrol/dynamictariff/evcc.py index af8ee025..228e002f 100644 --- a/src/batcontrol/dynamictariff/evcc.py +++ b/src/batcontrol/dynamictariff/evcc.py @@ -35,13 +35,13 @@ class Evcc(DynamicTariffBaseclass): Inherits from DynamicTariffBaseclass Native resolution: 15 minutes - EVCC provides 15-minute price data natively. + evcc provides 15-minute price data natively. Baseclass handles averaging to hourly if target_resolution=60. """ def __init__(self, timezone, url, min_time_between_API_calls=60, target_resolution: int = 60): - # EVCC provides native 15-minute data + # evcc provides native 15-minute data super().__init__( timezone, min_time_between_API_calls, @@ -118,7 +118,7 @@ def _get_prices_native(self) -> dict[int, float]: prices[rel_interval] = price logger.debug( - 'EVCC: Retrieved %d prices at 15-min resolution (hour-aligned)', + 'evcc: Retrieved %d prices at 15-min resolution (hour-aligned)', len(prices) ) return prices diff --git a/src/batcontrol/evcc_api.py b/src/batcontrol/evcc_api.py index 483f19f9..4794d985 100644 --- a/src/batcontrol/evcc_api.py +++ b/src/batcontrol/evcc_api.py @@ -77,8 +77,8 @@ def __init__(self, config: dict): self.evcc_is_charging = False self.evcc_loadpoint_status = {} - self.evcc_loadpoint_mode = {} # topic_root → mode string ("pv", "now", "minpv", "off") - self.evcc_loadpoint_connected = {} # topic_root → bool + self.evcc_loadpoint_mode = {} # topic_root -> mode string ("pv", "now", "minpv", "off") + self.evcc_loadpoint_connected = {} # topic_root -> bool self.list_topics_mode = [] # derived mode topics self.list_topics_connected = [] # derived connected topics @@ -367,7 +367,7 @@ def handle_mode_message(self, message): mode = message.payload.decode('utf-8').strip().lower() old_mode = self.evcc_loadpoint_mode.get(root) if old_mode != mode: - logger.info('Loadpoint %s mode changed: %s → %s', root, old_mode, mode) + logger.info('Loadpoint %s mode changed: %s -> %s', root, old_mode, mode) self.evcc_loadpoint_mode[root] = mode def handle_connected_message(self, message): diff --git a/src/batcontrol/forecastsolar/evcc_solar.py b/src/batcontrol/forecastsolar/evcc_solar.py index e1669f69..3731d05a 100644 --- a/src/batcontrol/forecastsolar/evcc_solar.py +++ b/src/batcontrol/forecastsolar/evcc_solar.py @@ -63,7 +63,7 @@ def __init__(self, pvinstallations, timezone, min_time_between_api_calls, api_de """ super().__init__(pvinstallations, timezone, min_time_between_api_calls, api_delay, target_resolution=target_resolution, - native_resolution=15) # EVCC provides 15-minute data + native_resolution=15) # evcc provides 15-minute data # Extract URL from pvinstallations config if not pvinstallations or not isinstance(pvinstallations, list): diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index a103907b..c22e0363 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -226,7 +226,7 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, - Force-charge from grid active (MODE -1) - Discharge not allowed (battery preserved for high-price hours) - Note: EVCC checks (charging, connected+pv mode) are handled in + Note: evcc checks (charging, connected+pv mode) are handled in core.py, not here. """ mode = self.calculation_parameters.peak_shaving_mode diff --git a/tests/batcontrol/dynamictariff/test_baseclass.py b/tests/batcontrol/dynamictariff/test_baseclass.py index 2456ac1a..56b4284b 100644 --- a/tests/batcontrol/dynamictariff/test_baseclass.py +++ b/tests/batcontrol/dynamictariff/test_baseclass.py @@ -255,23 +255,23 @@ def test_tibber_initialization_15min(self, timezone): class TestEvccProvider: - """Tests for EVCC provider""" + """Tests for evcc provider""" @pytest.fixture def timezone(self): return pytz.timezone('Europe/Berlin') def test_evcc_initialization(self, timezone): - """Test EVCC provider initialization""" + """Test evcc provider initialization""" from batcontrol.dynamictariff.evcc import Evcc provider = Evcc(timezone, 'http://evcc.local/api/tariff/grid', 60, target_resolution=60) - assert provider.native_resolution == 15 # EVCC native is 15-min + assert provider.native_resolution == 15 # evcc native is 15-min assert provider.target_resolution == 60 def test_evcc_15min_target(self, timezone): - """Test EVCC with 15-min target (no conversion needed)""" + """Test evcc with 15-min target (no conversion needed)""" from batcontrol.dynamictariff.evcc import Evcc provider = Evcc(timezone, 'http://evcc.local/api/tariff/grid', 60, diff --git a/tests/batcontrol/dynamictariff/test_evcc.py b/tests/batcontrol/dynamictariff/test_evcc.py index cab0638b..de410b92 100644 --- a/tests/batcontrol/dynamictariff/test_evcc.py +++ b/tests/batcontrol/dynamictariff/test_evcc.py @@ -1,6 +1,6 @@ -"""Tests for EVCC dynamic tariff provider with 15-minute native resolution. +"""Tests for evcc dynamic tariff provider with 15-minute native resolution. -The EVCC provider has native_resolution=15 and returns 15-minute interval prices directly. +The evcc provider has native_resolution=15 and returns 15-minute interval prices directly. Averaging to hourly is done by the baseclass when target_resolution=60. """ import unittest @@ -239,7 +239,7 @@ def test_mixed_granularity_prices(self): self.assertEqual(prices[8], 0.28) def test_native_resolution_is_15min(self): - """Test that EVCC provider has native 15-min resolution""" + """Test that evcc provider has native 15-min resolution""" evcc = Evcc(self.timezone, self.url) self.assertEqual(evcc.native_resolution, 15) diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 32db4b5d..97568487 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -288,7 +288,7 @@ def test_api_set_limit_applies_immediately_in_mode_8( class TestEvccPeakShavingGuard: - """Test EVCC peak shaving guard in core.py run loop.""" + """Test evcc peak shaving guard in core.py run loop.""" @pytest.fixture def mock_config(self): @@ -347,11 +347,11 @@ def _create_bc(self, mock_config, mock_inverter_factory, mock_tariff, def test_evcc_charging_clears_charge_limit( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When EVCC is actively charging, peak shaving charge limit is cleared.""" + """When evcc is actively charging, peak shaving charge limit is cleared.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) - # Simulate EVCC API + # Simulate evcc API mock_evcc = MagicMock() mock_evcc.evcc_is_charging = True mock_evcc.evcc_ev_expects_pv_surplus = False @@ -366,7 +366,7 @@ def test_evcc_charging_clears_charge_limit( limit_battery_charge_rate=500 ) - # Apply the EVCC guard logic (same block as in core.py run loop) + # Apply the evcc guard logic (same block as in core.py run loop) evcc_disable_peak_shaving = ( bc.evcc_api.evcc_is_charging or bc.evcc_api.evcc_ev_expects_pv_surplus @@ -416,7 +416,7 @@ def test_evcc_pv_mode_clears_charge_limit( def test_evcc_not_charging_preserves_charge_limit( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When EVCC is not charging and no PV mode, charge limit is preserved.""" + """When evcc is not charging and no PV mode, charge limit is preserved.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) @@ -449,7 +449,7 @@ def test_evcc_not_charging_preserves_charge_limit( def test_evcc_no_limit_active_no_change( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When no charge limit is active (=-1), EVCC guard doesn't modify it.""" + """When no charge limit is active (=-1), evcc guard doesn't modify it.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) diff --git a/tests/batcontrol/test_evcc_mode.py b/tests/batcontrol/test_evcc_mode.py index 3ea526bc..5f5f1671 100644 --- a/tests/batcontrol/test_evcc_mode.py +++ b/tests/batcontrol/test_evcc_mode.py @@ -1,4 +1,4 @@ -"""Tests for EVCC mode and connected topic handling for peak shaving. +"""Tests for evcc mode and connected topic handling for peak shaving. Tests cover: - Topic derivation from loadpoint /charging topics @@ -23,7 +23,7 @@ def __init__(self, topic: str, payload: bytes): class TestEvccModeConnected(unittest.TestCase): - """Tests for mode/connected EVCC topic handling.""" + """Tests for mode/connected evcc topic handling.""" def _create_evcc_api(self, loadpoint_topics=None): """Create an EvccApi instance with mocked MQTT client.""" From 4fe2ee89510a29ce8556a76d57a52d5a294e6085 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 16:46:54 +0100 Subject: [PATCH 26/35] Iteration 5 , improving architecture --- src/batcontrol/core.py | 90 ++++++++++++++++++------------ tests/batcontrol/test_core.py | 102 ++++++++++++++++------------------ 2 files changed, 104 insertions(+), 88 deletions(-) diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 0371d4a2..311f47d3 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -16,6 +16,9 @@ import logging import platform +from dataclasses import dataclass +from typing import Optional + import pytz import numpy as np @@ -50,6 +53,27 @@ logger = logging.getLogger(__name__) +@dataclass +class PeakShavingConfig: + """ Holds peak shaving configuration parameters, initialized from the config dict. """ + enabled: bool = False + mode: str = 'combined' + allow_full_battery_after: int = 14 + price_limit: Optional[float] = None + + @classmethod + def from_config(cls, config: dict) -> 'PeakShavingConfig': + """ Create a PeakShavingConfig instance from a configuration dict. """ + ps = config.get('peak_shaving', {}) + price_limit_raw = ps.get('price_limit', None) + return cls( + enabled=ps.get('enabled', False), + mode=ps.get('mode', 'combined'), + allow_full_battery_after=ps.get('allow_full_battery_after', 14), + price_limit=float(price_limit_raw) if price_limit_raw is not None else None, + ) + + class Batcontrol: """ Main class for Batcontrol, handles the logic and control of the battery system """ general_logic = None # type: CommonLogic @@ -192,6 +216,8 @@ def __init__(self, configdict: dict): self.batconfig = config['battery_control'] self.time_at_forecast_error = -1 + self.peak_shaving_config = PeakShavingConfig.from_config(config) + self.max_charging_from_grid_limit = self.batconfig.get( 'max_charging_from_grid_limit', 0.8) self.min_price_difference = self.batconfig.get( @@ -519,18 +545,37 @@ def run(self): self.get_stored_usable_energy(), self.get_free_capacity() ) - peak_shaving_config = self.config.get('peak_shaving', {}) + peak_shaving_config_enabled = self.peak_shaving_config.enabled + + if peak_shaving_config_enabled: + # Determine whether evcc conditions require peak shaving to be disabled. + # This must happen before building calc_parameters so the logic never + # runs peak shaving unnecessarily. + evcc_disable_peak_shaving = False + if self.evcc_api is not None: + evcc_disable_peak_shaving = ( + self.evcc_api.evcc_is_charging or + self.evcc_api.evcc_ev_expects_pv_surplus + ) + if evcc_disable_peak_shaving: + if self.evcc_api.evcc_is_charging: + logger.debug('[PeakShaving] Disabled: evcc is actively charging') + else: + logger.debug('[PeakShaving] Disabled: EV connected in PV mode') + elif self._evcc_peak_shaving_disabled: + logger.debug('[PeakShaving] Re-enabled: evcc no longer blocking ' + '(EV disconnected or mode changed away from pv)') + self._evcc_peak_shaving_disabled = evcc_disable_peak_shaving calc_parameters = CalculationParameters( self.max_charging_from_grid_limit, self.min_price_difference, self.min_price_difference_rel, self.get_max_capacity(), - peak_shaving_enabled=peak_shaving_config.get('enabled', False), - peak_shaving_allow_full_after=peak_shaving_config.get( - 'allow_full_battery_after', 14), - peak_shaving_mode=peak_shaving_config.get('mode', 'combined'), - peak_shaving_price_limit=peak_shaving_config.get('price_limit', None), + peak_shaving_enabled=peak_shaving_config_enabled and not evcc_disable_peak_shaving, + peak_shaving_allow_full_after=self.peak_shaving_config.allow_full_battery_after, + peak_shaving_mode=self.peak_shaving_config.mode, + peak_shaving_price_limit=self.peak_shaving_config.price_limit, ) self.last_logic_instance = this_logic_run @@ -558,28 +603,8 @@ def run(self): logger.debug('Discharge blocked due to external lock') inverter_settings.allow_discharge = False - # evcc disables peak shaving (handled in core, not logic) - if self.evcc_api is not None: - evcc_disable_peak_shaving = ( - self.evcc_api.evcc_is_charging or - self.evcc_api.evcc_ev_expects_pv_surplus - ) - if evcc_disable_peak_shaving: - if inverter_settings.limit_battery_charge_rate >= 0: - if self.evcc_api.evcc_is_charging: - logger.debug('[PeakShaving] Disabled: evcc is actively charging') - else: - logger.debug('[PeakShaving] Disabled: EV connected in PV mode') - inverter_settings.limit_battery_charge_rate = -1 - else: - if self._evcc_peak_shaving_disabled: - logger.debug('[PeakShaving] Re-enabled: evcc no longer blocking ' - '(EV disconnected or mode changed away from pv)') - self._evcc_peak_shaving_disabled = evcc_disable_peak_shaving - # Publish peak shaving charge limit (after evcc guard may have cleared it) - peak_shaving_enabled = peak_shaving_config.get('enabled', False) - if self.mqtt_api is not None and peak_shaving_enabled: + if self.mqtt_api is not None and peak_shaving_config_enabled: self.mqtt_api.publish_peak_shaving_charge_limit( inverter_settings.limit_battery_charge_rate) @@ -841,11 +866,10 @@ def refresh_static_values(self) -> None: # self.mqtt_api.publish_discharge_blocked(self.discharge_blocked) # Peak shaving - peak_shaving_config = self.config.get('peak_shaving', {}) self.mqtt_api.publish_peak_shaving_enabled( - peak_shaving_config.get('enabled', False)) + self.peak_shaving_config.enabled) self.mqtt_api.publish_peak_shaving_allow_full_after( - peak_shaving_config.get('allow_full_battery_after', 14)) + self.peak_shaving_config.allow_full_battery_after) # Trigger Inverter self.inverter.refresh_api_values() @@ -982,8 +1006,7 @@ def api_set_peak_shaving_enabled(self, enabled_str: str): """ enabled = enabled_str.strip().lower() in ('true', 'on', '1') logger.info('API: Setting peak shaving enabled to %s', enabled) - peak_shaving = self.config.setdefault('peak_shaving', {}) - peak_shaving['enabled'] = enabled + self.peak_shaving_config.enabled = enabled if self.mqtt_api is not None: self.mqtt_api.publish_peak_shaving_enabled(enabled) @@ -998,7 +1021,6 @@ def api_set_peak_shaving_allow_full_after(self, hour: int): return logger.info( 'API: Setting peak shaving allow_full_battery_after to %d', hour) - peak_shaving = self.config.setdefault('peak_shaving', {}) - peak_shaving['allow_full_battery_after'] = hour + self.peak_shaving_config.allow_full_battery_after = hour if self.mqtt_api is not None: self.mqtt_api.publish_peak_shaving_allow_full_after(hour) diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 97568487..c21775a4 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -344,46 +344,43 @@ def _create_bc(self, mock_config, mock_inverter_factory, mock_tariff, @patch('batcontrol.core.inverter_factory.create_inverter') @patch('batcontrol.core.solar_factory.create_solar_provider') @patch('batcontrol.core.consumption_factory.create_consumption') - def test_evcc_charging_clears_charge_limit( + def test_evcc_charging_disables_peak_shaving_in_calc_params( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When evcc is actively charging, peak shaving charge limit is cleared.""" + """When evcc is actively charging, peak_shaving_enabled is False in calc_params.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) - # Simulate evcc API mock_evcc = MagicMock() mock_evcc.evcc_is_charging = True mock_evcc.evcc_ev_expects_pv_surplus = False bc.evcc_api = mock_evcc - # Simulate inverter_settings with peak shaving limit active - from batcontrol.logic.logic_interface import InverterControlSettings - settings = InverterControlSettings( - allow_discharge=True, - charge_from_grid=False, - charge_rate=0, - limit_battery_charge_rate=500 - ) - - # Apply the evcc guard logic (same block as in core.py run loop) + # Replicate the pre-calculation evcc check from core.py + from batcontrol.logic.logic_interface import CalculationParameters evcc_disable_peak_shaving = ( bc.evcc_api.evcc_is_charging or bc.evcc_api.evcc_ev_expects_pv_surplus ) - if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: - settings.limit_battery_charge_rate = -1 + peak_shaving_config = mock_config.get('peak_shaving', {}) + calc_params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.0, + max_capacity=10000, + peak_shaving_enabled=peak_shaving_config.get('enabled', False) and not evcc_disable_peak_shaving, + ) - assert settings.limit_battery_charge_rate == -1 + assert calc_params.peak_shaving_enabled is False @patch('batcontrol.core.tariff_factory.create_tarif_provider') @patch('batcontrol.core.inverter_factory.create_inverter') @patch('batcontrol.core.solar_factory.create_solar_provider') @patch('batcontrol.core.consumption_factory.create_consumption') - def test_evcc_pv_mode_clears_charge_limit( + def test_evcc_pv_mode_disables_peak_shaving_in_calc_params( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When EV connected in PV mode, peak shaving charge limit is cleared.""" + """When EV connected in PV mode, peak_shaving_enabled is False in calc_params.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) @@ -392,31 +389,30 @@ def test_evcc_pv_mode_clears_charge_limit( mock_evcc.evcc_ev_expects_pv_surplus = True bc.evcc_api = mock_evcc - from batcontrol.logic.logic_interface import InverterControlSettings - settings = InverterControlSettings( - allow_discharge=True, - charge_from_grid=False, - charge_rate=0, - limit_battery_charge_rate=500 - ) - + from batcontrol.logic.logic_interface import CalculationParameters evcc_disable_peak_shaving = ( bc.evcc_api.evcc_is_charging or bc.evcc_api.evcc_ev_expects_pv_surplus ) - if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: - settings.limit_battery_charge_rate = -1 + peak_shaving_config = mock_config.get('peak_shaving', {}) + calc_params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.0, + max_capacity=10000, + peak_shaving_enabled=peak_shaving_config.get('enabled', False) and not evcc_disable_peak_shaving, + ) - assert settings.limit_battery_charge_rate == -1 + assert calc_params.peak_shaving_enabled is False @patch('batcontrol.core.tariff_factory.create_tarif_provider') @patch('batcontrol.core.inverter_factory.create_inverter') @patch('batcontrol.core.solar_factory.create_solar_provider') @patch('batcontrol.core.consumption_factory.create_consumption') - def test_evcc_not_charging_preserves_charge_limit( + def test_evcc_not_active_keeps_peak_shaving_enabled( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When evcc is not charging and no PV mode, charge limit is preserved.""" + """When evcc is not charging and no PV mode, peak_shaving_enabled stays True.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) @@ -425,22 +421,21 @@ def test_evcc_not_charging_preserves_charge_limit( mock_evcc.evcc_ev_expects_pv_surplus = False bc.evcc_api = mock_evcc - from batcontrol.logic.logic_interface import InverterControlSettings - settings = InverterControlSettings( - allow_discharge=True, - charge_from_grid=False, - charge_rate=0, - limit_battery_charge_rate=500 - ) - + from batcontrol.logic.logic_interface import CalculationParameters evcc_disable_peak_shaving = ( bc.evcc_api.evcc_is_charging or bc.evcc_api.evcc_ev_expects_pv_surplus ) - if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: - settings.limit_battery_charge_rate = -1 + peak_shaving_config = mock_config.get('peak_shaving', {}) + calc_params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.0, + max_capacity=10000, + peak_shaving_enabled=peak_shaving_config.get('enabled', False) and not evcc_disable_peak_shaving, + ) - assert settings.limit_battery_charge_rate == 500 + assert calc_params.peak_shaving_enabled is True @patch('batcontrol.core.tariff_factory.create_tarif_provider') @patch('batcontrol.core.inverter_factory.create_inverter') @@ -449,7 +444,7 @@ def test_evcc_not_charging_preserves_charge_limit( def test_evcc_no_limit_active_no_change( self, mock_consumption, mock_solar, mock_inverter_factory, mock_tariff, mock_config): - """When no charge limit is active (=-1), evcc guard doesn't modify it.""" + """When evcc is charging but peak shaving was off in config, it stays disabled.""" bc = self._create_bc(mock_config, mock_inverter_factory, mock_tariff, mock_solar, mock_consumption) @@ -458,22 +453,21 @@ def test_evcc_no_limit_active_no_change( mock_evcc.evcc_ev_expects_pv_surplus = False bc.evcc_api = mock_evcc - from batcontrol.logic.logic_interface import InverterControlSettings - settings = InverterControlSettings( - allow_discharge=True, - charge_from_grid=False, - charge_rate=0, - limit_battery_charge_rate=-1 - ) - + from batcontrol.logic.logic_interface import CalculationParameters evcc_disable_peak_shaving = ( bc.evcc_api.evcc_is_charging or bc.evcc_api.evcc_ev_expects_pv_surplus ) - if evcc_disable_peak_shaving and settings.limit_battery_charge_rate >= 0: - settings.limit_battery_charge_rate = -1 + # Config has enabled=True, but evcc disables it -> result is False + calc_params = CalculationParameters( + max_charging_from_grid_limit=0.8, + min_price_difference=0.05, + min_price_difference_rel=0.0, + max_capacity=10000, + peak_shaving_enabled=False and not evcc_disable_peak_shaving, + ) - assert settings.limit_battery_charge_rate == -1 + assert calc_params.peak_shaving_enabled is False if __name__ == '__main__': From 77936c65d0a7e02fde4f5f2fa061b1ad0b89851c Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 16:52:08 +0100 Subject: [PATCH 27/35] Some more unicode to ascii converts --- scripts/test_evcc.py | 2 +- scripts/verify_production_offset.py | 2 +- src/batcontrol/dynamictariff/baseclass.py | 14 ++++++------ src/batcontrol/dynamictariff/tariffzones.py | 6 ++--- .../forecastconsumption/baseclass.py | 6 ++--- src/batcontrol/forecastsolar/baseclass.py | 8 +++---- src/batcontrol/interval_utils.py | 22 +++++++++---------- src/batcontrol/inverter/fronius.py | 2 +- .../dynamictariff/test_tariffzones.py | 4 ++-- .../forecastsolar/test_baseclass_alignment.py | 4 ++-- tests/batcontrol/logic/test_default.py | 2 +- tests/batcontrol/test_core.py | 2 +- tests/batcontrol/test_evcc_mode.py | 18 +++++++-------- 13 files changed, 46 insertions(+), 46 deletions(-) diff --git a/scripts/test_evcc.py b/scripts/test_evcc.py index e0b8a73c..25256106 100644 --- a/scripts/test_evcc.py +++ b/scripts/test_evcc.py @@ -61,7 +61,7 @@ def main(): print("=" * 50) if prices: for hour, price in sorted(prices.items()): - print(f"Hour +{hour:2d}: {price:.4f} €/kWh") + print(f"Hour +{hour:2d}: {price:.4f} EUR/kWh") else: print("No prices found") diff --git a/scripts/verify_production_offset.py b/scripts/verify_production_offset.py index e225bef6..a22a45d5 100755 --- a/scripts/verify_production_offset.py +++ b/scripts/verify_production_offset.py @@ -107,7 +107,7 @@ def demonstrate_production_offset(): print("=" * 70) print() - print("✓ Production offset feature successfully implemented!") + print("[done] Production offset feature successfully implemented!") print() if __name__ == '__main__': diff --git a/src/batcontrol/dynamictariff/baseclass.py b/src/batcontrol/dynamictariff/baseclass.py index 8af9579b..fede30b9 100644 --- a/src/batcontrol/dynamictariff/baseclass.py +++ b/src/batcontrol/dynamictariff/baseclass.py @@ -7,7 +7,7 @@ Key Design: - Providers declare their native_resolution (15 or 60 minutes) - Baseclass handles automatic upsampling/downsampling -- For prices: replication (hourly→15min) or averaging (15min→hourly) +- For prices: replication (hourly->15min) or averaging (15min->hourly) - Baseclass shifts indices to current-interval alignment """ @@ -31,7 +31,7 @@ class DynamicTariffBaseclass(TariffInterface): Provides automatic resolution handling: - Providers declare their native_resolution (15 or 60 minutes) - Baseclass converts between resolutions automatically - - For prices: uses replication (60→15) or averaging (15→60) + - For prices: uses replication (60->15) or averaging (15->60) - Baseclass shifts indices to current-interval alignment Subclasses must: @@ -154,8 +154,8 @@ def _convert_resolution(self, prices: dict[int, float]) -> dict[int, float]: """Convert prices between resolutions if needed. For prices: - - 60→15: Replicate (same price for all 4 quarters of an hour) - - 15→60: Average (mean of 4 quarters) + - 60->15: Replicate (same price for all 4 quarters of an hour) + - 15->60: Average (mean of 4 quarters) Args: prices: Hour-aligned price data at native resolution @@ -168,16 +168,16 @@ def _convert_resolution(self, prices: dict[int, float]) -> dict[int, float]: if self.native_resolution == 60 and self.target_resolution == 15: logger.debug( - '%s: Replicating hourly prices → 15min (same price per quarter)', + '%s: Replicating hourly prices -> 15min (same price per quarter)', self.__class__.__name__) return self._replicate_hourly_to_15min(prices) if self.native_resolution == 15 and self.target_resolution == 60: - logger.debug('%s: Averaging 15min prices → hourly', + logger.debug('%s: Averaging 15min prices -> hourly', self.__class__.__name__) return average_to_hourly(prices) - logger.error('%s: Cannot convert %d min → %d min', + logger.error('%s: Cannot convert %d min -> %d min', self.__class__.__name__, self.native_resolution, self.target_resolution) diff --git a/src/batcontrol/dynamictariff/tariffzones.py b/src/batcontrol/dynamictariff/tariffzones.py index 8e23b882..abbb9583 100644 --- a/src/batcontrol/dynamictariff/tariffzones.py +++ b/src/batcontrol/dynamictariff/tariffzones.py @@ -85,7 +85,7 @@ def __init__( self.zone_3_hours = zone_3_hours def get_raw_data_from_provider(self) -> dict: - """No external API — configuration is static.""" + """No external API - configuration is static.""" return {} def _validate_configuration(self) -> None: @@ -166,8 +166,8 @@ def _parse_hours(value, name: str) -> list: Accepted formats (may be mixed): - Single integer: 5 - Comma-separated values: "0,1,2,3" - - Inclusive ranges: "0-5" → [0, 1, 2, 3, 4, 5] - - Mixed: "0-5,6,7" → [0, 1, 2, 3, 4, 5, 6, 7] + - Inclusive ranges: "0-5" -> [0, 1, 2, 3, 4, 5] + - Mixed: "0-5,6,7" -> [0, 1, 2, 3, 4, 5, 6, 7] - Python list/tuple of ints or range-strings: [0, '1-3', 4] Raises ValueError if any hour is out of range [0, 23], if a range is diff --git a/src/batcontrol/forecastconsumption/baseclass.py b/src/batcontrol/forecastconsumption/baseclass.py index 79d0afea..1c8ebae5 100644 --- a/src/batcontrol/forecastconsumption/baseclass.py +++ b/src/batcontrol/forecastconsumption/baseclass.py @@ -120,18 +120,18 @@ def _convert_resolution( if self.native_resolution == 60 and self.target_resolution == 15: logger.debug( - '%s: Upsampling 60min → 15min using constant distribution', + '%s: Upsampling 60min -> 15min using constant distribution', self.__class__.__name__) # Use constant distribution for consumption (no interpolation) return upsample_forecast( forecast, target_resolution=15, method='constant') if self.native_resolution == 15 and self.target_resolution == 60: - logger.debug('%s: Downsampling 15min → 60min by summing quarters', + logger.debug('%s: Downsampling 15min -> 60min by summing quarters', self.__class__.__name__) return downsample_to_hourly(forecast) - logger.error('%s: Cannot convert %d min → %d min', + logger.error('%s: Cannot convert %d min -> %d min', self.__class__.__name__, self.native_resolution, self.target_resolution) diff --git a/src/batcontrol/forecastsolar/baseclass.py b/src/batcontrol/forecastsolar/baseclass.py index 5035fb2f..d6c81173 100644 --- a/src/batcontrol/forecastsolar/baseclass.py +++ b/src/batcontrol/forecastsolar/baseclass.py @@ -29,7 +29,7 @@ class ForecastSolarBaseclass(ForecastSolarInterface): Supports Full-Hour Alignment strategy: - Providers return hour-aligned data (index 0 = start of current hour) - - Baseclass handles resolution conversion (hourly ↔ 15-min) + - Baseclass handles resolution conversion (hourly <-> 15-min) - Baseclass shifts indices to current-interval alignment - Core receives data where [0] = current interval """ @@ -177,16 +177,16 @@ def _convert_resolution(self, forecast: dict[int, float]) -> dict[int, float]: return forecast if self.native_resolution == 60 and self.target_resolution == 15: - logger.debug('%s: Upsampling 60min → 15min using linear interpolation', + logger.debug('%s: Upsampling 60min -> 15min using linear interpolation', self.__class__.__name__) return upsample_forecast(forecast, target_resolution=15, method='linear') if self.native_resolution == 15 and self.target_resolution == 60: - logger.debug('%s: Downsampling 15min → 60min by summing quarters', + logger.debug('%s: Downsampling 15min -> 60min by summing quarters', self.__class__.__name__) return downsample_to_hourly(forecast) - logger.error('%s: Cannot convert %d min → %d min', + logger.error('%s: Cannot convert %d min -> %d min', self.__class__.__name__, self.native_resolution, self.target_resolution) diff --git a/src/batcontrol/interval_utils.py b/src/batcontrol/interval_utils.py index 157f4abb..5c46189e 100644 --- a/src/batcontrol/interval_utils.py +++ b/src/batcontrol/interval_utils.py @@ -60,20 +60,20 @@ def _upsample_linear(hourly_forecast: Dict[int, float]) -> Dict[int, float]: - Uses linear power interpolation, then converts to energy Method: - 1. Calculate average power per hour (Wh → W) + 1. Calculate average power per hour (Wh -> W) 2. Interpolate power linearly between hours - 3. Convert interpolated power back to energy (W → Wh for 15 min) + 3. Convert interpolated power back to energy (W -> Wh for 15 min) Example: - Hour 0: 1000 Wh → avg power = 1000 W - Hour 1: 2000 Wh → avg power = 2000 W + Hour 0: 1000 Wh -> avg power = 1000 W + Hour 1: 2000 Wh -> avg power = 2000 W 15-min intervals (linear power ramp): - [0]: Power = 1000 W → Energy = 1000 * 0.25 = 250 Wh - [1]: Power = 1250 W → Energy = 1250 * 0.25 = 312.5 Wh - [2]: Power = 1500 W → Energy = 1500 * 0.25 = 375 Wh - [3]: Power = 1750 W → Energy = 1750 * 0.25 = 437.5 Wh - [4]: Power = 2000 W → Energy = 2000 * 0.25 = 500 Wh (next hour begins) + [0]: Power = 1000 W -> Energy = 1000 * 0.25 = 250 Wh + [1]: Power = 1250 W -> Energy = 1250 * 0.25 = 312.5 Wh + [2]: Power = 1500 W -> Energy = 1500 * 0.25 = 375 Wh + [3]: Power = 1750 W -> Energy = 1750 * 0.25 = 437.5 Wh + [4]: Power = 2000 W -> Energy = 2000 * 0.25 = 500 Wh (next hour begins) """ forecast_15min = {} max_hour = max(hourly_forecast.keys()) @@ -118,8 +118,8 @@ def _upsample_constant(hourly_forecast: Dict[int, float]) -> Dict[int, float]: doesn't make physical sense. Example: - Hour 0: 1000 Wh → 250, 250, 250, 250 Wh per 15 min - Hour 1: 2000 Wh → 500, 500, 500, 500 Wh per 15 min + Hour 0: 1000 Wh -> 250, 250, 250, 250 Wh per 15 min + Hour 1: 2000 Wh -> 500, 500, 500, 500 Wh per 15 min """ forecast_15min = {} diff --git a/src/batcontrol/inverter/fronius.py b/src/batcontrol/inverter/fronius.py index 2a1e39f4..9a15e88d 100644 --- a/src/batcontrol/inverter/fronius.py +++ b/src/batcontrol/inverter/fronius.py @@ -158,7 +158,7 @@ def get_api_config(fw_version: version) -> FroniusApiConfig: if config.from_version <= fw_version < config.to_version: return config raise RuntimeError( - f"Keine API Konfiguration für Firmware-Version {fw_version}") + f"Keine API Konfiguration fuer Firmware-Version {fw_version}") class FroniusWR(InverterBaseclass): diff --git a/tests/batcontrol/dynamictariff/test_tariffzones.py b/tests/batcontrol/dynamictariff/test_tariffzones.py index 05e23213..4f38f8d5 100644 --- a/tests/batcontrol/dynamictariff/test_tariffzones.py +++ b/tests/batcontrol/dynamictariff/test_tariffzones.py @@ -188,7 +188,7 @@ def test_zone3_price_without_hours_raises(): # --------------------------------------------------------------------------- -# _get_prices_native — 2-zone +# _get_prices_native - 2-zone # --------------------------------------------------------------------------- def test_get_prices_native_returns_48_hours(): @@ -210,7 +210,7 @@ def test_get_prices_native_correct_zone_assignment(): # --------------------------------------------------------------------------- -# _get_prices_native — 3-zone +# _get_prices_native - 3-zone # --------------------------------------------------------------------------- def test_get_prices_native_three_zones(): diff --git a/tests/batcontrol/forecastsolar/test_baseclass_alignment.py b/tests/batcontrol/forecastsolar/test_baseclass_alignment.py index 40617b9b..9d681f17 100644 --- a/tests/batcontrol/forecastsolar/test_baseclass_alignment.py +++ b/tests/batcontrol/forecastsolar/test_baseclass_alignment.py @@ -193,7 +193,7 @@ def test_shift_at_20_minutes_15min(self, pvinstallations, timezone): result = provider._shift_to_current_interval(hour_aligned) # At 10:20, current_interval_in_hour = 20//15 = 1 - # Should shift by 1: drop [0], renumber [1]→[0], [2]→[1], etc. + # Should shift by 1: drop [0], renumber [1]->[0], [2]->[1], etc. expected = { 0: 300, # Was [1]: 10:15-10:30 (current) 1: 350, # Was [2]: 10:30-10:45 @@ -226,7 +226,7 @@ def test_shift_at_35_minutes_15min(self, pvinstallations, timezone): result = provider._shift_to_current_interval(hour_aligned) # At 10:35, current_interval_in_hour = 35//15 = 2 - # Should shift by 2: drop [0] and [1], renumber [2]→[0], [3]→[1], etc. + # Should shift by 2: drop [0] and [1], renumber [2]->[0], [3]->[1], etc. expected = { 0: 350, # Was [2]: 10:30-10:45 (current) 1: 400, # Was [3]: 10:45-11:00 diff --git a/tests/batcontrol/logic/test_default.py b/tests/batcontrol/logic/test_default.py index 86f3075c..9d61c1ed 100644 --- a/tests/batcontrol/logic/test_default.py +++ b/tests/batcontrol/logic/test_default.py @@ -60,7 +60,7 @@ def test_calculate_inverter_mode_high_soc(self): calc_input = CalculationInput( consumption=consumption, production=production, - prices={0: 0.25, 1: 0.30, 2: 0.35}, # Example prices in € per kWh + prices={0: 0.25, 1: 0.30, 2: 0.35}, # Example prices in EUR per kWh stored_energy=stored_energy, stored_usable_energy=stored_usable_energy, free_capacity=free_capacity, diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index c21775a4..fbfb7485 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -182,7 +182,7 @@ def test_limit_battery_charge_rate_min_exceeds_max( mock_solar.return_value = MagicMock() mock_consumption.return_value = MagicMock() - # Create Batcontrol instance — misconfiguration is corrected at init + # Create Batcontrol instance - misconfiguration is corrected at init bc = Batcontrol(mock_config) # min_pv_charge_rate should have been clamped to max_pv_charge_rate at init diff --git a/tests/batcontrol/test_evcc_mode.py b/tests/batcontrol/test_evcc_mode.py index 5f5f1671..d04b3e22 100644 --- a/tests/batcontrol/test_evcc_mode.py +++ b/tests/batcontrol/test_evcc_mode.py @@ -50,13 +50,13 @@ def _create_evcc_api(self, loadpoint_topics=None): # ---- Topic derivation ---- def test_topic_derivation_single(self): - """charging topic → mode and connected topics derived.""" + """charging topic -> mode and connected topics derived.""" api = self._create_evcc_api(['evcc/loadpoints/1/charging']) self.assertIn('evcc/loadpoints/1/mode', api.list_topics_mode) self.assertIn('evcc/loadpoints/1/connected', api.list_topics_connected) def test_topic_derivation_multiple(self): - """Multiple loadpoints → all mode/connected topics derived.""" + """Multiple loadpoints -> all mode/connected topics derived.""" api = self._create_evcc_api([ 'evcc/loadpoints/1/charging', 'evcc/loadpoints/2/charging', @@ -68,7 +68,7 @@ def test_topic_derivation_multiple(self): api.list_topics_connected) def test_non_standard_topic_warning(self): - """Topic not ending in /charging → warning, no mode/connected sub.""" + """Topic not ending in /charging -> warning, no mode/connected sub.""" with self.assertLogs('batcontrol.evcc_api', level='WARNING') as cm: api = self._create_evcc_api(['evcc/loadpoints/1/status']) self.assertEqual(len(api.list_topics_mode), 0) @@ -131,33 +131,33 @@ def test_handle_connected_case_insensitive(self): # ---- evcc_ev_expects_pv_surplus ---- def test_expects_pv_surplus_connected_pv_mode(self): - """connected=true + mode=pv → True.""" + """connected=true + mode=pv -> True.""" api = self._create_evcc_api() api.evcc_loadpoint_connected['evcc/loadpoints/1'] = True api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'pv' self.assertTrue(api.evcc_ev_expects_pv_surplus) def test_expects_pv_surplus_connected_now_mode(self): - """connected=true + mode=now → False.""" + """connected=true + mode=now -> False.""" api = self._create_evcc_api() api.evcc_loadpoint_connected['evcc/loadpoints/1'] = True api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'now' self.assertFalse(api.evcc_ev_expects_pv_surplus) def test_expects_pv_surplus_disconnected_pv_mode(self): - """connected=false + mode=pv → False.""" + """connected=false + mode=pv -> False.""" api = self._create_evcc_api() api.evcc_loadpoint_connected['evcc/loadpoints/1'] = False api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'pv' self.assertFalse(api.evcc_ev_expects_pv_surplus) def test_expects_pv_surplus_no_data(self): - """No data received → False.""" + """No data received -> False.""" api = self._create_evcc_api() self.assertFalse(api.evcc_ev_expects_pv_surplus) def test_multi_loadpoint_one_pv(self): - """Multi-loadpoint: one connected+pv is enough → True.""" + """Multi-loadpoint: one connected+pv is enough -> True.""" api = self._create_evcc_api([ 'evcc/loadpoints/1/charging', 'evcc/loadpoints/2/charging', @@ -169,7 +169,7 @@ def test_multi_loadpoint_one_pv(self): self.assertTrue(api.evcc_ev_expects_pv_surplus) def test_mode_change_pv_to_now(self): - """Mode change from pv to now → evcc_ev_expects_pv_surplus changes to False.""" + """Mode change from pv to now -> evcc_ev_expects_pv_surplus changes to False.""" api = self._create_evcc_api() api.evcc_loadpoint_connected['evcc/loadpoints/1'] = True api.evcc_loadpoint_mode['evcc/loadpoints/1'] = 'pv' From c23b38454e56f36c994693f5f5fde8c4d59993f4 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 13 Mar 2026 18:16:51 +0100 Subject: [PATCH 28/35] Fix logging messages for logic type selection and improve error message formatting --- src/batcontrol/logic/logic.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/batcontrol/logic/logic.py b/src/batcontrol/logic/logic.py index 3aa11571..53e06a23 100644 --- a/src/batcontrol/logic/logic.py +++ b/src/batcontrol/logic/logic.py @@ -19,17 +19,17 @@ def create_logic(config: dict, timezone) -> LogicInterface: logic = None if request_type == 'default': if Logic.print_class_message: - logger.info('Using default logic') + logger.info('Using "default" logic') Logic.print_class_message = False logic = DefaultLogic(timezone, interval_minutes=interval_minutes) elif request_type == 'next': if Logic.print_class_message: - logger.info('Using next logic (with peak shaving support)') + logger.info('Using "next" logic (with peak shaving support)') Logic.print_class_message = False logic = NextLogic(timezone, interval_minutes=interval_minutes) else: raise RuntimeError( - f'[Logic] Unknown logic type {request_type}') + f'[Logic] Unknown logic type "{request_type}" specified in configuration') # Apply expert tuning attributes (shared between default and next) if config.get('battery_control_expert', None) is not None: From a3b971cbe57149ecebfe848b6bc85cf6fc2a96ee Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Tue, 17 Mar 2026 15:17:53 +0100 Subject: [PATCH 29/35] Minor comment fix --- src/batcontrol/logic/next.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index c22e0363..8b19bfbb 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -99,7 +99,7 @@ def get_inverter_control_settings(self) -> InverterControlSettings: return self.inverter_control_settings # ------------------------------------------------------------------ # - # Main control logic (same as DefaultLogic) # + # Main control logic (same as DefaultLogic) # # ------------------------------------------------------------------ # def calculate_inverter_mode(self, calc_input: CalculationInput, From a960513fdeb41f9ddb7c44748fd975d200a8e1ec Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Thu, 19 Mar 2026 20:25:07 +0100 Subject: [PATCH 30/35] Fix peak_shaving enable/disable via api --- src/batcontrol/mqtt_api.py | 5 +- tests/batcontrol/test_mqtt_api.py | 132 ++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 tests/batcontrol/test_mqtt_api.py diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py index 422f1bea..7ffa3057 100644 --- a/src/batcontrol/mqtt_api.py +++ b/src/batcontrol/mqtt_api.py @@ -155,8 +155,11 @@ def _handle_message(self, client, userdata, message): # pylint: disable=unused- logger.debug('Received message on %s', message.topic) if message.topic in self.callbacks: try: + payload = message.payload + if isinstance(payload, bytes): + payload = payload.decode('utf-8') self.callbacks[message.topic]['function']( - self.callbacks[message.topic]['convert'](message.payload) + self.callbacks[message.topic]['convert'](payload) ) except (ValueError, TypeError) as e: logger.error('Error in callback %s : %s', message.topic, e) diff --git a/tests/batcontrol/test_mqtt_api.py b/tests/batcontrol/test_mqtt_api.py new file mode 100644 index 00000000..7040f1e0 --- /dev/null +++ b/tests/batcontrol/test_mqtt_api.py @@ -0,0 +1,132 @@ +"""Tests for MqttApi._handle_message, focusing on bytes payload decoding.""" +from unittest.mock import MagicMock + +from batcontrol.mqtt_api import MqttApi + + +def _make_handler_stub(): + """Return a minimal stub that only has the attributes used by _handle_message. + + This avoids having to initialise a full MqttApi (which tries to connect to + MQTT broker, requires a full config, etc.). + """ + stub = MagicMock(spec=MqttApi) + stub.callbacks = {} + # Bind the real _handle_message to our stub + stub._handle_message = MqttApi._handle_message.__get__(stub, MqttApi) + return stub + + +def _make_message(topic: str, payload): + """Create a minimal MQTT message mock.""" + msg = MagicMock() + msg.topic = topic + msg.payload = payload + return msg + + +class TestHandleMessageBytesDecoding: + """_handle_message must decode bytes payloads before calling convert.""" + + def test_str_convert_with_bytes_true(self): + """bytes b'true' must be decoded to 'true', not "b'true'".""" + api = _make_handler_stub() + received = [] + api.callbacks['batcontrol/test/set'] = { + 'function': received.append, + 'convert': str, + } + msg = _make_message('batcontrol/test/set', b'true') + api._handle_message(None, None, msg) + assert received == ['true'] + + def test_str_convert_with_bytes_false(self): + """bytes b'false' must be decoded to 'false'.""" + api = _make_handler_stub() + received = [] + api.callbacks['batcontrol/test/set'] = { + 'function': received.append, + 'convert': str, + } + msg = _make_message('batcontrol/test/set', b'false') + api._handle_message(None, None, msg) + assert received == ['false'] + + def test_float_convert_with_bytes(self): + """float conversion from bytes string still works correctly.""" + api = _make_handler_stub() + received = [] + api.callbacks['batcontrol/test/set'] = { + 'function': received.append, + 'convert': float, + } + msg = _make_message('batcontrol/test/set', b'1.5') + api._handle_message(None, None, msg) + assert received == [1.5] + + def test_int_convert_with_bytes(self): + """int conversion from bytes string still works correctly.""" + api = _make_handler_stub() + received = [] + api.callbacks['batcontrol/test/set'] = { + 'function': received.append, + 'convert': int, + } + msg = _make_message('batcontrol/test/set', b'42') + api._handle_message(None, None, msg) + assert received == [42] + + def test_str_convert_with_plain_string(self): + """Plain string payloads (already decoded) remain unchanged.""" + api = _make_handler_stub() + received = [] + api.callbacks['batcontrol/test/set'] = { + 'function': received.append, + 'convert': str, + } + msg = _make_message('batcontrol/test/set', 'true') + api._handle_message(None, None, msg) + assert received == ['true'] + + +class TestPeakShavingEnabledApi: + """Regression test: peak_shaving/enabled must correctly parse the bytes payload.""" + + def _make_api_with_callback(self): + api = _make_handler_stub() + self.enabled_values = [] + + def fake_set_enabled(enabled_str: str): + enabled = enabled_str.strip().lower() in ('true', 'on', '1') + self.enabled_values.append(enabled) + + topic = 'batcontrol/peak_shaving/enabled/set' + api.callbacks[topic] = { + 'function': fake_set_enabled, + 'convert': str, + } + return api, topic + + def test_bytes_true_sets_enabled(self): + """Sending b'true' via MQTT must enable peak shaving.""" + api, topic = self._make_api_with_callback() + api._handle_message(None, None, _make_message(topic, b'true')) + assert self.enabled_values == [True] + + def test_bytes_false_sets_disabled(self): + """Sending b'false' via MQTT must disable peak shaving.""" + api, topic = self._make_api_with_callback() + api._handle_message(None, None, _make_message(topic, b'false')) + assert self.enabled_values == [False] + + def test_bytes_on_sets_enabled(self): + """Sending b'ON' (HA switch ON) via MQTT must enable peak shaving.""" + api, topic = self._make_api_with_callback() + api._handle_message(None, None, _make_message(topic, b'ON')) + assert self.enabled_values == [True] + + def test_bytes_off_sets_disabled(self): + """Sending b'OFF' (HA switch OFF) via MQTT must disable peak shaving.""" + api, topic = self._make_api_with_callback() + api._handle_message(None, None, _make_message(topic, b'OFF')) + assert self.enabled_values == [False] From 4705dbb837785acc3cde751809dfb3c81d0195eb Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 20 Mar 2026 08:58:08 +0100 Subject: [PATCH 31/35] Implement minimum PV charge rate enforcement for peak shaving logic --- src/batcontrol/logic/common.py | 19 ++++ src/batcontrol/logic/next.py | 4 + tests/batcontrol/logic/test_common.py | 17 ++++ tests/batcontrol/logic/test_peak_shaving.py | 105 ++++++++++++++++++++ 4 files changed, 145 insertions(+) diff --git a/src/batcontrol/logic/common.py b/src/batcontrol/logic/common.py index d8f4ec50..b4fc2e08 100644 --- a/src/batcontrol/logic/common.py +++ b/src/batcontrol/logic/common.py @@ -131,3 +131,22 @@ def calculate_charge_rate(self, charge_rate: float) -> int: adjusted_charge_rate = int(round(adjusted_charge_rate, 0)) logger.debug('Adjusted charge rate: %d W', adjusted_charge_rate) return adjusted_charge_rate + + def enforce_min_pv_charge_rate(self, limit: int) -> int: + """Enforce minimum PV charge rate for peak shaving. + + A positive limit below MIN_CHARGE_RATE is raised to MIN_CHARGE_RATE + to avoid inefficient low-power charging scenarios. + A limit of 0 (block charging entirely) is never raised. + + Args: + limit: Computed PV charge rate limit in W. + Returns: + int: Adjusted limit in W. + """ + if limit > 0 and limit < MIN_CHARGE_RATE: + logger.debug( + '[PeakShaving] Raising PV limit from %d W to minimum charge rate %d W', + limit, MIN_CHARGE_RATE) + return MIN_CHARGE_RATE + return limit diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index 8b19bfbb..f82f13e2 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -278,6 +278,10 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, charge_limit = min(candidates) + # Enforce minimum charge rate: avoid inefficient low-power scenarios. + # 0 is kept as-is (means block charging entirely). + charge_limit = self.common.enforce_min_pv_charge_rate(charge_limit) + # Apply charge rate limit (keep more restrictive if one already exists) if settings.limit_battery_charge_rate < 0: settings.limit_battery_charge_rate = charge_limit diff --git a/tests/batcontrol/logic/test_common.py b/tests/batcontrol/logic/test_common.py index 00bcfcb4..37f6f1a7 100644 --- a/tests/batcontrol/logic/test_common.py +++ b/tests/batcontrol/logic/test_common.py @@ -97,3 +97,20 @@ def test_custom_initialization(self): self.assertEqual(custom_logic.charge_rate_multiplier, 1.2) self.assertEqual(custom_logic.always_allow_discharge_limit, 0.8) self.assertEqual(custom_logic.max_capacity, 12000) + + def test_enforce_min_pv_charge_rate_raises_low_positive(self): + """A positive limit below MIN_CHARGE_RATE must be raised to MIN_CHARGE_RATE.""" + logic = CommonLogic.get_instance() + self.assertEqual(logic.enforce_min_pv_charge_rate(1), MIN_CHARGE_RATE) + self.assertEqual(logic.enforce_min_pv_charge_rate(MIN_CHARGE_RATE - 1), MIN_CHARGE_RATE) + + def test_enforce_min_pv_charge_rate_keeps_high_value(self): + """A limit at or above MIN_CHARGE_RATE must pass through unchanged.""" + logic = CommonLogic.get_instance() + self.assertEqual(logic.enforce_min_pv_charge_rate(MIN_CHARGE_RATE), MIN_CHARGE_RATE) + self.assertEqual(logic.enforce_min_pv_charge_rate(MIN_CHARGE_RATE + 100), MIN_CHARGE_RATE + 100) + + def test_enforce_min_pv_charge_rate_keeps_zero(self): + """A limit of 0 (block charging entirely) must not be raised.""" + logic = CommonLogic.get_instance() + self.assertEqual(logic.enforce_min_pv_charge_rate(0), 0) diff --git a/tests/batcontrol/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py index 80d7dbb6..d13ed726 100644 --- a/tests/batcontrol/logic/test_peak_shaving.py +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -848,3 +848,108 @@ def test_combine_price_and_time_limits_stricter_wins(self): time_lim = logic._calculate_peak_shaving_charge_limit(calc_input, ts) expected = min(x for x in [price_lim, time_lim] if x >= 0) self.assertEqual(result.limit_battery_charge_rate, expected) + + +class TestPeakShavingMinChargeRate(unittest.TestCase): + """Tests for the minimum charge rate enforcement in _apply_peak_shaving. + + The enforcement uses CommonLogic.enforce_min_pv_charge_rate() which applies + the module-level MIN_CHARGE_RATE constant from common.py. + A computed limit of 0 (block charging entirely) must never be raised. + """ + + _MAX_CAPACITY = 10000 # Wh + + def _make_logic(self): + CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=self._MAX_CAPACITY, + ) + logic = NextLogic(timezone=datetime.timezone.utc, interval_minutes=60) + params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=self._MAX_CAPACITY, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=14, + peak_shaving_mode='time', + ) + logic.set_calculation_parameters(params) + return logic + + def _make_input(self, production, consumption, free_capacity, + stored_energy=None): + """Helper - prices unused for time-mode tests. + + stored_energy defaults to half of max_capacity so it stays well below + the always_allow_discharge threshold (90% * 10 000 = 9 000 Wh), + allowing _apply_peak_shaving to proceed without being skipped. + free_capacity and stored_energy are intentionally decoupled here + to isolate the charge-limit computation from the guard check. + """ + if stored_energy is None: + stored_energy = self._MAX_CAPACITY * 0.5 # 5 000 Wh – below gate + n = len(production) + return CalculationInput( + production=np.array(production, dtype=float), + consumption=np.array(consumption, dtype=float), + prices=np.zeros(n), + stored_energy=float(stored_energy), + stored_usable_energy=float(stored_energy), + free_capacity=float(free_capacity), + ) + + def test_low_positive_limit_raised_to_min_charge_rate(self): + """A computed limit below MIN_CHARGE_RATE must be raised to MIN_CHARGE_RATE.""" + from batcontrol.logic.common import MIN_CHARGE_RATE + logic = self._make_logic() + # 8 slots until 14:00 from 06:00, small free capacity (200 Wh) + # -> raw limit = 200 / 8 = 25 W, well below MIN_CHARGE_RATE (500 W) + production = [5000] * 8 + [0] * 4 + consumption = [500] * 8 + [0] * 4 + calc_input = self._make_input(production, consumption, free_capacity=200) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, tzinfo=datetime.timezone.utc) + settings = InverterControlSettings( + allow_discharge=True, charge_from_grid=False, + charge_rate=0, limit_battery_charge_rate=-1) + + result = logic._apply_peak_shaving(settings, calc_input, ts) + + self.assertEqual(result.limit_battery_charge_rate, MIN_CHARGE_RATE) + + def test_limit_above_min_charge_rate_kept_unchanged(self): + """A computed limit already above MIN_CHARGE_RATE must not be altered.""" + from batcontrol.logic.common import MIN_CHARGE_RATE + logic = self._make_logic() + # 8 slots, 5000 Wh free -> raw limit = 5000/8 = 625 W (above 500 W) + production = [5000] * 8 + [0] * 4 + consumption = [500] * 8 + [0] * 4 + calc_input = self._make_input(production, consumption, free_capacity=5000) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, tzinfo=datetime.timezone.utc) + settings = InverterControlSettings( + allow_discharge=True, charge_from_grid=False, + charge_rate=0, limit_battery_charge_rate=-1) + + result = logic._apply_peak_shaving(settings, calc_input, ts) + + self.assertGreater(result.limit_battery_charge_rate, MIN_CHARGE_RATE) + self.assertEqual(result.limit_battery_charge_rate, 625) + + def test_zero_limit_not_raised(self): + """A computed limit of 0 (block charging) must stay 0.""" + logic = self._make_logic() + production = [5000] * 8 + [0] * 4 + consumption = [500] * 8 + [0] * 4 + # free_capacity = 0 -> battery full -> raw limit = 0 + calc_input = self._make_input(production, consumption, free_capacity=0) + ts = datetime.datetime(2025, 6, 20, 6, 0, 0, tzinfo=datetime.timezone.utc) + settings = InverterControlSettings( + allow_discharge=True, charge_from_grid=False, + charge_rate=0, limit_battery_charge_rate=-1) + + result = logic._apply_peak_shaving(settings, calc_input, ts) + + self.assertEqual(result.limit_battery_charge_rate, 0) + From 4bbb3db9172c0bac124db2c6e3bf3e7628b292ca Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 20 Mar 2026 09:10:26 +0100 Subject: [PATCH 32/35] Enhance peak shaving logic to ignore cheap slots beyond production window and ensure valid reserves within it --- src/batcontrol/logic/next.py | 18 ++++++- tests/batcontrol/logic/test_peak_shaving.py | 56 +++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index f82f13e2..7cfc665d 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -305,6 +305,11 @@ def _calculate_peak_shaving_charge_limit_price_based( self, calc_input: CalculationInput) -> int: """Reserve battery free capacity for upcoming cheap-price PV slots. + Only slots within the production window are considered as cheap slots. + The production window ends at the first slot where production is zero; + beyond that there is no PV generation so no capacity needs to be + reserved. + When currently inside a cheap window (first cheap slot == 0): If total PV surplus in the window exceeds free capacity, spread the free capacity evenly over all remaining cheap slots so the @@ -324,10 +329,19 @@ def _calculate_peak_shaving_charge_limit_price_based( prices = calc_input.prices interval_hours = self.interval_minutes / 60.0 + # Limit cheap-slot search to the production window. + # The production window ends at the first slot with zero production; + # beyond that there is no PV generation and no need to reserve capacity. + production_end = len(prices) + for i, prod in enumerate(calc_input.production): + if float(prod) == 0: + production_end = i + break + cheap_slots = [i for i, p in enumerate(prices) - if p is not None and p <= price_limit] + if i < production_end and p is not None and p <= price_limit] if not cheap_slots: - return -1 # No cheap slots in the forecast + return -1 # No cheap slots in the production window first_cheap_slot = cheap_slots[0] diff --git a/tests/batcontrol/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py index d13ed726..1e4fc456 100644 --- a/tests/batcontrol/logic/test_peak_shaving.py +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -849,6 +849,62 @@ def test_combine_price_and_time_limits_stricter_wins(self): expected = min(x for x in [price_lim, time_lim] if x >= 0) self.assertEqual(result.limit_battery_charge_rate, expected) + def test_cheap_slot_beyond_production_window_ignored(self): + """Regression: cheap slots with zero production must NOT trigger a reserve. + + Scenario mirrors the real-world bug message: + [PeakShaving] Price-based: cheap window at slot 27, reserve=4376 Wh ... + + Slot 27 is nighttime (after the production window ends). The prices + array has a cheap slot there, but production is 0 from slot 8 onwards. + The algorithm must ignore all slots at or beyond the first zero-production + slot and therefore find no cheap window -> return -1 (no limit). + """ + # 8 slots with production, then 24 night slots with production=0 + n_day = 8 + n_night = 24 + production = [2000.0] * n_day + [0.0] * n_night + # Make ONLY the night slots cheap; day slots have high prices + prices = [10.0] * n_day + [0.0] * n_night + n = n_day + n_night + calc_input = CalculationInput( + production=np.array(production, dtype=float), + consumption=np.zeros(n, dtype=float), + prices=np.array(prices, dtype=float), + stored_energy=self.max_capacity - 5000, + stored_usable_energy=(self.max_capacity - 5000) * 0.95, + free_capacity=5000, + ) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + self.assertEqual(result, -1, + "Cheap slots after production window end must be ignored") + + def test_cheap_slot_within_production_window_still_triggers_reserve(self): + """After the bug-fix the production-window limit must not suppress legitimate + cheap windows that fall inside the production window. + + Cheap slot 4 has production=3000 W (within the window that ends at slot 8). + The algorithm must still compute a reserve for that slot. + """ + n_day = 8 + n_night = 16 + production = [500.0] * n_day + [0.0] * n_night + prices = [10.0] * 4 + [0.0] + [10.0] * (n_day - 5) + [10.0] * n_night + production[4] = 3000.0 # cheap slot inside production window + n = n_day + n_night + calc_input = CalculationInput( + production=np.array(production, dtype=float), + consumption=np.zeros(n, dtype=float), + prices=np.array(prices, dtype=float), + stored_energy=self.max_capacity - 8000, + stored_usable_energy=(self.max_capacity - 8000) * 0.95, + free_capacity=8000, + ) + result = self.logic._calculate_peak_shaving_charge_limit_price_based(calc_input) + # A limit > 0 must be produced (not -1 and not some invalid value) + self.assertGreater(result, 0, + "Cheap slot inside production window must still trigger a limit") + class TestPeakShavingMinChargeRate(unittest.TestCase): """Tests for the minimum charge rate enforcement in _apply_peak_shaving. From 6decfc71e00fc9c789bc2445f6df3e46d8d01d37 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 20 Mar 2026 09:32:15 +0100 Subject: [PATCH 33/35] Refine peak shaving charge limit calculation to implement counter-linear ramp and update related tests --- src/batcontrol/logic/next.py | 24 +++++++++-- tests/batcontrol/logic/test_peak_shaving.py | 48 ++++++++++++--------- 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index 7cfc665d..89abe945 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -405,7 +405,17 @@ def _calculate_peak_shaving_charge_limit_price_based( def _calculate_peak_shaving_charge_limit(self, calc_input: CalculationInput, calc_timestamp: datetime.datetime) -> int: - """Calculate PV charge rate limit to fill battery by target hour. + """Calculate PV charge rate limit (counter-linear ramp) to fill battery by target hour. + + Assigns weight k+1 to slot k (k=0 = now, k=n-1 = last slot before target), + so the allowed charge rate *increases* linearly as the target hour approaches. + The current slot gets the lowest allocation; the last slot gets the highest. + + Weight of current slot: 1 + Total weight: n*(n+1)/2 + Wh for current slot: free_capacity * 1 / (n*(n+1)/2) + = 2 * free_capacity / (n*(n+1)) + Charge rate [W]: wh_current / interval_hours Returns: int: charge rate limit in W, or -1 if no limit needed. @@ -447,9 +457,15 @@ def _calculate_peak_shaving_charge_limit(self, calc_input: CalculationInput, if free_capacity <= 0: return 0 # Battery is full, block PV charging - # Spread charging evenly across remaining slots - wh_per_slot = free_capacity / slots_remaining - charge_rate_w = wh_per_slot / interval_hours # Wh/slot -> W + # Counter-linear ramp: current slot gets weight 1, last slot before + # target gets weight n. This lifts the charge limit progressively + # as the target hour approaches instead of applying a flat cap. + # Total weight = n*(n+1)/2, so current-slot allocation: + # wh_current = free_capacity * 1 / (n*(n+1)/2) + # = 2 * free_capacity / (n * (n+1)) + n = slots_remaining + wh_current_slot = 2.0 * free_capacity / (n * (n + 1)) + charge_rate_w = wh_current_slot / interval_hours # Wh -> W return int(charge_rate_w) diff --git a/tests/batcontrol/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py index 1e4fc456..333d68ec 100644 --- a/tests/batcontrol/logic/test_peak_shaving.py +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -60,12 +60,13 @@ def _make_input(self, production, consumption, stored_energy, ) def test_high_surplus_small_free_capacity(self): - """High PV surplus, small free capacity -> low charge limit.""" + """High PV surplus, small free capacity -> low charge limit (counter-linear ramp).""" # 8 hours until 14:00 starting from 06:00 # 5000 W PV per slot, 500 W consumption -> 4500 W surplus per slot # 8 slots * 4500 Wh = 36000 Wh surplus total # free_capacity = 2000 Wh - # charge limit = 2000 / 8 = 250 Wh/slot -> 250 W (60 min intervals) + # counter-linear ramp: current slot weight=1, total weight=8*9/2=36 + # wh_current = 2 * 2000 / (8 * 9) = 55.5 Wh -> 55 W (60 min intervals) production = [5000] * 8 + [0] * 4 consumption = [500] * 8 + [0] * 4 calc_input = self._make_input(production, consumption, @@ -75,7 +76,7 @@ def test_high_surplus_small_free_capacity(self): tzinfo=datetime.timezone.utc) limit = self.logic._calculate_peak_shaving_charge_limit( calc_input, ts) - self.assertEqual(limit, 250) + self.assertEqual(limit, 55) def test_low_surplus_large_free_capacity(self): """Low PV surplus, large free capacity -> no limit (-1).""" @@ -154,7 +155,7 @@ def test_consumption_reduces_surplus(self): # 3000 W PV, 2000 W consumption -> 1000 W surplus # 8 slots * 1000 Wh = 8000 Wh surplus # free_capacity = 4000 Wh -> surplus > free - # limit = 4000 / 8 = 500 Wh/slot -> 500 W + # counter-linear ramp: wh_current = 2*4000/(8*9) = 111.1 Wh -> 111 W production = [3000] * 8 + [0] * 4 consumption = [2000] * 8 + [0] * 4 calc_input = self._make_input(production, consumption, @@ -164,10 +165,10 @@ def test_consumption_reduces_surplus(self): tzinfo=datetime.timezone.utc) limit = self.logic._calculate_peak_shaving_charge_limit( calc_input, ts) - self.assertEqual(limit, 500) + self.assertEqual(limit, 111) def test_15min_intervals(self): - """Test with 15-minute intervals.""" + """Test counter-linear ramp with 15-minute intervals.""" logic_15 = NextLogic(timezone=datetime.timezone.utc, interval_minutes=15) logic_15.set_calculation_parameters(self.params) @@ -177,8 +178,9 @@ def test_15min_intervals(self): # surplus Wh per slot = 4000 * 0.25 = 1000 Wh # total surplus = 4 * 1000 = 4000 Wh # free_capacity = 1000 Wh -> surplus > free - # wh_per_slot = 1000 / 4 = 250 Wh - # charge_rate_w = 250 / 0.25 = 1000 W + # counter-linear ramp (n=4, ih=0.25): + # wh_current = 2*1000/(4*5) = 100 Wh + # charge_rate = 100 / 0.25 = 400 W production = [4500] * 4 consumption = [500] * 4 calc_input = self._make_input(production, consumption, @@ -188,7 +190,7 @@ def test_15min_intervals(self): tzinfo=datetime.timezone.utc) limit = logic_15._calculate_peak_shaving_charge_limit( calc_input, ts) - self.assertEqual(limit, 1000) + self.assertEqual(limit, 400) class TestPeakShavingDecision(unittest.TestCase): @@ -287,9 +289,10 @@ def test_always_allow_discharge_region(self): def test_peak_shaving_applies_limit(self): """Before target hour, limit calculated -> limit set.""" settings = self._make_settings() - # 6 slots (6..14), 5000W PV, 500W consumption -> 4500W surplus + # 6 slots (08..14), 5000W PV, 500W consumption -> 4500W surplus # surplus Wh = 6 * 4500 = 27000 > free 3000 - # limit = 3000 / 6 = 500 W + # counter-linear ramp: wh_current = 2*3000/(6*7) = 142.8 Wh -> 142 W + # 142 W < MIN_CHARGE_RATE (500 W) -> enforced to 500 W calc_input = self._make_input( [5000] * 8, [500] * 8, stored_energy=7000, free_capacity=3000) @@ -394,7 +397,7 @@ def test_currently_in_cheap_slot_surplus_overflow(self): ts = datetime.datetime(2025, 6, 20, 8, 0, 0, tzinfo=datetime.timezone.utc) result = self.logic._apply_peak_shaving(settings, calc_input, ts) - self.assertEqual(result.limit_battery_charge_rate, 625) + self.assertEqual(result.limit_battery_charge_rate, 500) def test_mode_time_only_ignores_price_limit(self): """Mode 'time': price_limit=None does not disable peak shaving.""" @@ -415,7 +418,7 @@ def test_mode_time_only_ignores_price_limit(self): ts = datetime.datetime(2025, 6, 20, 8, 0, 0, tzinfo=datetime.timezone.utc) result = self.logic._apply_peak_shaving(settings, calc_input, ts) - # time-based: 6 slots, surplus=6*4500=27000>3000 -> limit=3000/6=500 W + # time-based (n=6): raw = 2*3000/(6*7) = 142 W < MIN_CHARGE_RATE -> enforced to 500 W self.assertEqual(result.limit_battery_charge_rate, 500) def test_mode_price_only_no_time_limit(self): @@ -976,14 +979,19 @@ def test_low_positive_limit_raised_to_min_charge_rate(self): self.assertEqual(result.limit_battery_charge_rate, MIN_CHARGE_RATE) def test_limit_above_min_charge_rate_kept_unchanged(self): - """A computed limit already above MIN_CHARGE_RATE must not be altered.""" + """A computed limit already above MIN_CHARGE_RATE must not be altered. + + Use n=2 (12:00->14:00) with 2000 Wh free: + raw = 2*2000/(2*3) / 1h = 4000/6 = 666.6 -> 666 W > MIN_CHARGE_RATE (500 W). + """ from batcontrol.logic.common import MIN_CHARGE_RATE logic = self._make_logic() - # 8 slots, 5000 Wh free -> raw limit = 5000/8 = 625 W (above 500 W) - production = [5000] * 8 + [0] * 4 - consumption = [500] * 8 + [0] * 4 - calc_input = self._make_input(production, consumption, free_capacity=5000) - ts = datetime.datetime(2025, 6, 20, 6, 0, 0, tzinfo=datetime.timezone.utc) + # 2 slots remaining (12:00 -> 14:00), free_capacity=2000 Wh + # raw counter-linear limit = 2*2000/(2*3) = 666 W > MIN_CHARGE_RATE + production = [5000] * 2 + [0] * 4 + consumption = [500] * 2 + [0] * 4 + calc_input = self._make_input(production, consumption, free_capacity=2000) + ts = datetime.datetime(2025, 6, 20, 12, 0, 0, tzinfo=datetime.timezone.utc) settings = InverterControlSettings( allow_discharge=True, charge_from_grid=False, charge_rate=0, limit_battery_charge_rate=-1) @@ -991,7 +999,7 @@ def test_limit_above_min_charge_rate_kept_unchanged(self): result = logic._apply_peak_shaving(settings, calc_input, ts) self.assertGreater(result.limit_battery_charge_rate, MIN_CHARGE_RATE) - self.assertEqual(result.limit_battery_charge_rate, 625) + self.assertEqual(result.limit_battery_charge_rate, 666) def test_zero_limit_not_raised(self): """A computed limit of 0 (block charging) must stay 0.""" From cd8a196cecf0cdd3fa19626d12d24cc4b5e73a59 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 20 Mar 2026 09:42:27 +0100 Subject: [PATCH 34/35] Enhance peak shaving documentation to clarify counter-linear ramp logic and update limitations --- docs/WIKI_peak_shaving.md | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/WIKI_peak_shaving.md b/docs/WIKI_peak_shaving.md index 648355da..2ba86311 100644 --- a/docs/WIKI_peak_shaving.md +++ b/docs/WIKI_peak_shaving.md @@ -55,27 +55,42 @@ Peak shaving uses one or two components depending on `mode`. The stricter (lower **Component 1: Time-Based** (modes `time` and `combined`) -Spreads remaining free capacity evenly until `allow_full_battery_after`: +Uses a **counter-linear ramp** so the allowed charge rate rises as the target hour approaches. Slot 0 (now) gets the smallest allocation; each later slot gets progressively more, reaching its largest value in the last slot before the target. This mirrors actual PV production curves that rise towards midday. ``` -slots_remaining = slots until allow_full_battery_after -pv_surplus = sum of max(production - consumption, 0) for remaining slots +slots_remaining = n (slots until allow_full_battery_after) +pv_surplus = sum of max(production - consumption, 0) for remaining slots if pv_surplus > free_capacity: - charge_limit = free_capacity / slots_remaining (Wh/slot -> W) + # Weight of current slot = 1, weight of slot k = k+1 + # Total weight = n*(n+1)/2 + wh_current = 2 * free_capacity / (n * (n+1)) + charge_limit = wh_current / interval_hours (Wh -> W) ``` +Example with free_capacity = 2000 Wh and 1 h intervals: + +| Hours to target (n) | Allowed rate | +|---|---| +| 8 | 55 W | +| 4 | 200 W | +| 2 | 666 W | +| 1 | 2000 W (full send) | + **Component 2: Price-Based** (modes `price` and `combined`) +Only slots within the **production window** are considered. The production window ends at the first slot where forecast production is zero; nighttime cheap slots beyond that point are ignored because there is no PV to charge from. This prevents reserving capacity for e.g. a cheap slot at 03:00 that would never produce energy. + Before cheap window - reserves free capacity so cheap-slot PV surplus fills battery completely: ``` -cheap_slots = slots where price <= price_limit +production_end = index of first slot with production = 0 +cheap_slots = slots where price <= price_limit AND slot < production_end target_reserve = min(sum of PV surplus in cheap slots, max_capacity) additional_allowed = free_capacity - target_reserve if additional_allowed <= 0: -> block charging (rate = 0) -else: -> spread additional_allowed over slots before window +else: -> spread additional_allowed evenly over slots before window ``` Inside cheap window - if total PV surplus in the window exceeds free capacity, the battery cannot fully absorb everything. Charging is spread evenly over the cheap slots so the battery fills gradually instead of hitting 100% in the first slot: @@ -143,8 +158,6 @@ The following HA entities are automatically created: ## Known Limitations -1. **Flat charge distribution:** The charge rate limit is uniform across all time slots, but PV production peaks at midday. The battery may not reach exactly 100% by the target hour. - -2. **No intra-day adjustment:** If clouds reduce PV significantly, the limit stays as calculated until the next evaluation cycle (every 3 minutes). The system self-corrects because free capacity stays high, which increases the allowed charge rate. +1. **No intra-day adjustment:** If clouds reduce PV significantly, the limit stays as calculated until the next evaluation cycle (every 3 minutes). The counter-linear ramp self-corrects automatically: high free capacity at the next cycle produces a higher allowed rate. -3. **Code duplication:** `NextLogic` is a copy of `DefaultLogic` with peak shaving added. Once stable, the two could be merged or refactored. +2. **Code duplication:** `NextLogic` is a copy of `DefaultLogic` with peak shaving added. Once stable, the two could be merged or refactored. From 87cf2af8334cd0a652f881f196ea6215d576366b Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Tue, 24 Mar 2026 10:27:04 +0100 Subject: [PATCH 35/35] Add simulate python scripts --- scripts/simulate_peak_shaving_day.py | 407 +++++++++++++++++++++++++ scripts/simulate_peak_shaving_price.py | 365 ++++++++++++++++++++++ 2 files changed, 772 insertions(+) create mode 100644 scripts/simulate_peak_shaving_day.py create mode 100644 scripts/simulate_peak_shaving_price.py diff --git a/scripts/simulate_peak_shaving_day.py b/scripts/simulate_peak_shaving_day.py new file mode 100644 index 00000000..e5192ee2 --- /dev/null +++ b/scripts/simulate_peak_shaving_day.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +"""Day simulation for NextLogic peak shaving. + +Simulates a summer day with a realistic PV production bell-curve. +Runs through each hour slot, calls the peak shaving logic with the +current battery state and remaining forecast, then applies the +resulting charge rate limit to evolve battery SOC. + +Two modes are compared side-by-side: + - Baseline : no peak shaving (battery charges as fast as PV allows) + - Peak Shaving (time mode, target = end of production ~19:00) + +This script answers the question: + "What happens when the target hour is set to the end of production?" + +Usage: + python scripts/simulate_peak_shaving_day.py +""" +import sys +import os +import datetime + +import numpy as np + +# Allow running from project root without installing the package +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from batcontrol.logic.next import NextLogic +from batcontrol.logic.logic_interface import ( + CalculationInput, + CalculationParameters, + InverterControlSettings, +) +from batcontrol.logic.common import CommonLogic + +# --------------------------------------------------------------------------- +# Simulation parameters +# --------------------------------------------------------------------------- +INTERVAL_MIN = 60 # 1-hour slots +INTERVAL_H = INTERVAL_MIN / 60.0 + +MAX_CAPACITY = 10_000 # Wh (10 kWh battery) +MIN_SOC_WH = 500 # Wh (~5 % reserve kept for self consumption) +MAX_SOC_WH = MAX_CAPACITY # charge to 100 % + +CONSUMPTION_W = 400 # W constant house consumption + +# Target hour: 17 +# Adjust this value to explore behaviour with an earlier target (e.g. 14). +TARGET_HOUR = 17 + +PRICE_LIMIT = 0.05 # EUR/kWh – used only for combined/price modes +FLAT_PRICE = 0.30 # EUR/kWh – flat tariff for the whole day + +# Initial battery state (morning, mostly empty) +INITIAL_SOC_WH = 1_500 # Wh + +# --------------------------------------------------------------------------- +# 24-hour PV production profile (W) – typical German summer day +# Entries are average W for each hour 0..23. +# Production ends near 0 at hour 19 (~10 W), full darkness after that. +# --------------------------------------------------------------------------- +PRODUCTION_PROFILE_W = np.array([ + 0, 0, 0, 0, 0, 0, # 00-05 night + 120, 600, 1600, 3100, 4600, 5900, # 06-11 morning / ramp up + 6600, 6100, 5100, 3600, 2100, 900, # 12-17 midday / ramp down + 200, 10, 0, 0, 0, 0, # 18-23 dusk / night +], dtype=float) + +assert len(PRODUCTION_PROFILE_W) == 24, "Profile must have 24 entries" + +# --------------------------------------------------------------------------- +# Production end: first hour where production == 0 *after* sunrise +# (production window for price-based check mirrors the code fix) +# --------------------------------------------------------------------------- +def find_production_end(production_w: np.ndarray) -> int: + """Return the index of the first zero-production slot after sun has risen.""" + sunrise_found = False + for i, p in enumerate(production_w): + if p > 0: + sunrise_found = True + if sunrise_found and p == 0: + return i + return len(production_w) + + +PRODUCTION_END_SLOT = find_production_end(PRODUCTION_PROFILE_W) +print(f"Production window: slots 0..{PRODUCTION_END_SLOT - 1} " + f"(ends at {PRODUCTION_END_SLOT:02d}:00)") +print(f"Target hour : {TARGET_HOUR:02d}:00") +print() + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def build_calc_input(current_hour: int, stored_wh: float) -> CalculationInput: + """Build a CalculationInput for the given hour. + + The arrays are *shifted* so that index 0 = current hour, mirroring what + the real batcontrol system does when it passes the hour-ahead forecast. + """ + remaining = 24 - current_hour + production = PRODUCTION_PROFILE_W[current_hour:].copy() + consumption = np.full(remaining, CONSUMPTION_W, dtype=float) + prices = np.full(remaining, FLAT_PRICE, dtype=float) + free_cap = float(MAX_SOC_WH - stored_wh) + usable = float(max(stored_wh - MIN_SOC_WH, 0)) + return CalculationInput( + production=production, + consumption=consumption, + prices=prices, + stored_energy=float(stored_wh), + stored_usable_energy=usable, + free_capacity=free_cap, + ) + + +def apply_one_hour(production_w: float, consumption_w: float, + charge_limit_w: int, stored_wh: float) -> tuple: + """Simulate one hour of battery operation. + + Returns: + actual_charge_w : W actually charged into battery this hour + actual_feed_in_w : W fed into grid this hour (excess after limit) + new_stored_wh : updated battery energy in Wh + """ + net_surplus_w = production_w - consumption_w # positive = PV excess + + if net_surplus_w <= 0: + # Consuming from battery or grid – peak shaving does not apply + actual_charge_w = 0.0 + actual_feed_in_w = 0.0 + discharge_w = min(-net_surplus_w, stored_wh / INTERVAL_H) + new_stored_wh = stored_wh - discharge_w * INTERVAL_H + else: + # PV surplus available + if charge_limit_w == 0: + # Charging blocked entirely + actual_charge_w = 0.0 + actual_feed_in_w = net_surplus_w + elif charge_limit_w > 0: + # Limit applied + actual_charge_w = min(net_surplus_w, float(charge_limit_w)) + actual_feed_in_w = net_surplus_w - actual_charge_w + else: + # No limit (-1): charge as fast as PV allows + actual_charge_w = net_surplus_w + actual_feed_in_w = 0.0 + + # Clamp to remaining free capacity + free_cap_wh = MAX_SOC_WH - stored_wh + max_charge_this_hour_wh = free_cap_wh + actual_charge_wh = min(actual_charge_w * INTERVAL_H, max_charge_this_hour_wh) + actual_charge_w = actual_charge_wh / INTERVAL_H + actual_feed_in_w = net_surplus_w - actual_charge_w + new_stored_wh = stored_wh + actual_charge_wh + + new_stored_wh = max(min(new_stored_wh, MAX_SOC_WH), 0.0) + return actual_charge_w, actual_feed_in_w, new_stored_wh + + +# --------------------------------------------------------------------------- +# Setup logic instances +# --------------------------------------------------------------------------- +# Reset singleton for clean setup +CommonLogic._instance = None +common = CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=MAX_CAPACITY, +) + +TZ = datetime.timezone.utc +BASE_DATE = datetime.datetime(2026, 6, 21, 0, 0, 0, tzinfo=TZ) # summer solstice + +# Shared parameters for the shaving scenario +params_shaving = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=MAX_CAPACITY, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=TARGET_HOUR, + peak_shaving_mode='time', # pure time-based ramp +) + +# --------------------------------------------------------------------------- +# Run simulations +# --------------------------------------------------------------------------- +ROW_FMT = ( + "{hour:02d}:00 " + "PV:{prod:>5.0f}W " + "SOC_base:{soc_base:>6.0f}Wh({soc_base_pct:>3.0f}%) " + "SOC_shav:{soc_shav:>6.0f}Wh({soc_shav_pct:>3.0f}%) " + "limit:{limit:>6}W " + "charge:{chg:>5.0f}W " + "feed-in:{fi:>5.0f}W" +) + +print("=" * 100) +print(f" Day simulation | Battery {MAX_CAPACITY/1000:.0f} kWh | " + f"Consumption {CONSUMPTION_W} W | Target SOC=100% at {TARGET_HOUR:02d}:00") +print("=" * 100) +print( + "Hour Production SOC-base SOC-shaving " + "PeakShav-Limit PeakShav-Charge PeakShav-FeedIn" +) +print("-" * 100) + +# Start both at the same SOC +soc_base = INITIAL_SOC_WH +soc_shav = INITIAL_SOC_WH + +total_feed_in_base = 0.0 +total_feed_in_shav = 0.0 +total_charged_base = 0.0 +total_charged_shav = 0.0 + +# Build a fresh logic per iteration (it carries state via CommonLogic singleton) +logic_shav = NextLogic(timezone=TZ, interval_minutes=INTERVAL_MIN) +logic_shav.set_calculation_parameters(params_shaving) + +for hour in range(24): + ts = BASE_DATE.replace(hour=hour) + prod_w = float(PRODUCTION_PROFILE_W[hour]) + cons_w = float(CONSUMPTION_W) + + # ---------- Baseline: no limiting ---------- + _, fi_base, soc_base_new = apply_one_hour(prod_w, cons_w, -1, soc_base) + chg_base = (soc_base_new - soc_base) / INTERVAL_H if soc_base_new > soc_base else 0.0 + + # ---------- Peak shaving scenario ---------- + calc_input = build_calc_input(hour, soc_shav) + + # Compute charge limit (time-based ramp). + # Skip peak shaving guard conditions by calling the private method directly + # (mirrors what _apply_peak_shaving does internally). + if prod_w > 0 and hour < TARGET_HOUR: + raw_limit = logic_shav._calculate_peak_shaving_charge_limit(calc_input, ts) + if raw_limit >= 0: + limit_w = common.enforce_min_pv_charge_rate(raw_limit) + else: + limit_w = -1 # no limiting needed + else: + limit_w = -1 # outside active window (night or past target) + + chg_shav, fi_shav, soc_shav_new = apply_one_hour(prod_w, cons_w, limit_w, soc_shav) + + # Accumulate totals + total_feed_in_base += fi_base * INTERVAL_H / 1000 # kWh + total_feed_in_shav += fi_shav * INTERVAL_H / 1000 + total_charged_base += chg_base * INTERVAL_H / 1000 + total_charged_shav += chg_shav * INTERVAL_H / 1000 + + limit_str = f"{limit_w:>5}" if limit_w >= 0 else " N/A" + + print(ROW_FMT.format( + hour=hour, + prod=prod_w, + soc_base=soc_base, + soc_base_pct=soc_base / MAX_CAPACITY * 100, + soc_shav=soc_shav, + soc_shav_pct=soc_shav / MAX_CAPACITY * 100, + limit=limit_str, + chg=chg_shav, + fi=fi_shav, + )) + + soc_base = soc_base_new + soc_shav = soc_shav_new + +print("-" * 100) +print(f" End of day SOC-base: {soc_base:.0f} Wh ({soc_base/MAX_CAPACITY*100:.0f}%) " + f"SOC-shav: {soc_shav:.0f} Wh ({soc_shav/MAX_CAPACITY*100:.0f}%)") +print() +print(f" Total charged (base): {total_charged_base:.2f} kWh") +print(f" Total charged (shaving): {total_charged_shav:.2f} kWh") +print(f" Total feed-in (base): {total_feed_in_base:.2f} kWh") +print(f" Total feed-in (shaving): {total_feed_in_shav:.2f} kWh") +print("=" * 100) +print() + +# --------------------------------------------------------------------------- +# Additional: show the ramp profile for the first slot with surplus +# --------------------------------------------------------------------------- +print("Counter-linear ramp profile from sunrise to target hour") +print(f"(free_capacity at start: {MAX_SOC_WH - INITIAL_SOC_WH:.0f} Wh)") +print() +print(f"{'Slot':>5} {'Time':>6} {'PV (W)':>8} {'n remain':>9} {'raw limit (W)':>14} {'applied (W)':>12}") +print("-" * 65) + +logic_ramp = NextLogic(timezone=TZ, interval_minutes=INTERVAL_MIN) +logic_ramp.set_calculation_parameters(params_shaving) +ramp_soc = INITIAL_SOC_WH + +for hour in range(24): + ts = BASE_DATE.replace(hour=hour) + prod_w = float(PRODUCTION_PROFILE_W[hour]) + if prod_w <= 0 and hour > 12: + break # past production window + + calc_input = build_calc_input(hour, ramp_soc) + n_remain = max(TARGET_HOUR - hour, 0) + if prod_w > 0 and hour < TARGET_HOUR: + raw = logic_ramp._calculate_peak_shaving_charge_limit(calc_input, ts) + applied = common.enforce_min_pv_charge_rate(raw) if raw >= 0 else -1 + else: + raw = -1 + applied = -1 + + raw_s = f"{raw:>14}" if raw >= 0 else " N/A" + applied_s = f"{applied:>12}" if applied >= 0 else " N/A" + + print(f"{hour:>5} {hour:02d}:00 {prod_w:>8.0f} {n_remain:>9} {raw_s} {applied_s}") + + # Evolve SOC for ramp display (unthrottled so free capacity shrinks) + _, _, ramp_soc = apply_one_hour(prod_w, float(CONSUMPTION_W), -1, ramp_soc) + +print() +print("Note: 'N/A' = peak shaving inactive for this slot (night, past target, or no limit needed).") +print(" MIN_CHARGE_RATE = 500 W is applied when raw limit > 0 but below minimum.") + +# --------------------------------------------------------------------------- +# Comparison: target 14 vs target end-of-production +# --------------------------------------------------------------------------- +def run_scenario(target_hour: int, initial_soc_wh: float) -> dict: + """Run a full day simulation and return key metrics.""" + CommonLogic._instance = None + common_local = CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=MAX_CAPACITY, + ) + params = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=MAX_CAPACITY, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=target_hour, + peak_shaving_mode='time', + ) + logic = NextLogic(timezone=TZ, interval_minutes=INTERVAL_MIN) + logic.set_calculation_parameters(params) + + soc = float(initial_soc_wh) + total_charged = 0.0 + total_feed_in = 0.0 + soc_at_target = None + first_full_hour = None + + for hour in range(24): + ts_local = BASE_DATE.replace(hour=hour) + prod_w = float(PRODUCTION_PROFILE_W[hour]) + calc_input = build_calc_input(hour, soc) + + if prod_w > 0 and hour < target_hour: + raw = logic._calculate_peak_shaving_charge_limit(calc_input, ts_local) + lim = common_local.enforce_min_pv_charge_rate(raw) if raw >= 0 else -1 + else: + lim = -1 + + chg_w, fi_w, soc_new = apply_one_hour(prod_w, float(CONSUMPTION_W), lim, soc) + total_charged += chg_w * INTERVAL_H / 1000 + total_feed_in += fi_w * INTERVAL_H / 1000 + + if hour == target_hour: + soc_at_target = soc # SOC at the START of target hour + + if first_full_hour is None and soc_new >= MAX_SOC_WH: + first_full_hour = hour + + soc = soc_new + + return { + 'target_hour' : target_hour, + 'soc_at_target': soc_at_target, + 'soc_end_of_day': soc, + 'first_full_hour': first_full_hour, + 'total_charged_kwh': total_charged, + 'total_feed_in_kwh': total_feed_in, + } + + +# Reset the singleton before comparison runs +print() +print("=" * 70) +print(" Scenario comparison (initial SOC: " + f"{INITIAL_SOC_WH/MAX_CAPACITY*100:.0f}%)") +print("=" * 70) +print(f" {'Target':>14} {'SOC@target':>12} {'First 100%':>12} " + f"{'Charged':>9} {'Feed-in':>9}") +print("-" * 70) + +for t in (14, TARGET_HOUR): + r = run_scenario(t, INITIAL_SOC_WH) + soc_t = r['soc_at_target'] + full_h = r['first_full_hour'] + full_s = f"{full_h:02d}:00" if full_h is not None else "never" + soc_pct = f"{soc_t/MAX_CAPACITY*100:.0f}%" if soc_t is not None else "-" + print(f" {t:02d}:00{' (end of prod)' if t == TARGET_HOUR else ' ':14} " + f"{soc_pct:>12} {full_s:>12} " + f"{r['total_charged_kwh']:>8.2f}k " + f"{r['total_feed_in_kwh']:>8.2f}k") + +print("=" * 70) +print() diff --git a/scripts/simulate_peak_shaving_price.py b/scripts/simulate_peak_shaving_price.py new file mode 100644 index 00000000..8b84c874 --- /dev/null +++ b/scripts/simulate_peak_shaving_price.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""Day simulation for NextLogic price-based peak shaving. + +Simulates a summer day using the **price-based** algorithm only (mode='price'). + +Scenario: + - Realistic PV production bell-curve (same as simulate_peak_shaving_day.py) + - Price curve that dips below PRICE_LIMIT during hours 12-13 (solar surplus + pushes spot prices negative / very low at midday) + - Battery starts at ~15% SOC + - Two traces compared: + Baseline : no peak shaving + PriceShav : mode='price', price_limit applied + +The question answered: + "With a cheap window at 12-13, does the price-based algo reserve capacity + before the window so the battery can fully absorb cheap-slot PV surplus?" + +Usage: + python scripts/simulate_peak_shaving_price.py +""" +import sys +import os +import datetime + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from batcontrol.logic.next import NextLogic +from batcontrol.logic.logic_interface import ( + CalculationInput, + CalculationParameters, +) +from batcontrol.logic.common import CommonLogic + +# --------------------------------------------------------------------------- +# Simulation parameters +# --------------------------------------------------------------------------- +INTERVAL_MIN = 60 +INTERVAL_H = INTERVAL_MIN / 60.0 + +MAX_CAPACITY = 10_000 # Wh (10 kWh battery) +MIN_SOC_WH = 500 # Wh (~5 % floor) +MAX_SOC_WH = MAX_CAPACITY + +CONSUMPTION_W = 400 # W constant house load + +# Price threshold: slots at or below this are the "cheap window" +PRICE_LIMIT = 0.05 # EUR/kWh + +# allow_full_battery_after is required by CalculationParameters even if we +# only use mode='price'. Set it past the production window so it never +# interferes with our price-based scenario. +ALLOW_FULL_AFTER = 23 + +INITIAL_SOC_WH = 1_500 # Wh start at ~15% + +# --------------------------------------------------------------------------- +# 24-hour PV production profile (W) +# --------------------------------------------------------------------------- +PRODUCTION_PROFILE_W = np.array([ + 0, 0, 0, 0, 0, 0, # 00-05 + 120, 600, 1600, 3100, 4600, 5900, # 06-11 + 6600, 6100, 5100, 3600, 2100, 900, # 12-17 + 200, 10, 0, 0, 0, 0, # 18-23 +], dtype=float) + +# --------------------------------------------------------------------------- +# 24-hour price profile (EUR/kWh) +# Normal day-ahead price with a midday dip caused by solar oversupply. +# Hours 12 and 13 are below PRICE_LIMIT (cheap window). +# --------------------------------------------------------------------------- +PRICE_PROFILE = np.array([ + 0.28, 0.26, 0.25, 0.24, 0.25, 0.27, # 00-05 night (low demand) + 0.29, 0.31, 0.33, 0.34, 0.30, 0.18, # 06-11 morning ramp, starts dipping + 0.03, 0.04, 0.12, 0.22, 0.32, 0.38, # 12-17 cheap dip at 12-13, evening rise + 0.35, 0.31, 0.29, 0.28, 0.27, 0.27, # 18-23 evening/night +], dtype=float) + +assert len(PRODUCTION_PROFILE_W) == 24 +assert len(PRICE_PROFILE) == 24 + +CHEAP_HOURS = [h for h, p in enumerate(PRICE_PROFILE) if p <= PRICE_LIMIT] + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def build_calc_input(current_hour: int, stored_wh: float) -> CalculationInput: + """Build a CalculationInput starting at current_hour (slot 0 = now).""" + remaining = 24 - current_hour + production = PRODUCTION_PROFILE_W[current_hour:].copy() + consumption = np.full(remaining, CONSUMPTION_W, dtype=float) + prices = PRICE_PROFILE[current_hour:].copy() + free_cap = float(MAX_SOC_WH - stored_wh) + usable = float(max(stored_wh - MIN_SOC_WH, 0)) + return CalculationInput( + production=production, + consumption=consumption, + prices=prices, + stored_energy=float(stored_wh), + stored_usable_energy=usable, + free_capacity=free_cap, + ) + + +def apply_one_hour(production_w: float, consumption_w: float, + charge_limit_w: int, stored_wh: float) -> tuple: + """Advance battery by one hour. + + Returns (actual_charge_w, actual_feed_in_w, new_stored_wh). + """ + net_surplus_w = production_w - consumption_w + + if net_surplus_w <= 0: + discharge_w = min(-net_surplus_w, stored_wh / INTERVAL_H) + new_stored_wh = stored_wh - discharge_w * INTERVAL_H + return 0.0, 0.0, max(new_stored_wh, 0.0) + + # PV surplus available + if charge_limit_w == 0: + actual_charge_w = 0.0 + elif charge_limit_w > 0: + actual_charge_w = min(net_surplus_w, float(charge_limit_w)) + else: # -1 no limit + actual_charge_w = net_surplus_w + + # Clamp to remaining free capacity + max_charge_wh = MAX_SOC_WH - stored_wh + actual_charge_wh = min(actual_charge_w * INTERVAL_H, max_charge_wh) + actual_charge_w = actual_charge_wh / INTERVAL_H + actual_feed_in_w = net_surplus_w - actual_charge_w + new_stored_wh = min(stored_wh + actual_charge_wh, MAX_SOC_WH) + + return actual_charge_w, actual_feed_in_w, new_stored_wh + + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- +CommonLogic._instance = None +common = CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=MAX_CAPACITY, +) + +TZ = datetime.timezone.utc +BASE_DATE = datetime.datetime(2026, 6, 21, 0, 0, 0, tzinfo=TZ) + +params_price = CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=MAX_CAPACITY, + peak_shaving_enabled=True, + peak_shaving_allow_full_after=ALLOW_FULL_AFTER, + peak_shaving_mode='price', + peak_shaving_price_limit=PRICE_LIMIT, +) + +# --------------------------------------------------------------------------- +# Print header info +# --------------------------------------------------------------------------- +print("=" * 105) +print(f" Price-based peak shaving simulation | Battery {MAX_CAPACITY/1000:.0f} kWh | " + f"Consumption {CONSUMPTION_W} W | Price limit {PRICE_LIMIT} EUR/kWh") +print(f" Cheap window hours: {CHEAP_HOURS} (price <= {PRICE_LIMIT})") +print("=" * 105) +print() + +# --------------------------------------------------------------------------- +# Print price and production overview +# --------------------------------------------------------------------------- +print("Hour-by-hour forecast:") +print(f" {'Hour':>5} {'Price €/kWh':>11} {'PV (W)':>8} {'Surplus (W)':>11} {'<= limit':>8}") +print(" " + "-" * 52) +for h in range(24): + price = PRICE_PROFILE[h] + prod = PRODUCTION_PROFILE_W[h] + surplus = max(prod - CONSUMPTION_W, 0) + cheap_mk = " CHEAP <--" if price <= PRICE_LIMIT else "" + print(f" {h:02d}:00 {price:>11.3f} {prod:>8.0f} {surplus:>11.0f}{cheap_mk}") +print() + +# --------------------------------------------------------------------------- +# Main simulation loop +# --------------------------------------------------------------------------- +logic_shav = NextLogic(timezone=TZ, interval_minutes=INTERVAL_MIN) +logic_shav.set_calculation_parameters(params_price) + +soc_base = float(INITIAL_SOC_WH) +soc_shav = float(INITIAL_SOC_WH) + +total_feed_in_base = 0.0 +total_feed_in_shav = 0.0 +total_charged_base = 0.0 +total_charged_shav = 0.0 +wasted_cheap_base = 0.0 # feed-in during cheap hours (wasted cheap PV) +wasted_cheap_shav = 0.0 + +ROW_FMT = ( + "{hour:02d}:00 {price:>6.3f}€ " + "SOC_base:{soc_base:>6.0f}Wh({soc_base_pct:>3.0f}%) " + "SOC_shav:{soc_shav:>6.0f}Wh({soc_shav_pct:>3.0f}%) " + "limit:{limit:>6}W " + "chg:{chg:>5.0f}W " + "fi:{fi:>5.0f}W" + "{cheap_marker}" +) + +print(f" {'Hour':>5} {'Price':>7} " + f"{'SOC-base':>20} " + f"{'SOC-shaving':>20} " + f"{'PS-Limit':>10} " + f"{'Charge':>9} " + f"{'Feed-in':>9}") +print(" " + "-" * 97) + +for hour in range(24): + ts = BASE_DATE.replace(hour=hour) + prod_w = float(PRODUCTION_PROFILE_W[hour]) + price = float(PRICE_PROFILE[hour]) + is_cheap = price <= PRICE_LIMIT + + # ---- Baseline: no limiting ---- + _, fi_base, soc_base_new = apply_one_hour(prod_w, CONSUMPTION_W, -1, soc_base) + chg_base = (soc_base_new - soc_base) / INTERVAL_H + + # ---- Price-based shaving ---- + calc_input = build_calc_input(hour, soc_shav) + + # Only engage peak shaving when there is PV production + if prod_w > 0: + raw_limit = logic_shav._calculate_peak_shaving_charge_limit_price_based( + calc_input) + if raw_limit >= 0: + limit_w = common.enforce_min_pv_charge_rate(raw_limit) + else: + limit_w = -1 + else: + limit_w = -1 + + chg_shav, fi_shav, soc_shav_new = apply_one_hour(prod_w, CONSUMPTION_W, + limit_w, soc_shav) + + # Accumulate + total_charged_base += chg_base * INTERVAL_H / 1000 + total_charged_shav += chg_shav * INTERVAL_H / 1000 + total_feed_in_base += fi_base * INTERVAL_H / 1000 + total_feed_in_shav += fi_shav * INTERVAL_H / 1000 + if is_cheap: + wasted_cheap_base += fi_base * INTERVAL_H / 1000 + wasted_cheap_shav += fi_shav * INTERVAL_H / 1000 + + limit_str = f"{limit_w:>5}" if limit_w >= 0 else " N/A" + cheap_marker = " << CHEAP" if is_cheap else "" + + print(" " + ROW_FMT.format( + hour=hour, + price=price, + soc_base=soc_base, + soc_base_pct=soc_base / MAX_CAPACITY * 100, + soc_shav=soc_shav, + soc_shav_pct=soc_shav / MAX_CAPACITY * 100, + limit=limit_str, + chg=chg_shav, + fi=fi_shav, + cheap_marker=cheap_marker, + )) + + soc_base = soc_base_new + soc_shav = soc_shav_new + +print(" " + "-" * 97) +print(f" End of day " + f"SOC-base: {soc_base:.0f} Wh ({soc_base/MAX_CAPACITY*100:.0f}%) " + f"SOC-shav: {soc_shav:.0f} Wh ({soc_shav/MAX_CAPACITY*100:.0f}%)") +print() +print(f" Total charged (base): {total_charged_base:.2f} kWh") +print(f" Total charged (shaving): {total_charged_shav:.2f} kWh") +print(f" Total feed-in (base): {total_feed_in_base:.2f} kWh") +print(f" Total feed-in (shaving): {total_feed_in_shav:.2f} kWh") +print(f" Feed-in during cheap hours (base): {wasted_cheap_base:.2f} kWh " + f"(PV surplus at cheap prices that could not be stored)") +print(f" Feed-in during cheap hours (shaving): {wasted_cheap_shav:.2f} kWh") +print("=" * 105) + +# --------------------------------------------------------------------------- +# Per-hour debug: show reserve, allowed, raw limit for each production slot +# --------------------------------------------------------------------------- +print() +print("Pre-window reserve calculation trace (price-based, initial SOC):") +print(f" {'Hour':>5} {'PV (W)':>8} {'Price':>7} " + f"{'FreeCapWh':>10} {'ReserveWh':>10} {'AllowedWh':>10} " + f"{'RawLimit W':>11} {'Applied W':>10}") +print(" " + "-" * 80) + +debug_soc = float(INITIAL_SOC_WH) +debug_logic = NextLogic(timezone=TZ, interval_minutes=INTERVAL_MIN) +debug_logic.set_calculation_parameters(params_price) + +# We need CommonLogic still pointing to the right instance +# (already set above via get_instance) + +for hour in range(24): + ts_d = BASE_DATE.replace(hour=hour) + prod_w = float(PRODUCTION_PROFILE_W[hour]) + price = float(PRICE_PROFILE[hour]) + + calc_input_d = build_calc_input(hour, debug_soc) + free_cap = calc_input_d.free_capacity + + # Compute reserve metrics manually mirroring the implementation + params = debug_logic.calculation_parameters + prices_d = calc_input_d.prices + prod_d = calc_input_d.production + cons_d = calc_input_d.consumption + + # Find production_end in the shifted array + prod_end = len(prices_d) + for i, p in enumerate(prod_d): + if float(p) == 0: + prod_end = i + break + + cheap_slots_d = [i for i, p in enumerate(prices_d) + if i < prod_end and p is not None and p <= PRICE_LIMIT] + surplus_in_cheap = sum( + max(float(prod_d[i]) - float(cons_d[i]), 0) * INTERVAL_H + for i in cheap_slots_d + if i < len(prod_d) and i < len(cons_d) + ) + reserve_wh = min(surplus_in_cheap, MAX_CAPACITY) if cheap_slots_d else 0.0 + allowed_wh = free_cap - reserve_wh + + if prod_w > 0: + raw = debug_logic._calculate_peak_shaving_charge_limit_price_based( + calc_input_d) + applied = common.enforce_min_pv_charge_rate(raw) if raw >= 0 else -1 + else: + raw = None + applied = None + + raw_s = f"{raw:>11}" if raw is not None and raw >= 0 else ( + " 0" if raw == 0 else " N/A") + applied_s = f"{applied:>10}" if applied is not None and applied >= 0 else ( + " 0" if applied == 0 else " N/A") + cheap_mk = " << CHEAP" if price <= PRICE_LIMIT else "" + + print(f" {hour:02d}:00 {prod_w:>8.0f} {price:>7.3f} " + f"{free_cap:>10.0f} {reserve_wh:>10.0f} {allowed_wh:>10.0f} " + f"{raw_s} {applied_s}{cheap_mk}") + + # Advance SOC without limiting (to see reserve shrink as battery fills) + _, _, debug_soc = apply_one_hour(prod_w, CONSUMPTION_W, -1, debug_soc) + +print(" " + "-" * 80) +print() +print("FINDINGS:") +print(f" Before hours {CHEAP_HOURS}: price-based algo sees upcoming cheap-window") +print(f" PV surplus and holds back capacity (reserve_wh). Once free_cap") +print(f" shrinks below reserve_wh the algorithm blocks charging (raw=0).") +print(f" Inside cheap window (hours {CHEAP_HOURS}): surplus spread evenly if it") +print(f" exceeds free capacity; otherwise no limit (-1).")