diff --git a/config/batcontrol_config_dummy.yaml b/config/batcontrol_config_dummy.yaml index 96d12045..8e7ae865 100644 --- a/config/batcontrol_config_dummy.yaml +++ b/config/batcontrol_config_dummy.yaml @@ -41,32 +41,44 @@ battery_control_expert: #-------------------------- # 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. +# Three independent rules manage PV battery charging: spread charging until a target +# hour (time rule), reserve capacity for cheap-price windows (price rule), and absorb +# PV power above the feed-in limit to prevent clipping (solar rule). # 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] +# Rule switches (each independently toggle-able): +# time_active - spread charging until allow_full_battery_after +# price_active - reserve capacity for slots at or below price_limit +# solar_cap_active - absorb PV power above feed_in_limit_w (German 60% rule) # -# 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'. For mode 'combined' it is optional: -# when omitted, combined mode falls back to time-only behaviour and logs -# a warning. Ignored for mode 'time'. +# Priority: all active caps are combined, strictest (minimum) wins. The solar floor +# (charge needed to absorb clipped power) overrides all caps, because clipped energy +# is physically lost and takes precedence over economic optimization. The floor also +# applies after allow_full_battery_after and at high SoC, so the battery may reach +# 100% later than the target hour on clipping days. # -# Runtime control via MQTT is limited to 'enabled' and -# 'allow_full_battery_after'. Changes to 'mode' or 'price_limit' require -# a restart of batcontrol. +# Deprecated: 'mode' parameter (maps to switches at load time, logs a +# deprecation warning). Old syntax is still accepted: time->time_active, +# price->price_active, combined->both. See docs for full details. +# +# Runtime control via MQTT: 'enabled', 'allow_full_battery_after', 'price_limit' +# and the deprecated 'mode' (mapped onto the switches). The rule switches and +# the feed_in_limit_* parameters require a restart. #-------------------------- 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 + time_active: true # target-time rule: spread charging until allow_full_battery_after + price_active: true # price rule: reserve capacity for cheap-price PV windows + solar_cap_active: false # solar feed-in limit rule: absorb PV power above feed_in_limit_w + allow_full_battery_after: 14 # Hour (0-23) - battery should be full by this hour (time rule) + price_limit: 0.05 # Euro/kWh - cheap-slot threshold (price rule); -1 disables the price rule component + feed_in_limit_w: 0 # Watt - grid feed-in power limit (solar rule). 0 = neutral/off. + # German Solarspitzengesetz: 60% of installed power, + # formula: 0.6 * kWp * 1000 (e.g. 6000 for a 10 kWp plant). + feed_in_limit_headroom: 1.0 # Safety factor >= 1.0 on the forecast surplus (solar rule). + # Solar forecasts underestimate clear-day peaks; 1.1 recommended + # if curtailment losses are observed. Do NOT use + # battery_control_expert.production_offset_percent for this. #-------------------------- # Inverter diff --git a/docs/assets/solar_limit_algorithm.png b/docs/assets/solar_limit_algorithm.png new file mode 100644 index 00000000..cf07603c Binary files /dev/null and b/docs/assets/solar_limit_algorithm.png differ diff --git a/docs/assets/solar_limit_clipping.png b/docs/assets/solar_limit_clipping.png new file mode 100644 index 00000000..bdda4854 Binary files /dev/null and b/docs/assets/solar_limit_clipping.png differ diff --git a/docs/assets/solar_limit_headroom.png b/docs/assets/solar_limit_headroom.png new file mode 100644 index 00000000..608930c7 Binary files /dev/null and b/docs/assets/solar_limit_headroom.png differ diff --git a/docs/development/solar-limit-evaluation.md b/docs/development/solar-limit-evaluation.md new file mode 100644 index 00000000..47f9b6a0 --- /dev/null +++ b/docs/development/solar-limit-evaluation.md @@ -0,0 +1,348 @@ +# Evaluation: Solar feed-in limit (Solarspitzengesetz) in peak shaving + +Status: **implemented** — the rule described here ships as `src/batcontrol/logic/solar_limit.py` +plus `_apply_solar_limit()` in `src/batcontrol/logic/next.py`. This page documents the +evaluation that preceded the implementation; its numbers are reproduced by the test suite. +Simulation script: [`scripts/simulate_solar_limit_day.py`](https://github.com/MaStr/batcontrol/blob/main/scripts/simulate_solar_limit_day.py), +figures generated by `scripts/plot_solar_limit_day.py`. + +## Background: the 60% rule + +The German "Solarspitzengesetz" (in force since 2025-02-25) limits uncontrolled PV +plants (no iMSys + control box) to feeding at most **60% of their installed power** +into the grid at the grid connection point (Sec. 9 (2) no. 3 EEG). The inverter +enforces the limit hard: production above it is **curtailed and lost**, unless it is +self-consumed or charged into the battery. + +For a 10 kWp plant this means at most 6,000 W of feed-in. On a clear summer day +peaking at ~8.9 kW, several hours sit above the limit; without countermeasures about +**7.5 kWh are lost** on such a day (see the reference scenario below). + +![The clipping problem: power above the feed-in limit is lost](../assets/solar_limit_clipping.png) + +Important: this is a **power limit, not an energy limit**. The losses concentrate on +the midday peak — exactly the window in which a naively charged battery has long +been full. + +## Terminology + +| Term | Meaning | +|------|---------| +| **clip** | The part of the PV surplus above the feed-in limit — the energy the inverter curtails (loses) unless the battery absorbs it. Red area in the figures. | +| **cap** | An upper limit on the PV-to-battery charge rate (`limit_battery_charge_rate` in W). Today's peak-shaving rules emit caps to *delay* charging. `-1` = no cap, `0` = block charging. | +| **floor** | A lower bound on the allowed charge rate during clip slots: the battery must be *permitted* to charge at least the power above the feed-in limit. With a greedy-charging inverter a floor never forces charging — it only raises the applied cap, and the inverter charges `min(actual surplus, cap)`. | +| **reservation** | Free battery capacity held back before the clip window so it is still available when clipping starts. Implemented as a cap (case A below). | +| **headroom** | A safety factor >= 1.0 multiplied onto the *forecast surplus* before computing the clip. Solar forecasts systematically underestimate peaks; headroom reconstructs the higher real curve so reservation and floor are sized correctly. | + +## Key finding: today's peak shaving can cause curtailment + +The existing peak-shaving rules (`time`/`price`) emit a **cap** on the PV charge +rate. When the PV surplus exceeds the feed-in limit, that cap blocks exactly the +energy that should go into the battery — the difference is curtailed. In the +reference scenario, plain time-based shaving curtails 1.8 kWh that the new rule +recovers completely. + +Conversely, time-based shaving already helps partially (76% recovery vs. 0% +baseline) because it shifts capacity into the afternoon — but uncoordinated and +without any guarantee. + +## Proposed algorithm: the "solar_cap" rule + +The rule works on the existing forecast arrays (Wh per interval, index 0 = now) and +distinguishes two cases. Per slot `k` (up to the end of the production window): + +``` +surplus_wh[k] = max(0, production[k] - consumption[k]) +surplus_hr_wh[k] = surplus_wh[k] * headroom # headroom acts on the surplus +feed_allow_wh[k] = feed_in_limit_w * slot_h[k] +clip_wh[k] = max(0, surplus_hr_wh[k] - feed_allow_wh[k]) +``` + +The "everything fits, no cap needed" check in case B compares the **raw** (not +headroom-adjusted) total surplus against the free capacity — it is a physical +check, not a safety margin. + +**Case A — before the clip window: reservation cap.** +Free capacity minus the predicted clip energy is spread evenly over the slots until +the window starts. If the required reserve exceeds the free capacity, PV charging is +blocked entirely (cap 0). This prevents exportable energy from displacing clip +energy in the battery 1:1. + +**Case B — inside the clip window: floor + capacity-preserving cap.** + +``` +floor_w = clip_wh[0] / slot_h[0] +cap_w = -1 if total surplus <= free capacity + = floor_w + extra_wh / remaining_h otherwise (extra = free cap. - remaining clip) +``` + +Under scarcity (`extra = 0`) the cap equals the floor: the battery absorbs **only** +clip energy; everything below the limit is fed into the grid. No prioritization +inside the window is needed — every absorbed clip Wh has equal value; the only +harmful move is filling capacity with exportable energy. + +![The solar_cap rule on the reference day: reservation cap, floor, SoC comparison](../assets/solar_limit_algorithm.png) + +## Configuration design: one switch per rule + +With three rule flavors (target time, price, solar) the previous `mode` string +(`time`/`price`/`combined`) becomes confusing. Agreed design: **one explicit switch +per rule**; `mode` is deprecated and mapped onto the switches at load time +(`time` -> `time_active`, `price` -> `price_active`, `combined` -> both): + +```yaml +peak_shaving: + enabled: false # master switch (as today, incl. evcc override) + time_active: true # target-time rule (counter-linear ramp) + price_active: false # price rule (reserve for cheap windows) + solar_cap_active: false # NEW: clip absorption (feed-in limit) + allow_full_battery_after: 14 # parameter of the target-time rule + price_limit: 0.05 # parameter of the price rule + feed_in_limit_w: 0 # parameter of the solar rule: feed-in limit in W. + # 0 = neutral (rule has no effect even if + # solar_cap_active is true). Formula: 0.6 * kWp * 1000 + feed_in_limit_headroom: 1.0 # safety factor >= 1.0 on the forecast surplus + # (see terminology and scenario 4b) +``` + +`feed_in_limit_w` is deliberately an **absolute watt value**: the installed power +(kWp) exists in the config only for fcsolar `pvinstallations` (not at all for +Solcast), and the limit applies at the grid connection point of the whole plant. +`0` is the neutral value — in addition to the switch, so an unconfigured limit can +never be misread as "0 W of feed-in allowed". + +### Relation to production_offset_percent + +`battery_control_expert.production_offset_percent` also scales the production +forecast, so the overlap was evaluated. Measured inside the solar rule the two +knobs are indeed equivalent — same effect, same trade-off: + +| Setting (rule view) | Recovery at +25% error | Recovery with correct forecast | +|--------------------------------------|-----------------------:|-------------------------------:| +| `production_offset_percent: 1.25` | 100.0% | 58.7% | +| `feed_in_limit_headroom: 1.25` | 94.7% | 62.7% | + +They differ in **scope**, which is why the rule gets its own key: + +- `production_offset_percent` is applied globally in `core.py` before the forecast + enters the logic. A value > 1 distorts every downstream decision: less grid + recharge is planned, discharge decisions become more generous, time/price caps + engage too early. Its documented purpose is the opposite direction (winter mode + `0.7`, snow, degradation). +- `feed_in_limit_headroom` affects only the solar rule's reservation and floor. + The clipping-relevant forecast error is a *shape* error (underestimated midday + peak on clear days), not a whole-day energy error. + +**Do not use `production_offset_percent` (> 1) to tune clip absorption.** The two +compose cleanly instead: the solar rule consumes the already offset-adjusted +production array, so a winter user at `0.7` automatically gets a conservative +(smaller) clip prediction — harmless, since nothing clips in winter. + +### Priorities between the rule flavors + +Documented, fixed order of precedence (no configuration needed): + +1. **`enabled` (master)** off -> no rule acts (incl. the evcc runtime override). +2. **Force-charge from grid (MODE -1)** overrides all peak shaving (as today). +3. **All active cap rules** (target-time ramp, price reserve, solar reservation) + each emit a limit; the **strictest wins** (`min`, like today's `combined`). +4. **The solar floor overrides every cap**: `final = max(floor, min(caps))`. + Rationale: caps optimize economics (shift charging), the floor prevents + **physical loss** (curtailment). A cap below the floor would destroy energy. + The floor therefore also applies **after** `allow_full_battery_after` and at + high SoC (`always_allow_discharge` region) — the clip window physically lasts + longer than the target hour. Consequence: the solar reservation may let the + battery reach 100% only after the target hour; lost energy weighs more than a + late-full battery. +5. **Static inverter clamps** last (`max_pv_charge_rate` as upper bound, 500 W + minimum via `enforce_min_pv_charge_rate`). Caution: a configured + `max_pv_charge_rate` below the floor makes curtailment physically unavoidable + -> startup warning planned. + +Sentinel semantics stay unchanged: `-1` = no limit, `0` = block charging. `-1` +automatically satisfies every floor because the inverter then charges surplus +greedily anyway — **no new inverter mode** is needed; the floor is the guarantee +`applied cap >= floor`. + +## Simulation results + +All numbers from `scripts/simulate_solar_limit_day.py` (reference: 10 kWp south, +clear summer day, 8.9 kW peak, 6,000 W limit, 10 kWh battery, 400 W base load, +starting SoC 15%, hourly resolution). "Recovery" = share of the energy curtailed +without a battery that is saved. + +### Scenario 1 — reference day + +| Trace | Feed-in | Curtailed | Recovery | +|-----------------------------------|----------:|----------:|----------:| +| Baseline (all rules off) | 40.50 kWh | 7.50 kWh | 0% | +| Only `time_active` (today) | 46.20 kWh | 1.80 kWh | 76.0% | +| Only `solar_cap_active` | 48.00 kWh | 0.00 kWh | **100%** | +| `time_active + solar_cap_active` | 48.00 kWh | 0.00 kWh | **100%** | + +The end-of-day SoC is identical in all traces (83.3%) — the rule gives nothing +away, it only changes **what** the battery is filled with. Visible in the slot +detail: before the window the reservation cap limits charging to 625 W; from 11:00 +the floor lifts the charge rate to exactly the clip power (1,200 -> 2,500 -> 2,400 +-> 1,400 W) while feed-in stays pinned at 6,000 W. In the combined trace the floor +overrides the time-ramp cap exactly when that cap would cause curtailment. + +### Scenario 2 — east-west profile (5.6 kW peak < limit) + +No clipping expected; the rule stays completely inert — trace bit-identical to the +baseline (regression check passed, no false positives). + +### Scenario 3 — small battery (5 kWh, scarcity) + +Free capacity at window start 5.00 kWh, clip potential 7.50 kWh: + +| Trace | Curtailed | Recovery | +|-------------------------|----------:|---------:| +| Baseline | 7.50 kWh | 0% | +| Only `solar_cap_active` | 2.50 kWh | 66.7% | + +Recovered: **5.00 kWh = exactly the free capacity at window start** — the +theoretical maximum. The reservation blocks all morning PV charging (cap 0, feed-in +continues below the limit), inside the window `cap == floor` holds. + +### Scenario 4 — forecast error (forecast = 85% of actual) + +| Trace | Curtailed | Recovery | +|---------------------------|----------:|---------:| +| Baseline | 7.50 kWh | 0% | +| solar, headroom 1.0 | 4.96 kWh | 33.8% | +| solar, headroom 1.2 | 4.46 kWh | 40.6% | +| solar, headroom 1.5 | 4.23 kWh | 43.5% | +| solar, perfect forecast | 0.00 kWh | 100% | + +Findings: (a) the algorithm is clearly forecast-sensitive — a 15% underestimation +of production underestimates the clip disproportionately (the clip is the "tip" of +the curve). (b) Headroom applied to the clip energy improves the reservation only +moderately (+7 points at 1.2), because inside the window the **floor** is also +computed from the too-low forecast. The mitigation plan derived from this is +developed and quantified in scenario 4b. + +### Scenario 4b — severe forecast error (actual = 125% of forecast) + +The forecast sees only **1.36 kWh** of clip potential instead of the real 7.50 kWh +and does not recognize entire clip slots (11:00, 14:00) as such at all — a +multiplier on the predicted clip energy structurally cannot repair that. Since +batcontrol has **no live measurement of the current production**, only +forecast-based mitigations are available; two were implemented and compared: + +- **Headroom on the surplus** (`headroom_on='surplus'`): the factor is applied to + the forecast surplus before the clip computation. This reconstructs an + underestimated production curve and also finds clip slots the raw forecast + misses — it repairs the **reservation** before the window. +- **Headroom floor** (`floor_source='headroom'`): the floor inside the window is + computed from the headroom-corrected instead of the raw clip. With greedy + charging inverters the floor is only a **permission** anyway (the applied cap is + raised, the inverter charges `min(actual surplus, cap)`) — charging that does + not physically exist is never forced. It repairs the **absorption** inside the + window. + +![What headroom does: reconstructing an underestimated forecast](../assets/solar_limit_headroom.png) + +Results under both conditions (actual = 125% of forecast vs. forecast correct): + +| Setting | Recovery at +25% error | Recovery with correct forecast | +|-----------------------------------------|-----------------------:|-------------------------------:| +| headroom 1.25 on clip (raw floor) | 38.7% | — | +| headroom 1.25 on surplus (raw floor) | 31.8% | — | +| surplus 1.1 + headroom floor | 44.9% | 94.5% (loss 0.41 kWh) | +| surplus 1.25 + headroom floor | **94.7%** | 62.7% (loss 2.80 kWh) | +| neutral (headroom 1.0) | 31.8-38.7% | **100%** | + +Key insights: + +1. Both measures are effective **only together**: without the headroom floor the + perfect reservation is worthless (the forecast-based cap blocks charging while + real clipping happens — which is why "surplus alone" is even slightly worse + than "clip alone"); without the surplus headroom the battery is already + pre-filled when the window starts. +2. **Without a live measurement the headroom is a genuine trade-off**: its value + must roughly match the typical forecast error. Too high a value (1.25 with a + correct forecast) charges exportable energy inside the window and displaces + clip energy 1:1 on capacity-scarce days (2.8 kWh loss). Too low a value leaves + clip energy on the table. +3. **1.1 is the robust compromise**: it costs only 0.41 kWh with a correct + forecast and already improves the error case noticeably. + +**Forecast-error plan (settled for the integration, forecast-only):** + +1. **`feed_in_limit_headroom` acts on the forecast surplus** and the **floor is + computed from the headroom-corrected clip** (one shared knob, no second config + key). Default `1.0` (neutral, lossless with a correct forecast); documented + recommendation `1.1`, up to `1.25` for known-pessimistic forecast sources. +2. Document the side effects: headroom > 1 can trigger an unnecessary reservation + on days just below the limit (battery full later, no energy loss) and can + displace a small part of the clip on capacity-scarce clipping days with a + correct forecast (quantified above). +3. The 15-minute resolution (`time_resolution_minutes: 15`) additionally reduces + the systematic part of the error (scenario 6). +4. **Future option** (requires a new data path): a live measurement of the + current production/feed-in would make the floor forecast-independent and + dissolve the trade-off — batcontrol does not capture these values today. + +### Scenario 5 — midday consumption spike (2.4 kW, 12-14h) + +Self-consumption lowers the clip potential to 3.50 kWh; the combination +`time + solar_cap` recovers 100% (baseline 0%, time-only 60%). + +### Scenario 6 — 15-minute resolution + +Consistency check on the interpolated reference day: 99.1% recovery (residual loss +of 0.07 kWh from interpolation edges at slot boundaries). The 15-minute resolution +additionally reduces the systematic "hourly average understates instantaneous +clipping" error. + +## Assessment + +The algorithm meets the requirements: + +1. **It saves the "40%"**: 100% recovery with a correct forecast, exactly the + physical maximum with a scarce battery. +2. **It fixes a defect**: without the floor, the existing peak shaving itself + causes losses on clipping days (1.8 kWh on the reference day). +3. **It is minimally invasive**: no new inverter mode, no new data source, same + sentinel semantics, additive as a post-processing step. +4. **It is neutral when it has nothing to do** (east-west scenario) and fully + disengageable via `feed_in_limit_w: 0` or `solar_cap_active: false`. + +Known limits: forecast sensitivity (scenarios 4/4b) — without a live measurement +of the current production (currently not part of batcontrol) the headroom remains +a trade-off whose value must match the typical forecast error (recommendation +1.1); hourly average vs. instantaneous power (a slot averaging just below the +limit can still clip briefly — partially covered by headroom). + +## Integration roadmap (follow-up step) + +1. `logic/logic_interface.py`: extend `PeakShavingConfig` with `time_active`, + `price_active`, `solar_cap_active`, `feed_in_limit_w` (default 0 = neutral) and + `feed_in_limit_headroom` (default 1.0); deprecate `mode` and map it onto the + switches in `from_config()` (log a warning); validation analogous to + `price_limit`. +2. New `logic/solar_limit.py`: take `compute_solar_limit()` and `merge_limits()` + from the simulation script unchanged (pure functions, pattern: + `grid_charge_target.py`). +3. `logic/next.py`: own post-processing step `_apply_solar_limit()` **after** + `_apply_peak_shaving()` with its own (smaller) skip list: also runs at high SoC + and after `allow_full_battery_after`; still skips on force-charge and on + `allow_discharge == False` (there the inverter charges surplus unrestricted + anyway). Merge according to the priority rule above; + `enforce_min_pv_charge_rate` once on the final merged value. Extract the helper + `_remaining_interval_hours()` (partial slot 0, cf. the grid-recharge block). + `feed_in_limit_headroom` acts on the forecast surplus and the floor uses the + headroom-corrected clip (`headroom_on='surplus'`, `floor_source='headroom'` in + the simulation script; trade-off see scenario 4b). +4. `core.py`: startup warning when `feed_in_limit_w > 0` and + `max_pv_charge_rate > 0`. +5. Tests: `tests/batcontrol/logic/test_solar_limit.py` (pure functions) + + integration cases in `test_peak_shaving.py` (floor overrides cap incl. cap 0, + reservation, scarcity `cap == floor`, neutral value = bit-identical behavior, + sentinels, partial slot 0, 15-min, mode deprecation mapping). +6. `config/batcontrol_config_dummy.yaml` + `docs/features/peak-shaving.md` + + HA add-on mirroring (`MaStr/batcontrol_ha_addon`). +7. Open for the integration: live measurement as floor source for slot 0 (see + scenario 4b); active discharging before the window (deferred, passive + reservation only); MQTT topic `predicted_clip_wh` (read-only, optional). diff --git a/docs/features/peak-shaving.md b/docs/features/peak-shaving.md index 33bccb42..ccdee91c 100644 --- a/docs/features/peak-shaving.md +++ b/docs/features/peak-shaving.md @@ -28,9 +28,13 @@ Add a `peak_shaving` block at the **top level** of your configuration file (not ```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 -- slots at or below this price are "cheap" + time_active: true # target-time rule enabled + price_active: true # price rule enabled + solar_cap_active: false # solar feed-in limit rule (German Solarspitzengesetz) + allow_full_battery_after: 14 # Hour (0-23) -- battery should be full by this hour + price_limit: 0.05 # Euro/kWh -- slots at or below this price are "cheap" + feed_in_limit_w: 0 # Watt -- feed-in power limit (0 = off); formula: 0.6 * kWp * 1000 + feed_in_limit_headroom: 1.0 # Safety factor >= 1.0 (recommended 1.1 if underestimated) ``` ### Parameter Reference @@ -38,20 +42,28 @@ peak_shaving: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `enabled` | bool | `false` | Master switch for peak shaving | -| `mode` | string | `combined` | Algorithm mode (see below) | -| `allow_full_battery_after` | int | `14` | Target hour (0-23) by which the battery should be full | -| `price_limit` | float | *none* | Price threshold in Euro/kWh. Required for modes `price` and `combined` | +| `time_active` | bool | `true` | Enable target-time rule (spread charging until `allow_full_battery_after`) | +| `price_active` | bool | `true` | Enable price rule (reserve capacity for cheap slots) | +| `solar_cap_active` | bool | `false` | Enable solar feed-in limit rule (absorb PV above `feed_in_limit_w`) | +| `allow_full_battery_after` | int | `14` | Target hour (0-23) for the time rule | +| `price_limit` | float | unset (`None`) | Price threshold in Euro/kWh. The price rule only acts when a value is configured (e.g. `0.05`). Set `-1` to disable the price component explicitly. | +| `feed_in_limit_w` | int | `0` | Absolute feed-in power limit in watts (solar rule). Formula: `0.6 * kWp * 1000`. Set to `0` to disable. | +| `feed_in_limit_headroom` | float | `1.0` | Safety factor (>= 1.0) on the forecast surplus (solar rule). Recommended: `1.1` if clipping is observed. | + +**Deprecated:** The old `mode` parameter (`time` / `price` / `combined`) is still accepted for backward compatibility and mapped to the switches at startup; a deprecation warning is logged. New configurations should use the switch-based design above. ### MQTT Runtime Control -All four parameters can be changed at runtime via MQTT without restarting batcontrol: +The following parameters can be changed at runtime via MQTT without restarting batcontrol: | Topic | Accepts | Description | |-------|---------|-------------| | `{base}/peak_shaving/enabled/set` | `true` / `false` | Enable or disable peak shaving | -| `{base}/peak_shaving/allow_full_battery_after/set` | int 0-23 | Change the target hour | -| `{base}/peak_shaving/mode/set` | `time` / `price` / `combined` | Change the algorithm mode | -| `{base}/peak_shaving/price_limit/set` | float | Change the price threshold in EUR/kWh; send `-1` to disable the price component | +| `{base}/peak_shaving/allow_full_battery_after/set` | int 0-23 | Change the target hour for the time rule | +| `{base}/peak_shaving/price_limit/set` | float | Change the price threshold for the price rule | +| `{base}/peak_shaving/mode/set` | `time` / `price` / `combined` | Deprecated: kept for backward compatibility, mapped onto `time_active`/`price_active` | + +The rule switches themselves (`time_active`, `price_active`, `solar_cap_active`) and the solar parameters (`feed_in_limit_w`, `feed_in_limit_headroom`) have no MQTT setters and require restarting batcontrol to take effect. Runtime changes are temporary and are not written back to the configuration file. @@ -64,16 +76,14 @@ This parameter controls when the battery is **allowed** to be 100% full: The target hour applies globally to **all three modes**. Set it to the hour by which your PV system typically produces enough to fill the battery. For many Central European systems `14` (2 PM) is a good starting point; adjust based on your panel orientation and local conditions. -## Modes +## Rule Switches -Peak shaving offers three modes that control which algorithm components are active: +Peak shaving has three independent rules that can be enabled or disabled via the `time_active`, `price_active`, and `solar_cap_active` switches: -### `time` -- Time-Based Only +### `time_active` -- Target-Time Rule Distributes the remaining free battery capacity evenly over the slots between now and `allow_full_battery_after`, using a **counter-linear ramp**. The allowed charge rate starts low and increases as the target hour approaches, which mirrors the typical PV generation curve that rises towards midday. -`price_limit` is **not required** for this mode. - **Formula:** ``` @@ -96,11 +106,9 @@ If pv_surplus > free_capacity: If the expected PV surplus does not exceed the free capacity, no limit is applied -- the battery can absorb everything anyway. -### `price` -- Price-Based Only +### `price_active` -- Price Rule -Reserves free battery capacity for upcoming **cheap-price** slots where PV is still producing. A slot is "cheap" when its price is at or below `price_limit`. - -`price_limit` is **required** for this mode. +Reserves free battery capacity for upcoming **cheap-price** slots where PV is still producing. A slot is "cheap" when its price is at or below `price_limit`. Requires a `price_limit` value (use `-1` to disable without changing the switch). Only slots within the **production window** are considered. The production window ends at the first forecast slot where PV production is zero. This prevents reserving capacity for a cheap slot at e.g. 03:00 that would never produce any solar energy. @@ -114,11 +122,69 @@ Only slots within the **production window** are considered. The production windo - If total PV surplus during cheap slots exceeds free capacity, spread `free_capacity` evenly over cheap slots so the battery fills gradually. - If surplus fits in free capacity, no limit is applied. -### `combined` -- Both Active (Default) +### Combining Rules + +When multiple rules are active, the **strictest (lowest non-negative) limit wins**. For example, if the time rule suggests 500 W and the price rule suggests 300 W, the applied limit is 300 W. This conservative approach prioritizes the rules in combination rather than overriding each other. + +**Backward compatibility:** the old `mode` parameter (`time` / `price` / `combined`) is still accepted and mapped to the switches at startup: +- `mode: time` → `time_active: true`, `price_active: false` +- `mode: price` → `time_active: false`, `price_active: true` +- `mode: combined` → `time_active: true`, `price_active: true` + +New configurations should use the switch-based design. + +## Solar Feed-in Limit (Solarspitzengesetz) + +### The German 60% Rule + +The German "Solarspitzengesetz" (in force since 2025-02-25) limits uncontrolled PV plants to feeding at most **60% of their installed power** into the grid. The inverter enforces this limit hard: production above it is **curtailed and lost**, unless self-consumed or charged into the battery. + +For a 10 kWp plant this means at most 6,000 W of feed-in. On a clear summer day peaking at ~8.9 kW, several hours sit above the limit; without countermeasures about 7.5 kWh of energy are lost just on clipping. -Both the time-based and price-based components run in parallel. The **stricter (lower non-negative) limit wins**. This is the most conservative and generally recommended mode. +![Clipping problem: power above the feed-in limit is curtailed and lost](../assets/solar_limit_clipping.png) + +### How the Solar Cap Rule Works + +The `solar_cap_active` rule reserves battery capacity *before* the predicted clipping window so it can absorb the excess power during the peak. Inside the clipping window, it enforces a minimum charge rate (the "floor") equal to the predicted clip power, allowing the battery to absorb power that would otherwise be curtailed. + +The rule works in two phases: + +**Before clipping starts (reservation):** free battery capacity minus the predicted total clip energy is spread evenly. If the required reserve exceeds free capacity, PV charging is blocked entirely (cap 0). This prevents normal PV power from displacing clip power in the battery. + +**During clipping (floor + absorption):** the battery is required to accept at least the power above the feed-in limit. If free capacity is scarce, the cap equals the floor (absorb *only* clip power, grid feed-in at the limit). Otherwise, the battery can absorb additional surplus below the limit. + +![The solar_cap rule: reservation cap, floor, and SoC comparison](../assets/solar_limit_algorithm.png) + +### Configuration + +Enable the rule via `solar_cap_active: true` and set the feed-in limit: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `solar_cap_active` | bool | `false` | Enable the solar feed-in limit rule | +| `feed_in_limit_w` | int | `0` | Absolute grid feed-in power limit in watts. Formula: `0.6 * kWp * 1000` (e.g., 6000 W for a 10 kWp plant). Set to `0` to disable. | +| `feed_in_limit_headroom` | float | `1.0` | Safety factor >= 1.0 applied to the forecast surplus before computing clip energy. Default `1.0` (neutral, no safety margin); **recommended 1.1** if your solar forecast systematically underestimates production on clear days and you observe curtailment losses. | -`price_limit` is **required** for the price component. If `price_limit` is not set, the price component is disabled and `combined` falls back to **time-only** behaviour — batcontrol logs a warning at startup in this case. Set a numeric `price_limit` or change the mode to `time` to silence the warning. +**Headroom trade-off:** solar forecasts often underestimate midday peaks on clear days. The headroom reconstructs the likely real production curve so reservation and floor are sized correctly. Too low a value leaves clip energy on the table; too high a value wastes capacity on non-clipping days or displaces clip energy on capacity-scarce clipping days. Default `1.0` is lossless with a perfect forecast; `1.1` is the robust compromise and is recommended if you observe losses. + +### Priority Rule: Floor Overrides Caps + +When the solar rule is active alongside other peak-shaving rules, the final charge limit is computed as: + +``` +final_limit = max(solar_floor, min(all_caps)) +``` + +In words: if the solar floor (minimum charge rate needed to absorb clipped power) is higher than the strictest cap from the time or price rules, the floor wins. This is because clipped energy is **physically lost** and outweighs economic optimization. + +**Consequence:** the solar floor also applies **after** the `allow_full_battery_after` target hour and at high battery state-of-charge, so the battery may reach 100% later than the target hour on clipping days. A late-full battery weighs less than lost energy. + +### Limitations and Warnings + +- **Solar forecast sensitivity:** the rule relies on production forecasts, which may underestimate peak production on clear days. The `feed_in_limit_headroom` parameter mitigates this, but a live measurement of current production would be more accurate. +- **Inverter max charge rate:** if your inverter's `max_pv_charge_rate` is below the predicted clip power, some curtailment is physically unavoidable. Batcontrol logs a startup warning when this condition is detected. + +For a detailed evaluation of the algorithm including simulation results and sensitivity analysis, see [Solar Limit Evaluation](../development/solar-limit-evaluation.md). ## Charge Limit and Minimum Charge Rate @@ -134,18 +200,18 @@ The charge limit is published via MQTT: ## When Peak Shaving is Skipped -Peak shaving is automatically bypassed in the following situations: +Peak shaving cap rules (time and price) are automatically bypassed in the following situations. However, **the solar floor always applies during predicted clipping** even after `allow_full_battery_after` and in the high-SOC region, because clipped energy is physically lost: -| Condition | Reason | -|-----------|--------| -| No PV production (nighttime) | Nothing to limit | -| Past `allow_full_battery_after` hour | Target reached, charge freely | -| Battery in `always_allow_discharge` region (high SOC) | Battery is nearly full anyway | -| Force-charge from grid active (Mode -1) | Grid charging takes priority | -| Discharge not allowed | Battery is being preserved for expensive hours -- limiting PV would be counterproductive | -| evcc is actively charging the EV | The EV already consumes excess PV | -| EV connected in PV mode (evcc) | evcc will absorb surplus PV when its threshold is reached | -| `price_limit` not configured | Price component cannot operate; `combined` falls back to time-only, `price` is effectively inactive | +| Condition | Time/Price Caps | Solar Floor | +|-----------|--------|--------| +| No PV production (nighttime) | Bypassed | Not applied | +| Past `allow_full_battery_after` hour | Bypassed | Still applies (if clipping predicted) | +| Battery in `always_allow_discharge` region (high SOC) | Bypassed | Still applies (if clipping predicted) | +| Force-charge from grid active (Mode -1) | Bypassed | Not applied | +| Discharge not allowed (battery preserved) | Bypassed | Not applied (no charge cap is active in this state, the inverter charges all surplus anyway) | +| evcc is actively charging the EV | Bypassed | Not applied | +| EV connected in PV mode (evcc) | Bypassed | Not applied | +| `price_limit` not configured | Price rule inactive | Not affected | ## evcc Interaction @@ -171,7 +237,7 @@ The charge limit is recalculated every evaluation cycle (typically every 3 minut ## Quick-Start Examples -**Simple time-based setup** -- spread charging until 14:00, no price awareness: +**Simple time-based setup** -- spread charging until 14:00, no price or solar awareness: ```yaml battery_control: @@ -179,7 +245,9 @@ battery_control: peak_shaving: enabled: true - mode: time + time_active: true + price_active: false + solar_cap_active: false allow_full_battery_after: 14 ``` @@ -191,7 +259,26 @@ battery_control: peak_shaving: enabled: true - mode: combined + time_active: true + price_active: true + solar_cap_active: false + allow_full_battery_after: 14 + price_limit: 0.05 +``` + +**With solar feed-in limit** -- add clipping absorption for a 10 kWp plant (6000 W limit): + +```yaml +battery_control: + type: next + +peak_shaving: + enabled: true + time_active: true + price_active: true + solar_cap_active: true allow_full_battery_after: 14 price_limit: 0.05 + feed_in_limit_w: 6000 + feed_in_limit_headroom: 1.1 ``` diff --git a/mkdocs.yml b/mkdocs.yml index 1bab3703..2e507461 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,6 +79,7 @@ plugins: - integrations/forecast-metrics.md: Forecast data exposed via MQTT Optional: - development/15-min-transform.md: Internal 15-minute interval resolution + - development/solar-limit-evaluation.md: Solar feed-in limit (Solarspitzengesetz) evaluation nav: - Home: index.md @@ -102,6 +103,7 @@ nav: - evcc Connection: integrations/evcc-connection.md - Development: - 15-Minute Interval Transformation: development/15-min-transform.md + - Solar Feed-in Limit Evaluation: development/solar-limit-evaluation.md validation: links: diff --git a/scripts/README.md b/scripts/README.md index 604ea81f..1b17f432 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -13,6 +13,43 @@ The `scripts` folder is separate from the `tests` folder to avoid interference w ## Available Scripts +### simulate_solar_limit_day.py + +Day simulation for the proposed solar feed-in limit rule (Solarspitzengesetz, +60% feed-in cap for uncontrolled PV plants). Evaluates the "solar_cap" peak +shaving rule: reserve battery capacity before the predicted clipping window +and enforce a charge floor during it so the battery absorbs energy the +inverter would otherwise curtail. + +**Usage:** +```bash +python scripts/simulate_solar_limit_day.py +``` + +**Features:** +- Six scenarios: reference summer day, east-west profile, small battery, + forecast error with headroom sweep, midday consumption spike, 15-min interval +- Compares baseline, legacy time-based peak shaving, and the new rule +- Prints curtailed/feed-in energy, end SoC and clip-recovery percentage +- Contains a standalone reference copy of the algorithm (`compute_solar_limit`, + `merge_limits`); the authoritative production implementation lives in + `src/batcontrol/logic/solar_limit.py` + +See `docs/development/solar-limit-evaluation.md` for results and design. + +### plot_solar_limit_day.py + +Generates the figures for `docs/development/solar-limit-evaluation.md` into +`docs/assets/` (clipping concept, algorithm behaviour on the reference day, +headroom explainer). Imports profiles and the reference algorithm from +`simulate_solar_limit_day.py`. + +**Usage:** +```bash +uv pip install matplotlib # not part of the project dependencies +python scripts/plot_solar_limit_day.py +``` + ### test_evcc.py Standalone test script for the evcc dynamic tariff module. diff --git a/scripts/plot_solar_limit_day.py b/scripts/plot_solar_limit_day.py new file mode 100644 index 00000000..80d6e762 --- /dev/null +++ b/scripts/plot_solar_limit_day.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Generate the figures for docs/development/solar-limit-evaluation.md. + +Renders three PNGs into docs/assets/ visualizing the solar feed-in limit +(Solarspitzengesetz) evaluation: + + solar_limit_clipping.png - the problem: energy above the feed-in limit + is curtailed unless the battery absorbs it + solar_limit_algorithm.png - reservation cap (case A) and charge floor + (case B) on the reference day, SoC comparison + solar_limit_headroom.png - what 'headroom' means: reconstructing an + underestimated forecast + +Requires matplotlib (not part of the project dependencies): + uv pip install matplotlib + python scripts/plot_solar_limit_day.py +""" +import os +import sys + +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt + +sys.path.insert(0, os.path.dirname(__file__)) + +from simulate_solar_limit_day import ( + PROFILE_SOUTH_W, + CONSUMPTION_W, + FEED_IN_LIMIT_W, + run_day, +) + +ASSETS_DIR = os.path.join(os.path.dirname(__file__), '..', 'docs', 'assets') + +# Palette (validated, light mode) +C_SURFACE = '#fcfcfb' +C_PROD = '#2a78d6' # PV surplus (actual) +C_PROD_FC = '#86b6ef' # PV surplus (forecast, lighter step of the same hue) +C_PROD_HR = '#1c5cab' # PV surplus (headroom-corrected, darker step) +C_BASE = '#eb6834' # baseline trace +C_SOLAR = '#1baf7a' # solar_cap rule trace +C_LOST = '#e34948' # curtailed energy +C_INK = '#0b0b0b' +C_INK2 = '#52514e' +C_MUTED = '#898781' +C_GRID = '#e1e0d9' +C_AXIS = '#c3c2b7' + +HOURS = np.arange(24) +SURPLUS_W = np.clip(PROFILE_SOUTH_W - CONSUMPTION_W, 0, None) + +# Fine grid so curves and fill regions follow the limit-line crossings +# instead of jumping at whole-hour points. +XF = np.linspace(0, 23, 24 * 20 + 1) +SURPLUS_F = np.interp(XF, HOURS, SURPLUS_W) + + +def style_axis(ax, ylabel=None): + ax.set_facecolor(C_SURFACE) + for side in ('top', 'right', 'left'): + ax.spines[side].set_visible(False) + ax.spines['bottom'].set_color(C_AXIS) + ax.grid(axis='y', color=C_GRID, linewidth=0.8) + ax.set_axisbelow(True) + ax.tick_params(colors=C_MUTED, labelsize=9) + if ylabel: + ax.set_ylabel(ylabel, color=C_INK2, fontsize=10) + ax.margins(x=0) + + +def hour_axis(ax): + ax.set_xticks(range(0, 25, 3)) + ax.set_xticklabels([f'{h:02d}:00' for h in range(0, 25, 3)]) + ax.set_xlim(0, 23) + + +def limit_line(ax, x0=0, x1=23): + ax.hlines(FEED_IN_LIMIT_W, x0, x1, color=C_INK, linewidth=1.4, + linestyle=(0, (6, 3))) + + +def new_figure(height): + fig = plt.figure(figsize=(9, height), dpi=150) + fig.patch.set_facecolor(C_SURFACE) + return fig + + +def fig_clipping(): + """Figure 1: the clipping problem.""" + fig = new_figure(4.6) + ax = fig.add_subplot(111) + style_axis(ax, 'Power (W)') + hour_axis(ax) + + ax.plot(XF, SURPLUS_F, color=C_PROD, linewidth=2, solid_capstyle='round') + limit_line(ax) + ax.fill_between(XF, np.minimum(SURPLUS_F, FEED_IN_LIMIT_W), 0, + color=C_PROD, alpha=0.12, linewidth=0) + ax.fill_between(XF, SURPLUS_F, FEED_IN_LIMIT_W, + where=SURPLUS_F > FEED_IN_LIMIT_W, + color=C_LOST, alpha=0.45, linewidth=0) + + ax.annotate('clip: curtailed without a battery\n(7.5 kWh on this day)', + xy=(13.4, 7300), xytext=(17.4, 8600), color=C_LOST, + fontsize=10, ha='center', fontweight='bold', + arrowprops=dict(arrowstyle='-', color=C_LOST, linewidth=1)) + ax.text(22.6, 6180, 'feed-in limit 6000 W\n(60% of 10 kWp)', color=C_INK, + fontsize=9, ha='right', va='bottom') + ax.text(8.1, 3050, 'PV surplus\n(production - consumption)', color=C_PROD, + fontsize=10, ha='center', fontweight='bold') + ax.text(17.4, 1600, 'exportable\n(below the limit)', color=C_PROD, + fontsize=9, ha='center', alpha=0.9) + + ax.set_ylim(0, 9600) + ax.set_title('The 60% rule: power above the feed-in limit is lost', + color=C_INK, fontsize=12, loc='left', pad=12) + fig.tight_layout() + fig.savefig(os.path.join(ASSETS_DIR, 'solar_limit_clipping.png'), + facecolor=C_SURFACE, bbox_inches='tight') + plt.close(fig) + + +def fig_algorithm(): + """Figure 2: reservation cap + floor on the reference day, SoC compare.""" + cons = np.full(24, CONSUMPTION_W, dtype=float) + base = run_day(PROFILE_SOUTH_W, cons, 10_000, collect_rows=True) + solar = run_day(PROFILE_SOUTH_W, cons, 10_000, solar_cap_active=True, + collect_rows=True) + + charge = np.array([r['charge_w'] for r in solar['rows']]) + soc_solar = np.array([r['soc_pct'] for r in solar['rows']]) + soc_base = np.array([r['soc_pct'] for r in base['rows']]) + + fig = new_figure(7.2) + ax1 = fig.add_subplot(211) + ax2 = fig.add_subplot(212, sharex=ax1) + + # --- top: power view ------------------------------------------------- + style_axis(ax1, 'Power (W)') + ax1.plot(XF, SURPLUS_F, color=C_PROD, linewidth=2, + solid_capstyle='round') + limit_line(ax1) + ax1.fill_between(XF, SURPLUS_F, FEED_IN_LIMIT_W, + where=SURPLUS_F > FEED_IN_LIMIT_W, + color=C_LOST, alpha=0.18, linewidth=0) + ax1.step(HOURS, charge, where='post', color=C_SOLAR, linewidth=2) + ax1.fill_between(HOURS, charge, 0, step='post', color=C_SOLAR, + alpha=0.15, linewidth=0) + + ax1.annotate('case A: reservation cap\n(spread the non-reserved capacity)', + xy=(8.5, 660), xytext=(4.0, 3100), color=C_SOLAR, fontsize=9, + ha='center', fontweight='bold', + arrowprops=dict(arrowstyle='-', color=C_SOLAR, linewidth=1)) + ax1.annotate('case B: floor = power above the limit\n' + '(battery absorbs the would-be clip)', + xy=(12.5, 2550), xytext=(16.6, 4300), color=C_SOLAR, + fontsize=9, ha='center', fontweight='bold', + arrowprops=dict(arrowstyle='-', color=C_SOLAR, linewidth=1)) + ax1.text(9.2, 6900, 'PV surplus', color=C_PROD, fontsize=10, + fontweight='bold', ha='center') + ax1.text(22.6, 6180, 'feed-in limit', color=C_INK, fontsize=9, ha='right', + va='bottom') + ax1.text(12.5, 800, 'battery charge', color=C_SOLAR, fontsize=9, + ha='center', fontweight='bold') + ax1.set_ylim(0, 9600) + ax1.tick_params(labelbottom=False) + ax1.set_title('solar_cap rule on the reference day ' + '(10 kWp / 6 kW limit / 10 kWh battery)', + color=C_INK, fontsize=12, loc='left', pad=12) + + # --- bottom: SoC view ------------------------------------------------- + style_axis(ax2, 'State of charge (%)') + hour_axis(ax2) + ax2.plot(HOURS, soc_base, color=C_BASE, linewidth=2, + solid_capstyle='round') + ax2.plot(HOURS, soc_solar, color=C_SOLAR, linewidth=2, + solid_capstyle='round') + ax2.set_ylim(0, 108) + + ax2.annotate('baseline: full at 11:00,\neverything above 6 kW is lost', + xy=(11, 99), xytext=(6.2, 72), color=C_BASE, fontsize=9, + ha='center', fontweight='bold', + arrowprops=dict(arrowstyle='-', color=C_BASE, linewidth=1)) + ax2.annotate('solar_cap: capacity reserved,\nfilled with clip energy ' + 'instead', + xy=(13, 62), xytext=(17.8, 40), color=C_SOLAR, fontsize=9, + ha='center', fontweight='bold', + arrowprops=dict(arrowstyle='-', color=C_SOLAR, linewidth=1)) + + fig.tight_layout() + fig.savefig(os.path.join(ASSETS_DIR, 'solar_limit_algorithm.png'), + facecolor=C_SURFACE, bbox_inches='tight') + plt.close(fig) + + +def fig_headroom(): + """Figure 3: headroom reconstructs an underestimated forecast.""" + forecast_f = SURPLUS_F / 1.25 + corrected_f = forecast_f * 1.25 # == SURPLUS_F: that is the point + + fig = new_figure(4.6) + ax = fig.add_subplot(111) + style_axis(ax, 'Power (W)') + ax.set_xticks(range(8, 19, 2)) + ax.set_xticklabels([f'{h:02d}:00' for h in range(8, 19, 2)]) + ax.set_xlim(8, 18) + + ax.fill_between(XF, SURPLUS_F, FEED_IN_LIMIT_W, + where=SURPLUS_F > FEED_IN_LIMIT_W, + color=C_LOST, alpha=0.30, linewidth=0) + ax.fill_between(XF, forecast_f, FEED_IN_LIMIT_W, + where=forecast_f > FEED_IN_LIMIT_W, + color=C_PROD_FC, alpha=0.55, linewidth=0) + + ax.plot(XF, SURPLUS_F, color=C_PROD, linewidth=2, solid_capstyle='round') + ax.plot(XF, forecast_f, color=C_PROD_FC, linewidth=2, + linestyle=(0, (4, 3))) + # The corrected curve coincides with the actual one -- draw it as a + # dotted dark line ON TOP so the reconstruction is visible. + ax.plot(XF, corrected_f, color=C_PROD_HR, linewidth=2.4, + linestyle=(0, (1, 3)), dash_capstyle='round') + limit_line(ax, 8, 18) + + ax.annotate('forecast x headroom (dotted):\nreconstructs the actual ' + 'surplus', + xy=(10.3, 6450), xytext=(9.7, 8600), color=C_PROD_HR, + fontsize=9, ha='center', fontweight='bold', + arrowprops=dict(arrowstyle='-', color=C_PROD_HR, linewidth=1)) + ax.text(13.0, 8850, 'actual surplus', color=C_PROD, fontsize=10, + ha='center', fontweight='bold') + ax.text(15.9, 4600, 'forecast\n(15-25% too low)', color='#4b76ad', + fontsize=9, ha='center', fontweight='bold') + ax.text(17.9, 6120, 'feed-in limit', color=C_INK, fontsize=9, ha='right', + va='bottom') + ax.annotate('actual clip:\nfloor must allow this', + xy=(14.0, 6700), xytext=(15.8, 8300), color=C_LOST, + fontsize=9, ha='center', fontweight='bold', + arrowprops=dict(arrowstyle='-', color=C_LOST, linewidth=1)) + ax.annotate('clip visible to the raw forecast:\nreservation + floor far ' + 'too small', + xy=(12.4, 6400), xytext=(10.2, 2600), color='#4b76ad', + fontsize=9, ha='center', fontweight='bold', + arrowprops=dict(arrowstyle='-', color='#4b76ad', linewidth=1)) + + ax.set_ylim(0, 9800) + ax.set_title("What 'headroom' does: scale the forecast surplus before " + 'computing the clip', color=C_INK, fontsize=12, loc='left', + pad=12) + fig.tight_layout() + fig.savefig(os.path.join(ASSETS_DIR, 'solar_limit_headroom.png'), + facecolor=C_SURFACE, bbox_inches='tight') + plt.close(fig) + + +if __name__ == '__main__': + os.makedirs(ASSETS_DIR, exist_ok=True) + fig_clipping() + fig_algorithm() + fig_headroom() + print(f'Figures written to {os.path.abspath(ASSETS_DIR)}') diff --git a/scripts/simulate_solar_limit_day.py b/scripts/simulate_solar_limit_day.py new file mode 100644 index 00000000..984d6004 --- /dev/null +++ b/scripts/simulate_solar_limit_day.py @@ -0,0 +1,601 @@ +#!/usr/bin/env python3 +"""Day simulation for solar feed-in limit clip absorption (Solarspitzengesetz). + +German law (in force since 2025-02-25) limits uncontrolled PV plants (no +iMSys + Steuerbox) to feeding in at most 60% of their installed power at the +grid connection point. The inverter curtails everything above the limit -- +that energy is LOST unless it is self-consumed or charged into the battery. + +This script evaluates a proposed peak-shaving extension ("solar cap rule"): + - BEFORE the predicted clipping window: cap PV->battery charging so battery + capacity is reserved for energy that would otherwise be curtailed + (exportable energy must not displace clip energy 1:1). + - DURING clipping slots: enforce a charge FLOOR so the battery absorbs at + least the power above the feed-in limit. Important: the existing + time/price peak-shaving caps can otherwise CAUSE curtailment losses. + +Rule switches simulated (proposed config design): + time_active - existing counter-linear ramp until allow_full_battery_after + solar_cap_active - new rule evaluated here (reservation cap + floor) + (price_active exists in the code base but is orthogonal; not simulated) + +Priority between rules: + final_limit = max(solar_floor, min(all active caps)) + -1 = no cap. The floor overrides every cap because a cap below the floor + burns energy (curtailment); caps only optimize economics. + +The "reference algorithm" section below is the standalone copy this +evaluation was run with; the authoritative production implementation now +lives in src/batcontrol/logic/solar_limit.py (ported from here, with the +surplus-headroom and headroom-floor variants baked in). + +Usage: + python scripts/simulate_solar_limit_day.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, + PeakShavingConfig, +) +from batcontrol.logic.common import CommonLogic + +# --------------------------------------------------------------------------- +# Global simulation parameters +# --------------------------------------------------------------------------- +FEED_IN_LIMIT_W = 6_000 # W = 60% of a 10 kWp plant +CONSUMPTION_W = 400 # W constant house load (unless scenario overrides) +ALLOW_FULL_AFTER = 14 # target hour for the legacy time rule +INITIAL_SOC_PCT = 0.15 + +TZ = datetime.timezone.utc +BASE_DATE = datetime.datetime(2026, 6, 21, 0, 0, 0, tzinfo=TZ) + +# 10 kWp south-facing, clear summer day (W per hour). Peak ~8.9 kW. +PROFILE_SOUTH_W = np.array([ + 0, 0, 0, 0, 0, 50, # 00-05 + 300, 1200, 2800, 4700, 6300, 7600, # 06-11 + 8900, 8800, 7800, 6300, 4600, 2700, # 12-17 + 1100, 300, 30, 0, 0, 0, # 18-23 +], dtype=float) + +# 10 kWp east-west, flatter curve, peak below the feed-in limit. +PROFILE_EAST_WEST_W = np.array([ + 0, 0, 0, 0, 0, 100, # 00-05 + 700, 1800, 3000, 4100, 4900, 5400, # 06-11 + 5600, 5600, 5400, 4900, 4100, 3000, # 12-17 + 1800, 700, 100, 0, 0, 0, # 18-23 +], dtype=float) + + +# --------------------------------------------------------------------------- +# Reference algorithm (production version: src/batcontrol/logic/solar_limit.py) +# --------------------------------------------------------------------------- +def compute_solar_limit(production_wh, consumption_wh, feed_in_limit_w, + interval_h, free_capacity_wh, max_capacity_wh, + headroom=1.0, slot0_hours=None, headroom_on='clip', + floor_source='raw'): + """Compute the solar-cap rule output for the current slot. + + Args: + production_wh: forecast PV energy per slot (Wh), index 0 = now. + consumption_wh: forecast consumption per slot (Wh). + feed_in_limit_w: grid feed-in power limit in W. <= 0 = rule inactive + (neutral value, e.g. 0). + interval_h: slot length in hours (0.25 or 1.0). + free_capacity_wh: battery free capacity (Wh). + max_capacity_wh: battery max capacity (Wh). + headroom: safety factor >= 1.0 for RESERVATION sizing only + (forecasts understate clipping). Never applied to + the floor. + slot0_hours: remaining hours in the current slot (partial slot). + Defaults to interval_h. + headroom_on: 'clip' - multiply predicted clip energy (weak + against underestimated production: slots + forecast below the limit stay invisible) + 'surplus' - multiply predicted surplus BEFORE the + clip computation (reconstructs an + underestimated production curve and also + finds clip slots the raw forecast misses) + floor_source: 'raw' - floor from the raw forecast clip + 'headroom' - floor from the headroom-adjusted clip. + With a greedy-charging inverter the + floor only RAISES the allowed cap, so + this permits (never forces) absorbing + more than the raw forecast predicts; + cost: when the forecast is correct, + some exportable surplus is charged + instead of fed in (no energy loss). + + Returns: + (floor_w, cap_w): + floor_w: minimum charge rate (W) the battery must sustain NOW to + absorb power above the feed-in limit. 0 = no floor. + cap_w: charge rate cap (W) to reserve capacity for the clip + window. -1 = no cap, 0 = block charging. + """ + if feed_in_limit_w is None or feed_in_limit_w <= 0: + return 0, -1 + if slot0_hours is None: + slot0_hours = interval_h + + n = min(len(production_wh), len(consumption_wh)) + # Production window ends at the first slot with zero production + # (same convention as the price-based peak shaving rule). + prod_end = n + for i in range(n): + if float(production_wh[i]) == 0: + prod_end = i + break + if prod_end == 0: + return 0, -1 + + slot_h = np.full(prod_end, interval_h, dtype=float) + slot_h[0] = slot0_hours + + surplus_wh = np.clip( + np.asarray(production_wh[:prod_end], dtype=float) + - np.asarray(consumption_wh[:prod_end], dtype=float), + 0, None) + feed_allow_wh = feed_in_limit_w * slot_h + clip_raw_wh = np.clip(surplus_wh - feed_allow_wh, 0, None) + # Headroom only inflates the reservation; a slot can never clip more + # than its (headroom-adjusted) surplus. The floor always uses the raw + # clip so we never force absorbing exportable energy. + if headroom_on == 'surplus': + surplus_hr_wh = surplus_wh * headroom + clip_wh = np.minimum(surplus_hr_wh, + np.clip(surplus_hr_wh - feed_allow_wh, 0, None)) + else: + clip_wh = np.minimum(surplus_wh, clip_raw_wh * headroom) + + clip_slots = np.nonzero(clip_wh > 0)[0] + if len(clip_slots) == 0: + return 0, -1 + + first_clip = int(clip_slots[0]) + + # -- Case A: before the clip window -> reservation cap ---------------- # + if first_clip > 0: + total_clip_wh = min(float(np.sum(clip_wh)), max_capacity_wh) + allowed_wh = free_capacity_wh - total_clip_wh + if allowed_wh <= 0: + return 0, 0 # block PV charging, keep all capacity for the clip + hours_before = slot0_hours + (first_clip - 1) * interval_h + return 0, int(allowed_wh / hours_before) + + # -- Case B: inside a clip slot -> floor + capacity-preserving cap ---- # + # Default floor from the RAW clip (no headroom): never lift the cap + # beyond what the raw forecast predicts as curtailed. + if floor_source == 'headroom': + floor_w = clip_wh[0] / slot0_hours + else: + floor_w = clip_raw_wh[0] / slot0_hours + total_surplus_wh = float(np.sum(surplus_wh)) + if total_surplus_wh <= free_capacity_wh: + return int(floor_w), -1 # everything fits, no cap needed + + remaining_clip_wh = float(np.sum(clip_wh)) + extra_wh = max(0.0, free_capacity_wh - remaining_clip_wh) + remaining_prod_h = float(np.sum(slot_h)) + # When clip energy alone exceeds free capacity (extra == 0) the cap + # equals the floor: the battery absorbs ONLY otherwise-curtailed energy, + # exportable surplus goes to the grid instead of displacing clip energy. + cap_w = int(floor_w + extra_wh / remaining_prod_h) + return int(floor_w), cap_w + + +def merge_limits(floor_w, caps): + """Merge rule outputs: final = max(floor, min(active caps)). + + caps entries: -1 = rule emits no cap. Returns -1 (no limit), 0 (block) + or a positive W value. An unlimited cap always satisfies the floor + because the inverter charges PV surplus greedily. + """ + active = [c for c in caps if c is not None and c >= 0] + if not active: + return -1 + return max(int(floor_w), min(active)) + + +# --------------------------------------------------------------------------- +# Battery / feed-in model +# --------------------------------------------------------------------------- +def apply_slot(prod_w, cons_w, limit_w, stored_wh, capacity_wh, + feed_in_limit_w, interval_h): + """Advance the battery by one slot under a feed-in power limit. + + The inverter charges PV surplus greedily up to limit_w (-1 = unlimited), + exports the rest up to feed_in_limit_w and curtails everything above. + + Returns (charge_w, feed_in_w, curtailed_w, new_stored_wh). + """ + surplus_w = prod_w - cons_w + if surplus_w <= 0: + discharge_w = min(-surplus_w, stored_wh / interval_h) + return 0.0, 0.0, 0.0, max(stored_wh - discharge_w * interval_h, 0.0) + + if limit_w == 0: + want_w = 0.0 + elif limit_w > 0: + want_w = min(surplus_w, float(limit_w)) + else: + want_w = surplus_w + + charge_wh = min(want_w * interval_h, capacity_wh - stored_wh) + charge_w = charge_wh / interval_h + rest_w = surplus_w - charge_w + feed_in_w = min(rest_w, feed_in_limit_w) + curtailed_w = rest_w - feed_in_w + return charge_w, feed_in_w, curtailed_w, stored_wh + charge_wh + + +# --------------------------------------------------------------------------- +# Scenario runner +# --------------------------------------------------------------------------- +def run_day(prod_actual_w, cons_actual_w, capacity_wh, + time_active=False, solar_cap_active=False, + forecast_prod_w=None, forecast_cons_w=None, + feed_in_limit_w=FEED_IN_LIMIT_W, headroom=1.0, + headroom_on='clip', floor_source='raw', + interval_min=60, allow_full_after=ALLOW_FULL_AFTER, + initial_soc_wh=None, collect_rows=False): + """Simulate one day and return metrics (and per-slot rows on request).""" + interval_h = interval_min / 60.0 + n_slots = len(prod_actual_w) + if forecast_prod_w is None: + forecast_prod_w = prod_actual_w + if forecast_cons_w is None: + forecast_cons_w = cons_actual_w + if initial_soc_wh is None: + initial_soc_wh = INITIAL_SOC_PCT * capacity_wh + + # CommonLogic is a singleton keyed to battery capacity -> reset per run. + CommonLogic._instance = None + common = CommonLogic.get_instance( + charge_rate_multiplier=1.1, + always_allow_discharge_limit=0.90, + max_capacity=capacity_wh, + ) + logic = NextLogic(timezone=TZ, interval_minutes=interval_min) + logic.set_calculation_parameters(CalculationParameters( + max_charging_from_grid_limit=0.79, + min_price_difference=0.05, + min_price_difference_rel=0.2, + max_capacity=capacity_wh, + peak_shaving=PeakShavingConfig( + enabled=True, mode='time', + allow_full_battery_after=allow_full_after, + ), + )) + + stored_wh = float(initial_soc_wh) + totals = {'charged_wh': 0.0, 'feed_in_wh': 0.0, 'curtailed_wh': 0.0} + rows = [] + + for s in range(n_slots): + minutes = s * interval_min + ts = BASE_DATE + datetime.timedelta(minutes=minutes) + prod_w = float(prod_actual_w[s]) + cons_w = float(cons_actual_w[s]) + + fc_prod_wh = np.asarray(forecast_prod_w[s:], dtype=float) * interval_h + fc_cons_wh = np.asarray(forecast_cons_w[s:], dtype=float) * interval_h + free_cap = capacity_wh - stored_wh + + floor_w, solar_cap_w = 0, -1 + if solar_cap_active: + floor_w, solar_cap_w = compute_solar_limit( + fc_prod_wh, fc_cons_wh, feed_in_limit_w, interval_h, + free_cap, capacity_wh, headroom=headroom, + headroom_on=headroom_on, floor_source=floor_source) + + time_cap_w = -1 + if time_active and fc_prod_wh[0] > 0: + # Mirror the relevant _apply_peak_shaving skip: unlimited in the + # always_allow_discharge region (high SoC). + if not common.is_discharge_always_allowed_capacity(stored_wh): + calc_input = CalculationInput( + production=fc_prod_wh, + consumption=fc_cons_wh, + prices={}, + stored_energy=stored_wh, + stored_usable_energy=max(stored_wh - 0.05 * capacity_wh, 0), + free_capacity=free_cap, + ) + time_cap_w = logic._calculate_peak_shaving_charge_limit( + calc_input, ts) + + final_w = merge_limits(floor_w, [time_cap_w, solar_cap_w]) + if final_w > 0: + final_w = common.enforce_min_pv_charge_rate(final_w) + + charge_w, feed_w, curt_w, stored_wh_new = apply_slot( + prod_w, cons_w, final_w, stored_wh, capacity_wh, + feed_in_limit_w, interval_h) + + totals['charged_wh'] += charge_w * interval_h + totals['feed_in_wh'] += feed_w * interval_h + totals['curtailed_wh'] += curt_w * interval_h + + if collect_rows: + rows.append({ + 'ts': ts, 'prod_w': prod_w, 'cons_w': cons_w, + 'floor_w': int(floor_w), 'time_cap_w': time_cap_w, + 'solar_cap_w': solar_cap_w, 'final_w': final_w, + 'charge_w': charge_w, 'feed_w': feed_w, 'curt_w': curt_w, + 'soc_pct': stored_wh / capacity_wh * 100, + }) + stored_wh = stored_wh_new + + totals['end_soc_pct'] = stored_wh / capacity_wh * 100 + totals['rows'] = rows + return totals + + +def clip_potential_wh(prod_w, cons_w, feed_in_limit_w, interval_h): + """Energy above the feed-in limit if no battery absorbed anything (Wh).""" + surplus = np.clip(np.asarray(prod_w) - np.asarray(cons_w), 0, None) + return float(np.sum(np.clip(surplus - feed_in_limit_w, 0, None))) * interval_h + + +def fmt_cap(v): + return ' -' if v < 0 else f'{v:>4d}' + + +def print_rows(rows): + print(f" {'Time':>5} {'PV W':>5} {'Floor':>5} {'TimeCap':>7} " + f"{'SolarCap':>8} {'Final':>6} {'Chg W':>5} {'Feed W':>6} " + f"{'Curt W':>6} {'SoC%':>5}") + print(' ' + '-' * 78) + for r in rows: + if r['prod_w'] <= 0 and r['ts'].hour not in (4, 21): + continue # keep output compact: skip most night slots + final = 'unlim' if r['final_w'] < 0 else str(r['final_w']) + print(f" {r['ts'].strftime('%H:%M')} {r['prod_w']:>5.0f} " + f"{r['floor_w']:>5d} {fmt_cap(r['time_cap_w']):>7} " + f"{fmt_cap(r['solar_cap_w']):>8} {final:>6} " + f"{r['charge_w']:>5.0f} {r['feed_w']:>6.0f} " + f"{r['curt_w']:>6.0f} {r['soc_pct']:>5.1f}") + print(' ' + '-' * 78) + + +def print_summary(title, results, potential_wh): + print(f" {title}") + print(f" {'Trace':<34} {'Charged':>9} {'Feed-in':>9} {'Curtailed':>10} " + f"{'EndSoC':>7} {'ClipRecov':>10}") + print(' ' + '-' * 84) + for name, res in results: + recov = '' + if potential_wh > 0: + recov_pct = (potential_wh - res['curtailed_wh']) / potential_wh * 100 + recov = f'{recov_pct:>9.1f}%' + print(f" {name:<34} {res['charged_wh']/1000:>7.2f}kWh " + f"{res['feed_in_wh']/1000:>7.2f}kWh {res['curtailed_wh']/1000:>8.2f}kWh " + f"{res['end_soc_pct']:>6.1f}% {recov:>10}") + print(' ' + '-' * 84) + print(f" Clip potential (no battery absorption): {potential_wh/1000:.2f} kWh") + print() + + +# --------------------------------------------------------------------------- +# Scenarios +# --------------------------------------------------------------------------- +def scenario_reference(): + cons = np.full(24, CONSUMPTION_W, dtype=float) + cap = 10_000 + potential = clip_potential_wh(PROFILE_SOUTH_W, cons, FEED_IN_LIMIT_W, 1.0) + + base = run_day(PROFILE_SOUTH_W, cons, cap) + legacy = run_day(PROFILE_SOUTH_W, cons, cap, time_active=True) + solar = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True, + collect_rows=True) + both = run_day(PROFILE_SOUTH_W, cons, cap, time_active=True, + solar_cap_active=True, collect_rows=True) + + print('=' * 88) + print(' SCENARIO 1 -- Reference: 10 kWp south, clear day, limit 6000 W, ' + '10 kWh battery, 400 W load') + print('=' * 88) + print_summary('Full-day comparison:', [ + ('baseline (all rules off)', base), + ('time_active only (legacy)', legacy), + ('solar_cap_active only', solar), + ('time_active + solar_cap_active', both), + ], potential) + print(' Slot detail, solar_cap_active only:') + print_rows(solar['rows']) + print() + print(' Slot detail, time_active + solar_cap_active ' + '(floor overrides time cap in clip slots):') + print_rows(both['rows']) + print() + return {'baseline': base, 'legacy': legacy, 'solar': solar, 'both': both, + 'potential': potential} + + +def scenario_east_west(): + cons = np.full(24, CONSUMPTION_W, dtype=float) + cap = 10_000 + potential = clip_potential_wh(PROFILE_EAST_WEST_W, cons, FEED_IN_LIMIT_W, 1.0) + base = run_day(PROFILE_EAST_WEST_W, cons, cap) + solar = run_day(PROFILE_EAST_WEST_W, cons, cap, solar_cap_active=True) + print('=' * 88) + print(' SCENARIO 2 -- East-west 10 kWp (peak 5.6 kW < limit): rule must ' + 'stay inert') + print('=' * 88) + print_summary('No clipping expected; solar rule must not change anything:', [ + ('baseline', base), + ('solar_cap_active only', solar), + ], potential) + identical = abs(base['curtailed_wh'] - solar['curtailed_wh']) < 1e-6 and \ + abs(base['end_soc_pct'] - solar['end_soc_pct']) < 1e-6 + print(f" Check: solar trace identical to baseline: {identical}") + print() + + +def scenario_small_battery(): + cons = np.full(24, CONSUMPTION_W, dtype=float) + cap = 5_000 + potential = clip_potential_wh(PROFILE_SOUTH_W, cons, FEED_IN_LIMIT_W, 1.0) + base = run_day(PROFILE_SOUTH_W, cons, cap) + solar = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True, + collect_rows=True) + print('=' * 88) + print(' SCENARIO 3 -- Small battery 5 kWh: clip energy exceeds free ' + 'capacity (scarcity)') + print('=' * 88) + # Theoretical max recovery = free capacity when the clip window starts + # (overnight house load drains the battery below the day-start SoC). + first_clip_row = next(r for r in solar['rows'] if r['floor_w'] > 0) + free_at_window = cap * (1 - first_clip_row['soc_pct'] / 100) + print_summary( + f'Free capacity at window start: {free_at_window/1000:.2f} kWh ' + f'< clip potential {potential/1000:.2f} kWh:', [ + ('baseline', base), + ('solar_cap_active only', solar), + ], potential) + recovered = potential - solar['curtailed_wh'] + print(f" Recovered clip energy: {recovered/1000:.2f} kWh " + f"(theoretical max = free capacity at window start = " + f"{free_at_window/1000:.2f} kWh)") + print(' Slot detail (cap == floor inside window once capacity is scarce):') + print_rows(solar['rows']) + print() + + +def scenario_forecast_error(): + cons = np.full(24, CONSUMPTION_W, dtype=float) + cap = 10_000 + forecast = PROFILE_SOUTH_W * 0.85 # forecast 15% below actual + potential = clip_potential_wh(PROFILE_SOUTH_W, cons, FEED_IN_LIMIT_W, 1.0) + base = run_day(PROFILE_SOUTH_W, cons, cap) + h10 = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True, + forecast_prod_w=forecast, headroom=1.0) + h12 = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True, + forecast_prod_w=forecast, headroom=1.2) + h15 = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True, + forecast_prod_w=forecast, headroom=1.5) + perfect = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True) + print('=' * 88) + print(' SCENARIO 4 -- Forecast error: forecast = 85% of actual ' + '(underestimates clipping)') + print('=' * 88) + print_summary('Effect of feed_in_limit_headroom on the reservation:', [ + ('baseline', base), + ('solar, headroom 1.0', h10), + ('solar, headroom 1.2', h12), + ('solar, headroom 1.5', h15), + ('solar, perfect forecast (ref)', perfect), + ], potential) + + +def scenario_forecast_error_125(): + cons = np.full(24, CONSUMPTION_W, dtype=float) + cap = 10_000 + forecast = PROFILE_SOUTH_W / 1.25 # actual = 125% of forecast + potential = clip_potential_wh(PROFILE_SOUTH_W, cons, FEED_IN_LIMIT_W, 1.0) + fc_potential = clip_potential_wh(forecast, cons, FEED_IN_LIMIT_W, 1.0) + base = run_day(PROFILE_SOUTH_W, cons, cap) + clip_hr = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True, + forecast_prod_w=forecast, headroom=1.25, + headroom_on='clip') + surp_hr = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True, + forecast_prod_w=forecast, headroom=1.25, + headroom_on='surplus') + moderate = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True, + forecast_prod_w=forecast, headroom=1.1, + headroom_on='surplus', floor_source='headroom') + combined = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True, + forecast_prod_w=forecast, headroom=1.25, + headroom_on='surplus', floor_source='headroom') + perfect = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True) + # Regression: what do the same settings cost when the forecast is + # already correct? The inflated floor lets the battery absorb + # exportable energy inside the window, displacing clip energy 1:1 + # (the day is capacity-scarce: total surplus >> free capacity). + perfect_mod = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True, + headroom=1.1, headroom_on='surplus', + floor_source='headroom') + perfect_aggr = run_day(PROFILE_SOUTH_W, cons, cap, solar_cap_active=True, + headroom=1.25, headroom_on='surplus', + floor_source='headroom') + print('=' * 88) + print(' SCENARIO 4b -- Severe forecast error: actual = 125% of forecast') + print('=' * 88) + print(f' Forecast sees only {fc_potential/1000:.2f} kWh clip potential ' + f'(actual: {potential/1000:.2f} kWh) and') + print(' misses entire clip slots -- multiplying the predicted CLIP ' + 'energy cannot fix that.') + print(' All mitigations below are forecast-only (batcontrol has no ' + 'live production measurement).') + print() + print_summary('Mitigation comparison (headroom target + floor source):', [ + ('baseline', base), + ('solar, headroom 1.25 on clip', clip_hr), + ('solar, headroom 1.25 on surplus', surp_hr), + ('solar, surplus 1.1 + hr floor', moderate), + ('solar, surplus 1.25 + hr floor', combined), + ('solar, perfect forecast (ref)', perfect), + ('solar, perfect fc + surplus 1.1', perfect_mod), + ('solar, perfect fc + surplus 1.25', perfect_aggr), + ], potential) + + +def scenario_consumption_spike(): + cons = np.full(24, CONSUMPTION_W, dtype=float) + cons[12:14] = 2_400 # cooking 12:00-14:00 + cap = 10_000 + potential = clip_potential_wh(PROFILE_SOUTH_W, cons, FEED_IN_LIMIT_W, 1.0) + base = run_day(PROFILE_SOUTH_W, cons, cap) + legacy = run_day(PROFILE_SOUTH_W, cons, cap, time_active=True) + both = run_day(PROFILE_SOUTH_W, cons, cap, time_active=True, + solar_cap_active=True) + print('=' * 88) + print(' SCENARIO 5 -- Midday consumption spike (2.4 kW, 12-14h) reduces ' + 'clipping') + print('=' * 88) + print_summary('Self-consumption already absorbs part of the peak:', [ + ('baseline', base), + ('time_active only (legacy)', legacy), + ('time_active + solar_cap_active', both), + ], potential) + + +def scenario_15min(): + # Linear power interpolation of the hourly profile to 15-min slots. + hours = np.arange(24) + slots = np.arange(0, 24, 0.25) + prod15 = np.interp(slots, hours, PROFILE_SOUTH_W) + cons15 = np.full(len(slots), CONSUMPTION_W, dtype=float) + cap = 10_000 + potential = clip_potential_wh(prod15, cons15, FEED_IN_LIMIT_W, 0.25) + base = run_day(prod15, cons15, cap, interval_min=15) + solar = run_day(prod15, cons15, cap, solar_cap_active=True, + interval_min=15) + print('=' * 88) + print(' SCENARIO 6 -- 15-minute interval resolution ' + '(same reference day, interpolated)') + print('=' * 88) + print_summary('Consistency check vs. hourly resolution:', [ + ('baseline (15 min)', base), + ('solar_cap_active (15 min)', solar), + ], potential) + + +if __name__ == '__main__': + scenario_reference() + scenario_east_west() + scenario_small_battery() + scenario_forecast_error() + scenario_forecast_error_125() + scenario_consumption_spike() + scenario_15min() diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py index 056af25d..1a05a2e2 100644 --- a/src/batcontrol/core.py +++ b/src/batcontrol/core.py @@ -251,6 +251,18 @@ def __init__(self, configdict: dict): self.time_at_forecast_error = -1 self.peak_shaving_config = PeakShavingConfig.from_config(config) + if (self.peak_shaving_config.solar_cap_active + and self.peak_shaving_config.feed_in_limit_w > 0 + and self.max_pv_charge_rate > 0): + logger.warning( + 'peak_shaving.feed_in_limit_w (%.0f W) is configured together ' + 'with a static max_pv_charge_rate (%.0f W): if the solar_cap ' + 'clip absorption needs a higher charge rate than this static ' + 'cap allows, curtailment cannot be fully avoided. Consider ' + 'raising max_pv_charge_rate or removing it.', + self.peak_shaving_config.feed_in_limit_w, + self.max_pv_charge_rate, + ) self.max_charging_from_grid_limit = self.batconfig.get( 'max_charging_from_grid_limit', 0.8) @@ -1272,11 +1284,20 @@ def api_set_peak_shaving_price_limit(self, price_limit: float): def api_set_peak_shaving_mode(self, mode: str): """ Set peak shaving operating mode via external API request. The change is temporary and will not be written to the config file. + + ``mode`` is deprecated (see PeakShavingConfig.from_config), but + this setter is kept for backward compatibility: it also updates + the underlying time_active/price_active switches using the same + mapping so runtime mode changes keep working. """ normalized = (mode or '').strip().lower() try: new_config = dataclasses.replace( - self.peak_shaving_config, mode=normalized) + self.peak_shaving_config, + mode=normalized, + time_active=normalized in ('time', 'combined'), + price_active=normalized in ('price', 'combined'), + ) except ValueError as exc: logger.warning( 'API: Invalid peak_shaving mode %r: %s', mode, exc) diff --git a/src/batcontrol/logic/logic_interface.py b/src/batcontrol/logic/logic_interface.py index ab68ec1d..8941b8fe 100644 --- a/src/batcontrol/logic/logic_interface.py +++ b/src/batcontrol/logic/logic_interface.py @@ -18,18 +18,38 @@ def _default_grid_charge_target_config(): @dataclass -class PeakShavingConfig: +class PeakShavingConfig: # pylint: disable=too-many-instance-attributes """ Holds peak shaving configuration parameters, initialized from the config dict. Range/type validation runs in ``__post_init__``. The "combined mode without price_limit" fallback warning is emitted in :py:meth:`from_config` only, so it fires once at config load and not on every ``dataclasses.replace`` in the per-evaluation build path. + + ``mode`` is DEPRECATED in favour of explicit per-rule switches + (``time_active``, ``price_active``, ``solar_cap_active``); see + :py:meth:`from_config` for the mapping and + docs/development/solar-limit-evaluation.md for the rationale. """ enabled: bool = False mode: str = 'combined' allow_full_battery_after: int = 14 price_limit: Optional[float] = None + # ``None`` is a resolution sentinel, not a valid external value: when + # left unset, __post_init__ derives it from ``mode`` (backward + # compatibility for code that still constructs this dataclass directly + # with ``mode=`` instead of the explicit switches). Externally these + # fields always behave as booleans defaulting to True (i.e. equivalent + # to today's 'combined' mode) once construction has completed. + time_active: Optional[bool] = None + price_active: Optional[bool] = None + solar_cap_active: bool = False + # Feed-in power limit in W for the solar_cap rule. 0 = neutral (rule has + # no effect even if solar_cap_active is true). + feed_in_limit_w: float = 0.0 + # Safety factor >= 1.0 applied to the forecast surplus for the solar_cap + # rule's reservation and floor sizing. + feed_in_limit_headroom: float = 1.0 def __post_init__(self): """Validate configuration values and raise ValueError with a clear, @@ -57,6 +77,36 @@ def __post_init__(self): f"peak_shaving.price_limit must be numeric or None, " f"got {type(self.price_limit).__name__}" ) + if (isinstance(self.feed_in_limit_w, bool) + or not isinstance(self.feed_in_limit_w, (int, float))): + raise ValueError( + f"peak_shaving.feed_in_limit_w must be numeric, " + f"got {type(self.feed_in_limit_w).__name__}" + ) + if self.feed_in_limit_w < 0: + raise ValueError( + f"peak_shaving.feed_in_limit_w must be >= 0, " + f"got {self.feed_in_limit_w}" + ) + if (isinstance(self.feed_in_limit_headroom, bool) + or not isinstance(self.feed_in_limit_headroom, (int, float))): + raise ValueError( + f"peak_shaving.feed_in_limit_headroom must be numeric, " + f"got {type(self.feed_in_limit_headroom).__name__}" + ) + if self.feed_in_limit_headroom < 1.0: + raise ValueError( + f"peak_shaving.feed_in_limit_headroom must be >= 1.0, " + f"got {self.feed_in_limit_headroom}" + ) + # Resolve the deprecated ``mode`` into the explicit switches when the + # caller did not set them explicitly (see the field comment above). + # ``from_config`` always passes concrete booleans, so this path only + # matters for direct dataclass construction (tests, expert use). + if self.time_active is None: + self.time_active = self.mode in ('time', 'combined') + if self.price_active is None: + self.price_active = self.mode in ('price', 'combined') @classmethod def from_config(cls, config: dict) -> 'PeakShavingConfig': @@ -65,6 +115,19 @@ def from_config(cls, config: dict) -> 'PeakShavingConfig': Emits a one-time warning when peak shaving is enabled in 'combined' mode without a configured ``price_limit``: the price component is disabled in that case and behaviour falls back to time-only. + + ``mode`` is deprecated in favour of the explicit switches + ``time_active``/``price_active``/``solar_cap_active``. If any switch + key is present in the config, the switches win; a ``mode`` key + present alongside them has no effect on the switches (warning + logged), but its value is still validated -- an invalid ``mode`` + raises ValueError so configuration typos fail fast instead of being + silently swallowed. If only ``mode`` is present, it is mapped onto + the switches (``time`` -> ``time_active=True, price_active=False``; + ``price`` -> ``price_active=True, time_active=False``; + ``combined`` -> both True) and a one-time deprecation warning is + logged at config load. If neither is present, the defaults apply + (equivalent to ``combined``). """ ps = config.get('peak_shaving', {}) price_limit_raw = ps.get('price_limit', None) @@ -80,20 +143,68 @@ def from_config(cls, config: dict) -> 'PeakShavingConfig': f"peak_shaving.price_limit must be numeric or None, " f"got {price_limit_raw!r}" ) from exc + + mode = ps.get('mode', 'combined') + switch_keys = ('time_active', 'price_active', 'solar_cap_active') + switches_present = any(key in ps for key in switch_keys) + mode_present = 'mode' in ps + + if switches_present: + if mode_present: + logger.warning( + "peak_shaving.mode is deprecated and ignored because " + "explicit switches (time_active/price_active/" + "solar_cap_active) are configured. Remove peak_shaving.mode " + "from the configuration to silence this warning." + ) + time_active = ps.get('time_active', True) + price_active = ps.get('price_active', True) + elif mode_present: + # One-time deprecation warning at config load; the per-cycle + # dataclasses.replace path does not go through from_config, so + # this does not repeat on every evaluation. + logger.warning( + "peak_shaving.mode is deprecated; use the explicit switches " + "time_active/price_active/solar_cap_active instead. Mapping " + "mode='%s' onto the switches for now.", mode + ) + time_active = mode in ('time', 'combined') + price_active = mode in ('price', 'combined') + else: + time_active = True + price_active = True + instance = cls( enabled=ps.get('enabled', False), - mode=ps.get('mode', 'combined'), + mode=mode, allow_full_battery_after=ps.get('allow_full_battery_after', 14), price_limit=price_limit, + time_active=time_active, + price_active=price_active, + solar_cap_active=ps.get('solar_cap_active', False), + feed_in_limit_w=ps.get('feed_in_limit_w', 0.0), + feed_in_limit_headroom=ps.get('feed_in_limit_headroom', 1.0), ) - if instance.enabled and instance.mode == 'combined' \ + if instance.enabled and instance.price_active \ and instance.price_limit is None: - logger.warning( - "peak_shaving.mode='combined' but no peak_shaving.price_limit " - "configured: the price component is disabled; falling back " - "to time-only behaviour. Set a numeric price_limit or change " - "mode to 'time' to silence this warning." - ) + if instance.time_active: + logger.warning( + "peak_shaving price_active is enabled (combined-equivalent: " + "time_active and price_active both active) but no " + "peak_shaving.price_limit configured: the price " + "component is disabled; falling back to time-only " + "behaviour. Set a numeric price_limit or disable " + "price_active to silence this warning." + ) + else: + logger.warning( + "peak_shaving.price_active is enabled but no " + "peak_shaving.price_limit configured: the price " + "component is disabled entirely (time_active is also " + "disabled, so there is no fallback). Set a numeric " + "price_limit or disable price_active to silence this " + "warning." + ) return instance diff --git a/src/batcontrol/logic/next.py b/src/batcontrol/logic/next.py index f80ac733..263544f8 100644 --- a/src/batcontrol/logic/next.py +++ b/src/batcontrol/logic/next.py @@ -25,6 +25,7 @@ apply_grid_charge_target_to_recharge, apply_grid_charge_target_to_reserve, ) +from . import solar_limit # Minimum remaining time in hours to prevent division by very small numbers # when calculating charge rates. This constant serves as a safety threshold: @@ -180,18 +181,7 @@ def calculate_inverter_mode(self, calc_input: CalculationInput, # 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) + remaining_time = self._remaining_interval_hours(calc_timestamp) charge_rate = required_recharge_energy / remaining_time charge_rate = self.common.calculate_charge_rate(charge_rate) @@ -219,9 +209,37 @@ def calculate_inverter_mode(self, calc_input: CalculationInput, if self.calculation_parameters.peak_shaving.enabled: inverter_control_settings = self._apply_peak_shaving( inverter_control_settings, calc_input, calc_timestamp) + inverter_control_settings = self._apply_solar_limit( + inverter_control_settings, calc_input, calc_timestamp) return inverter_control_settings + # ------------------------------------------------------------------ # + # Shared helpers # + # ------------------------------------------------------------------ # + + def _remaining_interval_hours(self, calc_timestamp: datetime.datetime) -> float: + """Return the remaining time (in hours) within the current interval. + + For 15-minute resolution this is the time until the next quarter-hour + boundary; for 60-minute resolution the time until the next full hour. + Floored at ``MIN_REMAINING_TIME_HOURS`` to avoid division by very + small numbers (and the resulting unreasonably high charge rates) when + called close to an interval boundary. + """ + 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 + return max(remaining_time, MIN_REMAINING_TIME_HOURS) + # ------------------------------------------------------------------ # # Peak Shaving # # ------------------------------------------------------------------ # @@ -230,45 +248,50 @@ 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. + """Limit PV charge rate based on the active peak shaving switches. - 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 + Switch behaviour (peak_shaving.time_active / peak_shaving.price_active): + time_active - spread remaining capacity until allow_full_battery_after + price_active - reserve capacity for upcoming cheap-price PV slots; + inside cheap window, spread if surplus > free capacity + both active - both limits computed, stricter one wins Skipped when: - - 'price' mode and price_limit is not configured + - price_active and price_limit is not configured, and time_active is + also not active (no other component to fall back to) - No PV production right now (nighttime) - - Past allow_full_battery_after hour (all modes) + - Past allow_full_battery_after hour (both components) - Battery in always_allow_discharge region (high SOC) - Force-charge from grid active (MODE -1) - Discharge not allowed (battery preserved for high-price hours) - In 'combined' mode with price_limit=None, falls back to time-only - behaviour (the time component does not require price_limit). + If both time_active and price_active are set but price_limit is + None, falls back to time-only behaviour (the time component does + not require price_limit). Note: evcc checks (charging, connected+pv mode) are handled in - core.py, not here. + core.py, not here. The solar_cap rule is a separate + post-processing step, see :py:meth:`_apply_solar_limit`. """ - mode = self.calculation_parameters.peak_shaving.mode + time_active = self.calculation_parameters.peak_shaving.time_active + price_active = self.calculation_parameters.peak_shaving.price_active price_limit = self.calculation_parameters.peak_shaving.price_limit # Price component needs price_limit configured. - # For 'price' mode: skip entirely (no other component to fall back to). - # For 'combined' mode: fall back to time-only behaviour. The user is - # informed once at config-load time by PeakShavingConfig, so this - # path stays at debug level to avoid per-cycle log spam. - if price_limit is None: - if mode == 'price': + # If time_active is also off: skip entirely (no other component to + # fall back to). If time_active is on: fall back to time-only + # behaviour. The user is informed once at config-load time by + # PeakShavingConfig, so this path stays at debug level to avoid + # per-cycle log spam. + if price_active and price_limit is None: + if not time_active: logger.debug('[PeakShaving] Skipped: price_limit not ' - 'configured for mode price') + 'configured and price_active is the only ' + 'active component') return settings - if mode == 'combined': - logger.debug('[PeakShaving] price_limit not configured; ' - 'combined mode using time-only component') - mode = 'time' + logger.debug('[PeakShaving] price_limit not configured; ' + 'using time-only component') + price_active = False # No production right now: skip if calc_input.production[0] <= 0: @@ -295,13 +318,13 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, 'battery preserved for high-price hours') return settings - # Compute limits according to mode + # Compute limits according to the active switches price_limit_w = -1 time_limit_w = -1 - if mode in ('price', 'combined'): + if price_active: price_limit_w = self._calculate_peak_shaving_charge_limit_price_based(calc_input) - if mode in ('time', 'combined'): + if time_active: 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] @@ -326,15 +349,98 @@ def _apply_peak_shaving(self, settings: InverterControlSettings, # The limit_battery_charge_rate mode in the inverter layer requires # allow_discharge=True to work correctly. - logger.info('[PeakShaving] mode=%s, PV limit: %d W ' + active_components = ','.join( + name for name, active in + (('time', time_active), ('price', price_active)) if active + ) or 'none' + logger.info('[PeakShaving] active=%s, PV limit: %d W ' '(price-based=%s W, time-based=%s W, full by %d:00)', - mode, settings.limit_battery_charge_rate, + active_components, 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_battery_after) return settings + def _apply_solar_limit(self, settings: InverterControlSettings, + calc_input: CalculationInput, + calc_timestamp: datetime.datetime + ) -> InverterControlSettings: + """Apply the solar_cap rule (feed-in limit clip absorption). + + See docs/development/solar-limit-evaluation.md for the algorithm and + the priority rule between rule flavours. In short: this rule emits a + reservation cap ahead of the predicted clip window and a floor + (minimum permitted charge rate) plus capacity-preserving cap inside + it, so the existing time/price peak-shaving caps do not cause + curtailment. The floor overrides every cap (``final = max(floor, + min(caps))``) because a cap below the floor destroys energy. + + Gated on peak_shaving.enabled (checked by the caller), + peak_shaving.solar_cap_active and a configured feed_in_limit_w > 0. + + Deliberately smaller skip list than :py:meth:`_apply_peak_shaving`: + this rule must still act at high SoC (always_allow_discharge region) + and past allow_full_battery_after -- the clip window physically + outlasts the target hour. Skipped only when: + - No PV production right now (nighttime) + - Force-charge from grid active (MODE -1) + - Discharge not allowed (inverter charges surplus unrestricted there + anyway) + """ + peak_shaving = self.calculation_parameters.peak_shaving + if not peak_shaving.solar_cap_active or peak_shaving.feed_in_limit_w <= 0: + return settings + + if calc_input.production[0] <= 0: + return settings + + if settings.charge_from_grid: + logger.debug('[SolarLimit] Skipped: force_charge (MODE -1) active, ' + 'grid charging takes priority') + return settings + + if not settings.allow_discharge: + logger.debug('[SolarLimit] Skipped: discharge not allowed, ' + 'inverter charges surplus unrestricted') + return settings + + interval_h = self.interval_minutes / 60.0 + slot0_hours = self._remaining_interval_hours(calc_timestamp) + + floor_w, cap_w = solar_limit.compute_solar_limit( + calc_input.production, + calc_input.consumption, + peak_shaving.feed_in_limit_w, + interval_h, + calc_input.free_capacity, + self.common.max_capacity, + headroom=peak_shaving.feed_in_limit_headroom, + slot0_hours=slot0_hours, + ) + + if floor_w == 0 and cap_w < 0: + logger.debug('[SolarLimit] Evaluated: no clip predicted, ' + 'no limit needed') + return settings + + final_w = solar_limit.merge_limits( + floor_w, [settings.limit_battery_charge_rate, cap_w]) + + if final_w > 0: + final_w = self.common.enforce_min_pv_charge_rate(final_w) + + settings.limit_battery_charge_rate = final_w + + logger.info('[SolarLimit] floor=%d W, cap=%s W, final PV limit=%s W ' + '(feed_in_limit=%.0f W)', + floor_w, + cap_w if cap_w >= 0 else 'off', + final_w if final_w >= 0 else 'off', + peak_shaving.feed_in_limit_w) + + 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. diff --git a/src/batcontrol/logic/solar_limit.py b/src/batcontrol/logic/solar_limit.py new file mode 100644 index 00000000..730c4b71 --- /dev/null +++ b/src/batcontrol/logic/solar_limit.py @@ -0,0 +1,141 @@ +"""Solar feed-in limit ("solar_cap") peak-shaving rule. + +Pure functions for the clip-absorption rule described in +docs/development/solar-limit-evaluation.md. The rule works on the existing +forecast arrays (Wh per interval, index 0 = current interval) and produces +two outputs per evaluation: + + floor_w: minimum PV charge rate (W) the battery must be *permitted* to + sustain right now to absorb power above the feed-in limit + ("clip" energy that would otherwise be curtailed and lost). + With a greedy-charging inverter a floor never forces charging + that does not exist -- it only raises the applied cap, and the + inverter charges ``min(actual surplus, cap)``. + cap_w: an upper limit on the PV-to-battery charge rate, either to + reserve free battery capacity ahead of an upcoming clip window + ("reservation") or to keep some capacity free while already + inside the clip window. ``-1`` means no cap, ``0`` blocks PV + charging entirely. + +This module bakes in the settled semantics from the evaluation (headroom +applied to the forecast surplus, floor computed from the headroom-adjusted +clip) -- see the "Forecast-error plan" section of the linked document. +""" +import numpy as np + + +# pylint: disable=too-many-arguments,too-many-positional-arguments +# pylint: disable=too-many-locals,too-many-return-statements +def compute_solar_limit( + production_wh, consumption_wh, feed_in_limit_w, + interval_h, free_capacity_wh, max_capacity_wh, + headroom=1.0, slot0_hours=None): + """Compute the solar-cap rule output (floor, cap) for the current slot. + + Args: + production_wh: forecast PV energy per slot (Wh), index 0 = now. + consumption_wh: forecast consumption per slot (Wh). + feed_in_limit_w: grid feed-in power limit in W. <= 0 or None makes + the rule neutral (no effect). + interval_h: slot length in hours (e.g. 0.25 or 1.0). + free_capacity_wh: battery free capacity (Wh). + max_capacity_wh: battery max capacity (Wh). + headroom: safety factor >= 1.0 applied to the forecast surplus + before the clip is computed (reservation and floor sizing). + Forecasts systematically underestimate PV peaks; headroom + reconstructs the higher real curve. Default 1.0 (neutral). + slot0_hours: remaining hours in the current (partial) slot. + Defaults to ``interval_h``. + + Returns: + (floor_w, cap_w): both ints. ``floor_w`` of 0 means no floor. + ``cap_w`` of -1 means no cap, 0 blocks PV charging. + """ + if feed_in_limit_w is None or feed_in_limit_w <= 0: + return 0, -1 + if slot0_hours is None: + slot0_hours = interval_h + + n = min(len(production_wh), len(consumption_wh)) + # Production window ends at the first slot with zero production (same + # convention as the existing time/price peak-shaving rules). + prod_end = n + for i in range(n): + if float(production_wh[i]) == 0: + prod_end = i + break + if prod_end == 0: + return 0, -1 + + slot_h = np.full(prod_end, interval_h, dtype=float) + slot_h[0] = slot0_hours + + surplus_wh = np.clip( + np.asarray(production_wh[:prod_end], dtype=float) + - np.asarray(consumption_wh[:prod_end], dtype=float), + 0, None) + surplus_hr_wh = surplus_wh * headroom + feed_allow_wh = feed_in_limit_w * slot_h + clip_wh = np.clip(surplus_hr_wh - feed_allow_wh, 0, None) + + clip_slots = np.nonzero(clip_wh > 0)[0] + if len(clip_slots) == 0: + return 0, -1 + + first_clip = int(clip_slots[0]) + + # -- Case A: before the clip window -> reservation cap ---------------- # + # Free capacity minus the predicted clip energy is spread evenly over + # the slots until the window starts. This prevents exportable energy + # from displacing clip energy in the battery 1:1. + if first_clip > 0: + total_clip_wh = min(float(np.sum(clip_wh)), max_capacity_wh) + allowed_wh = free_capacity_wh - total_clip_wh + if allowed_wh <= 0: + return 0, 0 # block PV charging, keep all capacity for the clip + hours_before = slot0_hours + (first_clip - 1) * interval_h + return 0, int(allowed_wh / hours_before) + + # -- Case B: inside a clip slot -> floor + capacity-preserving cap ---- # + # The floor is computed from the headroom-adjusted clip: with a + # greedy-charging inverter this only ever raises the allowed cap, it + # never forces charging energy that does not actually exist. + floor_w = clip_wh[0] / slot0_hours + + # The "everything fits, no cap needed" check uses the RAW (not + # headroom-adjusted) surplus -- this is a physical check, not a safety + # margin. + total_surplus_wh = float(np.sum(surplus_wh)) + if total_surplus_wh <= free_capacity_wh: + return int(floor_w), -1 # everything fits, no cap needed + + remaining_clip_wh = float(np.sum(clip_wh)) + extra_wh = max(0.0, free_capacity_wh - remaining_clip_wh) + remaining_prod_h = float(np.sum(slot_h)) + # When clip energy alone exceeds free capacity (extra == 0) the cap + # equals the floor: the battery absorbs ONLY otherwise-curtailed + # energy, exportable surplus goes to the grid instead of displacing + # clip energy. + cap_w = int(floor_w + extra_wh / remaining_prod_h) + return int(floor_w), cap_w + + +def merge_limits(floor_w, caps): + """Merge the solar floor with a list of caps: ``final = max(floor, min(caps))``. + + ``caps`` entries: ``None`` or a negative value other than the sentinel + means "no opinion" and is ignored; ``-1`` explicitly means "no cap"; + ``0`` blocks charging. Rationale (see the "Priorities between the rule + flavors" section of docs/development/solar-limit-evaluation.md): caps + optimize economics (shift charging in time), the floor prevents + physical loss (curtailment) and therefore overrides every cap. An + unlimited cap (``-1``) automatically satisfies any floor because the + inverter then charges PV surplus greedily anyway. + + Returns: + int: -1 (no limit), 0 (block), or a positive charge rate in W. + """ + active = [c for c in caps if c is not None and c >= 0] + if not active: + return -1 + return max(int(floor_w), min(active)) diff --git a/tests/batcontrol/logic/helpers.py b/tests/batcontrol/logic/helpers.py index 15f9a40d..7920dd91 100644 --- a/tests/batcontrol/logic/helpers.py +++ b/tests/batcontrol/logic/helpers.py @@ -32,11 +32,18 @@ def make_logic(logic_cls, *, always_allow_discharge_limit=0.90, min_charge_energy=100, peak_shaving_enabled=False, - grid_charge_target=None): + peak_shaving=None, + grid_charge_target=None, + interval_minutes=60): """Create a logic instance with common scenario defaults. The CommonLogic singleton is reset so each helper call applies the requested singleton-backed tuning values independently. + + ``peak_shaving``, if given, is used as-is (a full ``PeakShavingConfig`` + instance) and takes priority over ``peak_shaving_enabled`` -- pass it + when a test needs to configure switches/feed_in_limit_w/etc. beyond the + simple enabled/disabled toggle. """ CommonLogic._instance = None CommonLogic.get_instance( @@ -45,7 +52,7 @@ def make_logic(logic_cls, *, max_capacity=capacity_wh, min_charge_energy=min_charge_energy, ) - logic = logic_cls(timezone=timezone, interval_minutes=60) + logic = logic_cls(timezone=timezone, interval_minutes=interval_minutes) logic.set_calculation_parameters(CalculationParameters( max_charging_from_grid_limit=max_charging_from_grid_limit, min_price_difference=min_price_difference, @@ -53,7 +60,10 @@ def make_logic(logic_cls, *, max_capacity=capacity_wh, min_grid_charge_soc=min_grid_charge_soc, preserve_min_grid_charge_soc=preserve_min_grid_charge_soc, - peak_shaving=PeakShavingConfig(enabled=peak_shaving_enabled), + peak_shaving=( + peak_shaving if peak_shaving is not None + else PeakShavingConfig(enabled=peak_shaving_enabled) + ), grid_charge_target=grid_charge_target or GridChargeTargetConfig(), )) return logic diff --git a/tests/batcontrol/logic/test_peak_shaving.py b/tests/batcontrol/logic/test_peak_shaving.py index 5a705620..f8a45204 100644 --- a/tests/batcontrol/logic/test_peak_shaving.py +++ b/tests/batcontrol/logic/test_peak_shaving.py @@ -21,6 +21,7 @@ PeakShavingConfig, ) from batcontrol.logic.common import CommonLogic +from .helpers import make_logic logging.basicConfig(level=logging.DEBUG) @@ -841,7 +842,7 @@ def _make_input(self, production, consumption, free_capacity, 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 + stored_energy = self._MAX_CAPACITY * 0.5 # 5 000 Wh - below gate n = len(production) return CalculationInput( production=np.array(production, dtype=float), @@ -1045,3 +1046,238 @@ def test_min_grid_charge_soc_does_not_block_discharge_at_high_price(self): self.assertTrue(result.allow_discharge) self.assertFalse(result.charge_from_grid) + + +class TestSolarLimitIntegration(unittest.TestCase): + """Integration tests for the solar_cap rule via NextLogic. + + Logic instances are built with tests/batcontrol/logic/helpers.py's + make_logic(), extended to accept a full PeakShavingConfig directly. + max_capacity=10000 throughout (matches the CommonLogic setup used by + the rest of this file). + + Shared clip scenario (headroom=1.0, interval=1h, slot0_hours=1.0, + i.e. calc_timestamp on the hour): + production = [9000, 9000, 9000, 0], consumption = [400]*4 + surplus = 8600 W/slot for slots 0..2 (prod_end=3) + feed_allow = 6000 Wh/slot (feed_in_limit_w=6000) + clip = 2600 Wh/slot -> currently clipping (first_clip=0) + floor = clip_wh[0] / 1.0 = 2600 W + total_surplus (raw) = 3 * 8600 = 25800 Wh + remaining_clip = 3 * 2600 = 7800 Wh + """ + + PRODUCTION = [9000, 9000, 9000, 0] + CONSUMPTION = [400, 400, 400, 400] + FEED_IN_LIMIT_W = 6000 + MAX_CAPACITY = 10000 + TS = datetime.datetime(2025, 6, 20, 11, 0, tzinfo=datetime.timezone.utc) + + 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_calc_input(self, free_capacity, production=None, + consumption=None, prices=None): + production = production if production is not None else self.PRODUCTION + consumption = consumption if consumption is not None else self.CONSUMPTION + stored_energy = self.MAX_CAPACITY - free_capacity + if prices is None: + prices = np.zeros(len(production)) + return CalculationInput( + production=np.array(production, dtype=float), + consumption=np.array(consumption, dtype=float), + prices=np.array(prices, dtype=float), + stored_energy=stored_energy, + stored_usable_energy=stored_energy, + free_capacity=free_capacity, + ) + + def _make_logic(self, peak_shaving): + return make_logic(NextLogic, capacity_wh=self.MAX_CAPACITY, + peak_shaving=peak_shaving) + + def test_floor_overrides_time_cap(self): + """Floor (2600 W) overrides the time-ramp cap while clipping now. + + free_capacity=9500 Wh (battery mostly empty), time_active only + (price_active off). + Time ramp: n=3 slots to 14:00 (target hour), free=9500 Wh + expected_surplus = 3*8600 = 25800 Wh > free -> ramp applies + wh_current = 2*9500/(3*4) = 1583.3 -> 1583 W + Solar rule: scarcity (25800 > 9500 free), and free(9500) > + remaining_clip(7800) -> extra=1700, remaining_prod_h=3 + cap = 2600 + 1700/3 = 3166 W; floor stays 2600 W. + merge_limits(2600, [1583, 3166]) = max(2600, 1583) = 2600. + """ + peak_shaving = PeakShavingConfig( + enabled=True, time_active=True, price_active=False, + solar_cap_active=True, feed_in_limit_w=self.FEED_IN_LIMIT_W, + allow_full_battery_after=14) + logic = self._make_logic(peak_shaving) + calc_input = self._make_calc_input(free_capacity=9500) + settings = self._make_settings() + + result = logic._apply_peak_shaving(settings, calc_input, self.TS) + self.assertEqual(result.limit_battery_charge_rate, 1583) + + result = logic._apply_solar_limit(result, calc_input, self.TS) + self.assertEqual(result.limit_battery_charge_rate, 2600) + self.assertGreater(result.limit_battery_charge_rate, 1583) + + def test_floor_overrides_price_cap_zero(self): + """Floor overrides a blocking (0 W) price cap while clipping now. + + free_capacity=5000 Wh. Price rule (price_active, price_limit=0.05): + the only cheap slot is index 2 (price 0), first_cheap_slot=2 > 0 + -> reserve = surplus[2] = 8600 Wh, additional_allowed = 5000-8600 + < 0 -> price cap = 0 (block PV charging). + Solar rule: scarcity, free(5000) <= remaining_clip(7800) + -> cap == floor == 2600 W. + merge_limits(2600, [0, 2600]) = max(2600, min(0, 2600)) = 2600. + """ + peak_shaving = PeakShavingConfig( + enabled=True, time_active=False, price_active=True, + price_limit=0.05, solar_cap_active=True, + feed_in_limit_w=self.FEED_IN_LIMIT_W, allow_full_battery_after=14) + logic = self._make_logic(peak_shaving) + prices = [10.0, 10.0, 0.0, 10.0] + calc_input = self._make_calc_input(free_capacity=5000, prices=prices) + settings = self._make_settings() + + result = logic._apply_peak_shaving(settings, calc_input, self.TS) + self.assertEqual(result.limit_battery_charge_rate, 0) + + result = logic._apply_solar_limit(result, calc_input, self.TS) + self.assertEqual(result.limit_battery_charge_rate, 2600) + + def test_neutral_by_default(self): + """solar_cap_active=False -> _apply_solar_limit is a no-op. + + Same setup as test_floor_overrides_time_cap, but with the solar + switch off: the settings after _apply_solar_limit must be + bit-identical to the settings right after _apply_peak_shaving. + """ + peak_shaving = PeakShavingConfig( + enabled=True, time_active=True, price_active=False, + solar_cap_active=False, feed_in_limit_w=self.FEED_IN_LIMIT_W, + allow_full_battery_after=14) + logic = self._make_logic(peak_shaving) + calc_input = self._make_calc_input(free_capacity=9500) + settings = self._make_settings() + + before = logic._apply_peak_shaving(settings, calc_input, self.TS) + before_snapshot = InverterControlSettings( + allow_discharge=before.allow_discharge, + charge_from_grid=before.charge_from_grid, + charge_rate=before.charge_rate, + limit_battery_charge_rate=before.limit_battery_charge_rate, + ) + + after = logic._apply_solar_limit(before, calc_input, self.TS) + self.assertEqual(after, before_snapshot) + + def test_high_soc_solar_floor_still_applies(self): + """always_allow_discharge region: peak shaving skips, solar acts. + + free_capacity=500 Wh -> stored=9500/10000=95% >= 90% threshold + -> _apply_peak_shaving skips (limit stays -1). + Solar rule: scarcity, free(500) <= remaining_clip(7800) + -> cap == floor == 2600 W. merge_limits(2600, [-1, 2600]) = 2600. + """ + peak_shaving = PeakShavingConfig( + enabled=True, time_active=True, price_active=False, + solar_cap_active=True, feed_in_limit_w=self.FEED_IN_LIMIT_W, + allow_full_battery_after=14) + logic = self._make_logic(peak_shaving) + calc_input = self._make_calc_input(free_capacity=500) + settings = self._make_settings() + + result = logic._apply_peak_shaving(settings, calc_input, self.TS) + self.assertEqual(result.limit_battery_charge_rate, -1) + + result = logic._apply_solar_limit(result, calc_input, self.TS) + self.assertEqual(result.limit_battery_charge_rate, 2600) + + def test_past_allow_full_battery_after_solar_floor_still_applies(self): + """Past the target hour: peak shaving skips, solar floor still acts. + + ts hour=15 >= allow_full_battery_after=14 -> _apply_peak_shaving + skips (limit stays -1). free_capacity=5000 Wh gives the same + scarcity math as test_floor_overrides_price_cap_zero: cap == floor + == 2600 W. + """ + peak_shaving = PeakShavingConfig( + enabled=True, time_active=True, price_active=False, + solar_cap_active=True, feed_in_limit_w=self.FEED_IN_LIMIT_W, + allow_full_battery_after=14) + logic = self._make_logic(peak_shaving) + calc_input = self._make_calc_input(free_capacity=5000) + settings = self._make_settings() + ts = datetime.datetime(2025, 6, 20, 15, 0, + tzinfo=datetime.timezone.utc) + + result = logic._apply_peak_shaving(settings, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, -1) + + result = logic._apply_solar_limit(result, calc_input, ts) + self.assertEqual(result.limit_battery_charge_rate, 2600) + + def test_force_charge_skips_solar_limit(self): + """charge_from_grid active -> _apply_solar_limit leaves settings unchanged.""" + peak_shaving = PeakShavingConfig( + enabled=True, solar_cap_active=True, + feed_in_limit_w=self.FEED_IN_LIMIT_W) + logic = self._make_logic(peak_shaving) + calc_input = self._make_calc_input(free_capacity=5000) + settings = self._make_settings( + allow_discharge=False, charge_from_grid=True, charge_rate=3000) + + result = logic._apply_solar_limit(settings, calc_input, self.TS) + + self.assertEqual(result.limit_battery_charge_rate, -1) + self.assertTrue(result.charge_from_grid) + + def test_allow_discharge_false_skips_solar_limit(self): + """allow_discharge=False -> _apply_solar_limit leaves settings unchanged.""" + peak_shaving = PeakShavingConfig( + enabled=True, solar_cap_active=True, + feed_in_limit_w=self.FEED_IN_LIMIT_W) + logic = self._make_logic(peak_shaving) + calc_input = self._make_calc_input(free_capacity=5000) + settings = self._make_settings(allow_discharge=False) + + result = logic._apply_solar_limit(settings, calc_input, self.TS) + + self.assertEqual(result.limit_battery_charge_rate, -1) + self.assertFalse(result.allow_discharge) + + def test_enforce_min_pv_charge_rate_on_solar_limit(self): + """A small positive solar cap (<500 W) is raised to 500 W. + + Case A reservation: production=[3000,3000,9000,9000,0], + consumption=400/slot, feed_in_limit_w=6000 -> clip slots 2,3, + clip=2600 Wh each, total=5200 Wh. free_capacity=5400 + -> allowed=200, hours_before=2 -> cap=int(200/2)=100 W, floor=0. + merge_limits(0, [-1, 100]) = 100 -> enforced up to 500 W. + """ + peak_shaving = PeakShavingConfig( + enabled=True, solar_cap_active=True, + feed_in_limit_w=self.FEED_IN_LIMIT_W) + logic = self._make_logic(peak_shaving) + production = [3000, 3000, 9000, 9000, 0] + consumption = [400] * 5 + calc_input = self._make_calc_input( + free_capacity=5400, production=production, consumption=consumption) + settings = self._make_settings() + ts = datetime.datetime(2025, 6, 20, 8, 0, + tzinfo=datetime.timezone.utc) + + result = logic._apply_solar_limit(settings, calc_input, ts) + + self.assertEqual(result.limit_battery_charge_rate, 500) diff --git a/tests/batcontrol/logic/test_solar_limit.py b/tests/batcontrol/logic/test_solar_limit.py new file mode 100644 index 00000000..c04917a6 --- /dev/null +++ b/tests/batcontrol/logic/test_solar_limit.py @@ -0,0 +1,229 @@ +"""Tests for the pure solar_cap rule functions in logic/solar_limit.py. + +Covers compute_solar_limit() (Case A reservation, Case B floor/cap, +headroom, partial slot 0, 15-minute intervals) and merge_limits() (the +floor-overrides-every-cap priority rule). See +docs/development/solar-limit-evaluation.md for the algorithm spec and the +reference-day numbers reproduced in test_reference_day_first_clip_floor. +""" +import unittest + +from batcontrol.logic.solar_limit import compute_solar_limit, merge_limits + + +class TestComputeSolarLimitNeutral(unittest.TestCase): + """Cases where the rule must have no effect.""" + + def test_feed_in_limit_zero_is_neutral(self): + """feed_in_limit_w=0 -> (0, -1) regardless of the arrays.""" + floor, cap = compute_solar_limit( + production_wh=[9000, 9000], consumption_wh=[400, 400], + feed_in_limit_w=0, interval_h=1.0, + free_capacity_wh=1000, max_capacity_wh=10000) + self.assertEqual((floor, cap), (0, -1)) + + def test_feed_in_limit_none_is_neutral(self): + """feed_in_limit_w=None -> (0, -1).""" + floor, cap = compute_solar_limit( + production_wh=[9000, 9000], consumption_wh=[400, 400], + feed_in_limit_w=None, interval_h=1.0, + free_capacity_wh=1000, max_capacity_wh=10000) + self.assertEqual((floor, cap), (0, -1)) + + def test_no_production_now_is_neutral(self): + """production[0] == 0 (nighttime) -> window length 0 -> (0, -1).""" + floor, cap = compute_solar_limit( + production_wh=[0, 5000], consumption_wh=[100, 100], + feed_in_limit_w=1000, interval_h=1.0, + free_capacity_wh=5000, max_capacity_wh=10000) + self.assertEqual((floor, cap), (0, -1)) + + def test_surplus_below_limit_no_clip(self): + """Surplus stays below the feed-in limit everywhere -> (0, -1).""" + floor, cap = compute_solar_limit( + production_wh=[2000, 2000], consumption_wh=[500, 500], + feed_in_limit_w=3000, interval_h=1.0, + free_capacity_wh=5000, max_capacity_wh=10000) + self.assertEqual((floor, cap), (0, -1)) + + +class TestComputeSolarLimitCaseA(unittest.TestCase): + """Case A: before the clip window -> reservation cap.""" + + # Shared scenario: clip window starts at slot 2 (clip 2600 Wh/slot at + # slots 2 and 3), total clip 5200 Wh. + # production = [3000, 3000, 9000, 9000, 0], consumption = [400]*5 + # surplus = [2600, 2600, 8600, 8600] (prod_end=4) + # feed_allow = 6000 Wh/slot -> clip = [0, 0, 2600, 2600] + PRODUCTION = [3000, 3000, 9000, 9000, 0] + CONSUMPTION = [400] * 5 + FEED_IN_LIMIT_W = 6000 + + def test_reservation_cap(self): + """free=8000, max=10000 -> allowed=8000-5200=2800, hours_before=2 + -> cap = int(2800/2) = 1400, floor = 0.""" + floor, cap = compute_solar_limit( + self.PRODUCTION, self.CONSUMPTION, self.FEED_IN_LIMIT_W, + interval_h=1.0, free_capacity_wh=8000, max_capacity_wh=10000) + self.assertEqual((floor, cap), (0, 1400)) + + def test_reservation_blocks_when_free_capacity_too_small(self): + """free=5000 <= total_clip(5200) -> (0, 0), PV charging blocked.""" + floor, cap = compute_solar_limit( + self.PRODUCTION, self.CONSUMPTION, self.FEED_IN_LIMIT_W, + interval_h=1.0, free_capacity_wh=5000, max_capacity_wh=10000) + self.assertEqual((floor, cap), (0, 0)) + + +class TestComputeSolarLimitCaseB(unittest.TestCase): + """Case B: inside the clip window -> floor + capacity-preserving cap.""" + + # Currently clipping: production=[8000,8000,0], consumption=[400,400,0] + # surplus = [7600, 7600] (prod_end=2), feed_allow = 6000/slot + # clip = [1600, 1600] -> floor = clip[0]/1.0 = 1600 + # total_surplus (raw) = 15200, remaining_clip = 3200 + PRODUCTION = [8000, 8000, 0] + CONSUMPTION = [400, 400, 0] + FEED_IN_LIMIT_W = 6000 + + def test_scarcity_cap_equals_floor(self): + """free=2000 <= remaining_clip(3200) -> extra=0 -> cap == floor.""" + floor, cap = compute_solar_limit( + self.PRODUCTION, self.CONSUMPTION, self.FEED_IN_LIMIT_W, + interval_h=1.0, free_capacity_wh=2000, max_capacity_wh=10000) + self.assertEqual(floor, 1600) + self.assertEqual(cap, floor) + + def test_abundance_no_cap_needed(self): + """free=20000 >= total raw surplus(15200) -> (floor, -1).""" + floor, cap = compute_solar_limit( + self.PRODUCTION, self.CONSUMPTION, self.FEED_IN_LIMIT_W, + interval_h=1.0, free_capacity_wh=20000, max_capacity_wh=30000) + self.assertEqual((floor, cap), (1600, -1)) + + def test_extra_spread_over_remaining_slots(self): + """free=5000: between remaining_clip(3200) and total surplus(15200). + extra = 5000-3200 = 1800, remaining_prod_h = 2 + -> cap = int(1600 + 1800/2) = 2500.""" + floor, cap = compute_solar_limit( + self.PRODUCTION, self.CONSUMPTION, self.FEED_IN_LIMIT_W, + interval_h=1.0, free_capacity_wh=5000, max_capacity_wh=10000) + self.assertEqual((floor, cap), (1600, 2500)) + + +class TestComputeSolarLimitHeadroom(unittest.TestCase): + """Headroom applied to the forecast surplus before clip computation.""" + + def test_headroom_creates_a_clip_slot_that_raw_surplus_would_miss(self): + """production=5500, consumption=500 -> raw surplus=5000 (< limit + 6000, no clip with headroom=1.0). With headroom=1.25 the + headroom-adjusted surplus is 5000*1.25=6250 > 6000 -> clip=250, + floor=250 (Case B). free_capacity is large enough that the raw + total surplus (5000) still fits -> cap stays -1. + """ + floor_neutral, cap_neutral = compute_solar_limit( + [5500], [500], feed_in_limit_w=6000, interval_h=1.0, + free_capacity_wh=10000, max_capacity_wh=10000, headroom=1.0) + self.assertEqual((floor_neutral, cap_neutral), (0, -1)) + + floor_headroom, cap_headroom = compute_solar_limit( + [5500], [500], feed_in_limit_w=6000, interval_h=1.0, + free_capacity_wh=10000, max_capacity_wh=10000, headroom=1.25) + self.assertEqual((floor_headroom, cap_headroom), (250, -1)) + + +class TestComputeSolarLimitPartialSlot(unittest.TestCase): + """slot0_hours: remaining hours in the current (partial) interval.""" + + def test_slot0_hours_halved_doubles_the_slot0_floor(self): + """production=5000, consumption=500 -> surplus=4500. + slot0_hours=0.5 -> feed_allow = 2000*0.5 = 1000 + -> clip_wh[0] = 4500-1000 = 3500 + -> floor = clip_wh[0] / 0.5 = 2 * clip_wh[0] = 7000 + free_capacity (20000) covers the raw total surplus (4500) -> cap=-1. + """ + floor, cap = compute_solar_limit( + [5000], [500], feed_in_limit_w=2000, interval_h=1.0, + free_capacity_wh=20000, max_capacity_wh=20000, + headroom=1.0, slot0_hours=0.5) + clip_wh_slot0 = 3500 + self.assertEqual(floor, 2 * clip_wh_slot0) + self.assertEqual((floor, cap), (7000, -1)) + + +class TestComputeSolarLimit15MinInterval(unittest.TestCase): + """15-minute resolution variant of a simple Case B scenario.""" + + def test_quarter_hour_interval(self): + """3 slots of 15 min: 1200 Wh production (4800 W), 100 Wh + consumption (400 W) per slot; feed_in_limit_w=3000. + surplus_wh = 1100/slot, feed_allow_wh = 3000*0.25 = 750/slot + clip_wh = 350/slot -> floor = 350/0.25 = 1400 + total_surplus = 3300 Wh <= free_capacity(10000) -> cap = -1. + """ + floor, cap = compute_solar_limit( + [1200, 1200, 1200, 0], [100, 100, 100, 100], + feed_in_limit_w=3000, interval_h=0.25, + free_capacity_wh=10000, max_capacity_wh=15000) + self.assertEqual((floor, cap), (1400, -1)) + + +class TestComputeSolarLimitReferenceDay(unittest.TestCase): + """Spot check against docs/development/solar-limit-evaluation.md scenario 1. + + At 11:00 the documented floor sequence is 1200 -> 2500 -> 2400 -> 1400 W + as the window progresses; this test reproduces only the first (1200 W) + value for the slot evaluated at 11:00, per the task's "keep it simple" + guidance. + """ + + def test_reference_day_first_clip_floor(self): + """production[0]=7600, consumption=400/slot, limit=6000 (1h slots): + surplus[0] = 7200, feed_allow[0] = 6000 -> clip[0] = 1200 + -> floor = 1200 W. free_capacity is large enough that the whole + window's raw surplus (37400 Wh) fits -> cap = -1. + """ + production = [7600, 8900, 8800, 7800, 6300, 0] + consumption = [400] * 6 + floor, cap = compute_solar_limit( + production, consumption, feed_in_limit_w=6000, interval_h=1.0, + free_capacity_wh=50000, max_capacity_wh=50000) + self.assertEqual(floor, 1200) + self.assertEqual(cap, -1) + + +class TestMergeLimits(unittest.TestCase): + """merge_limits: final = max(floor, min(active caps)).""" + + def test_no_caps_returns_no_limit(self): + """Empty caps list -> -1, regardless of the floor.""" + self.assertEqual(merge_limits(1500, []), -1) + + def test_all_caps_none_or_sentinel_returns_no_limit(self): + """None and -1 both mean 'no opinion' -> -1, floor irrelevant.""" + self.assertEqual(merge_limits(0, [None, -1]), -1) + + def test_positive_floor_with_unlimited_cap_returns_no_limit(self): + """A single -1 ('no cap') satisfies any floor -> -1.""" + self.assertEqual(merge_limits(1500, [-1]), -1) + + def test_strictest_cap_wins_when_floor_is_zero(self): + """floor=0, caps=[500, 300] -> 300 (the strictest cap).""" + self.assertEqual(merge_limits(0, [500, 300]), 300) + + def test_floor_overrides_a_looser_cap(self): + """floor=1500, caps=[500] -> 1500 (floor > cap).""" + self.assertEqual(merge_limits(1500, [500]), 1500) + + def test_floor_overrides_a_blocking_cap(self): + """floor=1500, caps=[0] -> 1500: the floor overrides even a cap + that would otherwise block charging entirely.""" + self.assertEqual(merge_limits(1500, [0]), 1500) + + def test_zero_floor_with_blocking_cap_blocks(self): + """floor=0, caps=[0] -> 0 (nothing to override with).""" + self.assertEqual(merge_limits(0, [0]), 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/batcontrol/test_peak_shaving_config.py b/tests/batcontrol/test_peak_shaving_config.py index 6d27a49c..a83fa742 100644 --- a/tests/batcontrol/test_peak_shaving_config.py +++ b/tests/batcontrol/test_peak_shaving_config.py @@ -160,14 +160,22 @@ def test_combined_without_price_limit_logs_warning(self, caplog): if r.levelname == 'WARNING'] assert any("combined" in m and "price_limit" in m for m in messages) + @staticmethod + def _non_deprecation_warnings(caplog): + # A config that still uses `mode` now always gets the one-time + # deprecation warning; these tests only guard the price_limit + # fallback warning, so the deprecation notice is filtered out. + return [r for r in caplog.records + if r.levelname == 'WARNING' + and 'deprecated' not in r.getMessage()] + def test_disabled_combined_without_price_limit_does_not_warn(self, caplog): # When peak shaving is disabled there is no user-visible problem. with caplog.at_level('WARNING', logger=self.LOGGER): PeakShavingConfig.from_config({ 'peak_shaving': {'enabled': False, 'mode': 'combined'}, }) - warnings = [r for r in caplog.records if r.levelname == 'WARNING'] - assert warnings == [] + assert self._non_deprecation_warnings(caplog) == [] def test_combined_with_price_limit_does_not_warn(self, caplog): with caplog.at_level('WARNING', logger=self.LOGGER): @@ -175,16 +183,14 @@ def test_combined_with_price_limit_does_not_warn(self, caplog): 'peak_shaving': { 'enabled': True, 'mode': 'combined', 'price_limit': 0.05}, }) - warnings = [r for r in caplog.records if r.levelname == 'WARNING'] - assert warnings == [] + assert self._non_deprecation_warnings(caplog) == [] def test_time_mode_without_price_limit_does_not_warn(self, caplog): with caplog.at_level('WARNING', logger=self.LOGGER): PeakShavingConfig.from_config({ 'peak_shaving': {'enabled': True, 'mode': 'time'}, }) - warnings = [r for r in caplog.records if r.levelname == 'WARNING'] - assert warnings == [] + assert self._non_deprecation_warnings(caplog) == [] def test_replace_does_not_re_emit_warning(self, caplog): # dataclasses.replace re-runs __post_init__ but must not trigger @@ -197,3 +203,177 @@ def test_replace_does_not_re_emit_warning(self, caplog): dataclasses.replace(cfg, enabled=True) warnings = [r for r in caplog.records if r.levelname == 'WARNING'] assert warnings == [] + + +class TestPeakShavingConfigModeDeprecationMapping: + """Test the deprecated `mode` -> explicit switches mapping in from_config. + + See docs/development/solar-limit-evaluation.md ("Configuration design: + one switch per rule") for the mapping rules: mode='time' -> + time_active=True, price_active=False; mode='price' -> the reverse; + mode='combined' -> both True. + """ + + def test_mode_time_maps_to_time_only(self): + cfg = PeakShavingConfig.from_config({ + 'peak_shaving': {'mode': 'time'} + }) + assert cfg.time_active is True + assert cfg.price_active is False + + def test_mode_only_config_logs_deprecation_warning(self, caplog): + """A config still using `mode` gets a one-time deprecation warning.""" + with caplog.at_level('WARNING', logger=TestPeakShavingConfigFallbackWarning.LOGGER): + PeakShavingConfig.from_config({ + 'peak_shaving': {'mode': 'time'} + }) + messages = [r.getMessage() for r in caplog.records + if r.levelname == 'WARNING'] + assert any('mode' in m and 'deprecated' in m for m in messages) + + def test_mode_price_maps_to_price_only(self): + cfg = PeakShavingConfig.from_config({ + 'peak_shaving': {'mode': 'price', 'price_limit': 0.05} + }) + assert cfg.time_active is False + assert cfg.price_active is True + + def test_mode_combined_maps_to_both_active(self): + cfg = PeakShavingConfig.from_config({ + 'peak_shaving': {'mode': 'combined', 'price_limit': 0.05} + }) + assert cfg.time_active is True + assert cfg.price_active is True + + def test_switches_win_over_mode_with_warning(self, caplog): + """An explicit switch key present alongside `mode` wins; `mode` is + ignored entirely and a warning is logged.""" + with caplog.at_level('WARNING', logger=TestPeakShavingConfigFallbackWarning.LOGGER): + cfg = PeakShavingConfig.from_config({ + 'peak_shaving': {'mode': 'time', 'price_active': True} + }) + assert cfg.price_active is True + messages = [r.getMessage() for r in caplog.records + if r.levelname == 'WARNING'] + assert any('mode' in m and 'deprecated' in m for m in messages) + + def test_solar_cap_active_switch_present_ignores_mode(self, caplog): + """solar_cap_active alone (without time_active/price_active keys) + also counts as 'switches present' and triggers the mode-ignored + warning; the unspecified switches default to True.""" + with caplog.at_level('WARNING', logger=TestPeakShavingConfigFallbackWarning.LOGGER): + cfg = PeakShavingConfig.from_config({ + 'peak_shaving': { + 'mode': 'price', 'solar_cap_active': True, + 'feed_in_limit_w': 6000}, + }) + assert cfg.solar_cap_active is True + assert cfg.time_active is True + assert cfg.price_active is True + messages = [r.getMessage() for r in caplog.records + if r.levelname == 'WARNING'] + assert any('mode' in m and 'deprecated' in m for m in messages) + + +class TestPeakShavingConfigDefaults: + """Test the default values of the new solar_cap fields.""" + + def test_empty_dict_defaults(self): + cfg = PeakShavingConfig.from_config({}) + assert cfg.time_active is True + assert cfg.price_active is True + assert cfg.solar_cap_active is False + assert cfg.feed_in_limit_w == 0.0 + assert cfg.feed_in_limit_headroom == 1.0 + + def test_empty_peak_shaving_section_defaults(self): + cfg = PeakShavingConfig.from_config({'peak_shaving': {}}) + assert cfg.time_active is True + assert cfg.price_active is True + assert cfg.solar_cap_active is False + assert cfg.feed_in_limit_w == 0.0 + assert cfg.feed_in_limit_headroom == 1.0 + + def test_dataclass_defaults_match(self): + cfg = PeakShavingConfig() + assert cfg.time_active is True + assert cfg.price_active is True + assert cfg.solar_cap_active is False + assert cfg.feed_in_limit_w == 0.0 + assert cfg.feed_in_limit_headroom == 1.0 + + +class TestPeakShavingConfigSolarCapValidation: + """Validation of feed_in_limit_w and feed_in_limit_headroom.""" + + def test_feed_in_limit_w_negative_raises(self): + with pytest.raises(ValueError, match='peak_shaving.feed_in_limit_w'): + PeakShavingConfig(feed_in_limit_w=-1) + + def test_feed_in_limit_w_bool_rejected(self): + with pytest.raises(ValueError, match='peak_shaving.feed_in_limit_w'): + PeakShavingConfig(feed_in_limit_w=True) + + def test_feed_in_limit_w_zero_accepted(self): + cfg = PeakShavingConfig(feed_in_limit_w=0) + assert cfg.feed_in_limit_w == 0 + + def test_feed_in_limit_w_positive_accepted(self): + cfg = PeakShavingConfig(feed_in_limit_w=6000) + assert cfg.feed_in_limit_w == 6000 + + def test_feed_in_limit_w_string_rejected(self): + with pytest.raises(ValueError, match='peak_shaving.feed_in_limit_w'): + PeakShavingConfig(feed_in_limit_w='6000') + + def test_feed_in_limit_headroom_below_one_raises(self): + with pytest.raises(ValueError, + match='peak_shaving.feed_in_limit_headroom'): + PeakShavingConfig(feed_in_limit_headroom=0.9) + + def test_feed_in_limit_headroom_bool_rejected(self): + with pytest.raises(ValueError, + match='peak_shaving.feed_in_limit_headroom'): + PeakShavingConfig(feed_in_limit_headroom=False) + + def test_feed_in_limit_headroom_one_accepted(self): + cfg = PeakShavingConfig(feed_in_limit_headroom=1.0) + assert cfg.feed_in_limit_headroom == 1.0 + + def test_feed_in_limit_headroom_above_one_accepted(self): + cfg = PeakShavingConfig(feed_in_limit_headroom=1.25) + assert cfg.feed_in_limit_headroom == 1.25 + + def test_feed_in_limit_headroom_string_rejected(self): + with pytest.raises(ValueError, + match='peak_shaving.feed_in_limit_headroom'): + PeakShavingConfig(feed_in_limit_headroom='1.1') + + +class TestPeakShavingConfigDirectConstruction: + """Direct dataclass construction (no from_config) resolves switches + from `mode` via __post_init__ when the switches are left at their + None sentinel -- used by code/tests that still construct with `mode=`.""" + + def test_mode_price_resolves_switches(self): + cfg = PeakShavingConfig(mode='price') + assert cfg.time_active is False + assert cfg.price_active is True + + def test_mode_time_resolves_switches(self): + cfg = PeakShavingConfig(mode='time') + assert cfg.time_active is True + assert cfg.price_active is False + + def test_mode_combined_resolves_switches(self): + cfg = PeakShavingConfig(mode='combined') + assert cfg.time_active is True + assert cfg.price_active is True + + def test_explicit_switches_are_not_overridden_by_mode(self): + """When switches are passed explicitly they win over `mode`, + matching the from_config precedence rule.""" + cfg = PeakShavingConfig( + mode='time', time_active=False, price_active=True) + assert cfg.time_active is False + assert cfg.price_active is True