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. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..9b97a592 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,697 @@ +# 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. + +**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 + +### 1.1 Top-Level `peak_shaving` Section + +```yaml +peak_shaving: + enabled: false + 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 +``` + +**`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 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** 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 + +```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 - 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 + +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) +``` + +**In `on_connect`:** Subscribe to mode and connected topics. + +**In `_handle_message`:** Route to new handlers based on topic matching. + +**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 +``` + +**`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 +- `evcc_ev_expects_pv_surplus` returns `False` when no data received +- Existing `evcc_is_charging` behavior is completely unchanged + +--- + +## 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. + +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 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. + +``` +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 +``` + +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: + +``` +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) +``` + +#### 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. + +### 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. + + 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 - 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_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) +``` + +#### 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. + + 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 + ... + cheap_slots = [i for i, p in enumerate(prices) if p is not None and p <= price_limit] + if not cheap_slots: + return -1 + + first_cheap_slot = cheap_slots[0] + + 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) + + # 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%. + +### 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 Peak Shaving Post-Processing in `NextLogic` + +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()`:** + +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 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'/'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 component needs price_limit configured + if mode in ('price', 'combined') and price_limit is None: + return settings + + # No production right now: skip + if calc_input.production[0] <= 0: + return settings + + # 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 + if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy): + return settings + + # Force charge takes priority + if settings.charge_from_grid: + return settings + + # Battery preserved for high-price hours + if not settings.allow_discharge: + return settings + + # 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] + if not candidates: + return settings + + 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` + +Peak shaving configuration is passed via the existing `CalculationParameters` dataclass (consistent with existing interface pattern): + +```python +@dataclass +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) + # '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(...) + 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. + +--- + +## 4. Logic Factory - `logic.py` + +The factory gains a new type `next`: + +```python +@staticmethod +def create_logic(config: dict, timezone) -> LogicInterface: + 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) + elif request_type == 'next': + logic = NextLogic(timezone, interval_minutes=interval_minutes) + else: + 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 +``` + +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', {}) + +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), +) +``` + +### 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). + +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) +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 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. + +### 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. + +--- + +## 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) + +Settable topics: +- `{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) + +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. + +--- + +## 7. Tests + +### 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 + +**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 + +**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` +- 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 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) +- 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 +8. **Tests** +9. **Documentation** - Write `docs/peak_shaving.md` covering feature overview, configuration, evcc interaction, algorithm explanation, and known limitations + +--- + +## 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/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/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`) + +--- + +## 10. Resolved Design Decisions + +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. + +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. **Interface consistency:** Peak shaving config is passed via `CalculationParameters` (extended with new fields), following the existing pattern. + +5. **High SOC handling:** When battery is in `always_allow_discharge` region, peak shaving is skipped entirely. This avoids toggling at near-full SOC. + +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 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) + +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. diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 6f32975f..f83a60c7 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,29 @@ 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 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. +# Use -1 to disable the price component without changing mode. +# Required for mode 'price' and 'combined'; ignored for mode 'time'. +#-------------------------- +peak_shaving: + enabled: false + 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 # See more Details in: https://github.com/MaStr/batcontrol/wiki/Inverter-Configuration 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 new file mode 100644 index 00000000..2ba86311 --- /dev/null +++ b/docs/WIKI_peak_shaving.md @@ -0,0 +1,163 @@ +# 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 + 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 + +| 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 (Euro/kWh); required for modes `price` and `combined` | + +**`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`. + +**`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 one or two components depending on `mode`. The stricter (lower non-negative) limit wins when both are active. + +**Component 1: Time-Based** (modes `time` and `combined`) + +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 = n (slots until allow_full_battery_after) +pv_surplus = sum of max(production - consumption, 0) for remaining slots + +if pv_surplus > free_capacity: + # 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: + +``` +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 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: + +``` +if total_cheap_surplus > free_capacity: + charge_limit = free_capacity / num_cheap_slots (Wh/slot -> W) +else: + no limit (-1) +``` + +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** 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 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. **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. + +2. **Code duplication:** `NextLogic` is a copy of `DefaultLogic` with peak shaving added. Once stable, the two could be merged or refactored. 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).") 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/core.py b/src/batcontrol/core.py index a21b0592..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 @@ -61,6 +85,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 @@ -191,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( @@ -270,6 +297,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 +545,37 @@ def run(self): self.get_stored_usable_energy(), self.get_free_capacity() ) + 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() + self.get_max_capacity(), + 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 @@ -540,6 +603,11 @@ def run(self): logger.debug('Discharge blocked due to external lock') inverter_settings.allow_discharge = False + # Publish peak shaving charge limit (after evcc guard may have cleared it) + 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) + 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 +865,11 @@ 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 + self.mqtt_api.publish_peak_shaving_enabled( + self.peak_shaving_config.enabled) + self.mqtt_api.publish_peak_shaving_allow_full_after( + self.peak_shaving_config.allow_full_battery_after) # Trigger Inverter self.inverter.refresh_api_values() @@ -926,3 +999,28 @@ 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) + self.peak_shaving_config.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) + 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/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/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/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/evcc_api.py b/src/batcontrol/evcc_api.py index 7ac6341d..4794d985 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/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/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/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/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/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/logic.py b/src/batcontrol/logic/logic.py index 1e2fb068..53e06a23 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,27 +13,36 @@ 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': 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) - 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}" specified in configuration') + + # 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..9febeca2 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 @@ -20,6 +21,37 @@ 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) + # 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): + 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}" + ) + 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 not isinstance(self.peak_shaving_price_limit, (int, float))): + raise ValueError( + f"peak_shaving_price_limit must be numeric or None, " + f"got {type(self.peak_shaving_price_limit).__name__}" + ) @dataclass class CalculationOutput: diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py new file mode 100644 index 00000000..89abe945 --- /dev/null +++ b/src/batcontrol/logic/next.py @@ -0,0 +1,710 @@ +"""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 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'/'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 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 + if calc_input.production[0] <= 0: + return settings + + # 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 + 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 + + # 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 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) + + 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 + + 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 + else: + 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] mode=%s, PV limit: %d W ' + '(price-based=%s W, time-based=%s W, full by %d:00)', + 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) + + 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. + + 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 + battery fills gradually rather than hitting 100% in the first slot. + If surplus <= free capacity no limit is needed. + + 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. + """ + price_limit = self.calculation_parameters.peak_shaving_price_limit + 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 i < production_end and p is not None and p <= price_limit] + if not cheap_slots: + return -1 # No cheap slots in the production window + + first_cheap_slot = cheap_slots[0] + + # -- Currently inside cheap window -------------------------------- # + if first_cheap_slot == 0: + 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) + + # -- 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): + 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: + logger.debug( + '[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 # Wh/slot -> W + + logger.debug( + '[PeakShaving] Price-based: cheap window at slot %d, ' + 'reserve=%.0f Wh, allowed=%.0f Wh -> %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 (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. + """ + 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 + + # 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) + + # ------------------------------------------------------------------ # + # 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..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) @@ -444,6 +447,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 +575,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/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/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_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_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/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py new file mode 100644 index 00000000..333d68ec --- /dev/null +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -0,0 +1,1019 @@ +"""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 (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 + # 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, + 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, 55) + + 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 + # 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, + 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, 111) + + def test_15min_intervals(self): + """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) + + # 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 + # 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, + 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, 400) + + +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, + 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) + + 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): + # 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), + 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 (08..14), 5000W PV, 500W consumption -> 4500W surplus + # surplus Wh = 6 * 4500 = 27000 > free 3000 + # 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) + 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) + + 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) + + def test_price_limit_none_disables_peak_shaving(self): + """price_limit=None with mode='combined' -> 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_mode='combined', + 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): + """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([200] * 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, -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, 500) + + 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 (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): + """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.""" + + 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) + self.assertEqual(params.peak_shaving_mode, 'combined') + + 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, + ) + + 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) + + 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_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_mode='invalid', + ) + + +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_mode='price', + 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_fits_no_limit(self): + """first_cheap_slot = 0, surplus fits in battery -> -1 (no limit).""" + prices = [0, 0, 10, 10] + # 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.""" + 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 (mode='combined'): stricter limit wins. + Setup at 08:00, target 14:00 (6 slots remaining). + High prices except slot 4 (cheap). + """ + 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(params_combined) + + ts = datetime.datetime(2025, 6, 20, 8, 0, 0, tzinfo=datetime.timezone.utc) + 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) + + 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. + + 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. + + 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() + # 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) + + 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, 666) + + 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) + diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 4482b8b4..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 @@ -287,5 +287,188 @@ 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_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_enabled is False in calc_params.""" + 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 + + # 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 + ) + 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 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_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_enabled is False in calc_params.""" + 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 CalculationParameters + evcc_disable_peak_shaving = ( + bc.evcc_api.evcc_is_charging or + bc.evcc_api.evcc_ev_expects_pv_surplus + ) + 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 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_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, peak_shaving_enabled stays True.""" + 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 CalculationParameters + evcc_disable_peak_shaving = ( + bc.evcc_api.evcc_is_charging or + bc.evcc_api.evcc_ev_expects_pv_surplus + ) + 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 calc_params.peak_shaving_enabled is True + + @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 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) + + 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 CalculationParameters + evcc_disable_peak_shaving = ( + bc.evcc_api.evcc_is_charging or + bc.evcc_api.evcc_ev_expects_pv_surplus + ) + # 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 calc_params.peak_shaving_enabled is False + + if __name__ == '__main__': pytest.main([__file__, '-v']) diff --git a/tests/batcontrol/test_evcc_mode.py b/tests/batcontrol/test_evcc_mode.py new file mode 100644 index 00000000..d04b3e22 --- /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() 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]