feat: solar feed-in limit (Solarspitzengesetz) clip absorption in peak shaving#405
feat: solar feed-in limit (Solarspitzengesetz) clip absorption in peak shaving#405MaStr wants to merge 13 commits into
Conversation
Add a day simulation for a proposed peak-shaving rule that absorbs PV energy above the 60% feed-in limit for uncontrolled plants into the battery instead of losing it to inverter curtailment: - scripts/simulate_solar_limit_day.py: candidate algorithm (reservation cap before the clipping window, charge floor inside it, merge rule final = max(floor, min(caps))) plus six scenarios comparing baseline, legacy time-based shaving and the new rule - docs/development/solar-limit-evaluation.md: results (100% clip recovery with correct forecast, theoretical maximum with a scarce battery), per-rule switch config design (time_active, price_active, solar_cap_active with feed_in_limit_w: 0 as neutral), documented rule priorities and integration roadmap - register the evaluation page in mkdocs.yml, extend scripts/README.md Key finding: the existing time/price caps can themselves cause curtailment losses on clipping days (1.8 kWh on the reference day); the proposed floor eliminates that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
Add scenario 4b (actual production = 125% of forecast) to the solar feed-in limit simulation. The forecast then sees only 1.36 of 7.50 kWh clip potential and misses entire clip slots, so multiplying predicted clip energy cannot recover it. Two mitigations are implemented and compared: - headroom_on='surplus': apply the headroom factor to the forecast surplus before the clip computation (fixes the reservation, finds clip slots the raw forecast misses) - live_floor: derive the slot-0 charge floor additionally from the actual current production (fixes absorption inside the window) Result: each mitigation alone recovers only 32-39%, the combination 94.7% (perfect forecast: 100%). The evaluation doc now mandates the live floor for v1 integration and fixes the feed_in_limit_headroom semantics to apply to the forecast surplus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
…loor batcontrol has no live production measurement, so the live-floor mitigation from the previous commit is not implementable today. Replace it with a forecast-only alternative: floor_source='headroom' computes the in-window charge floor from the headroom-adjusted clip instead of the raw forecast. With a greedy-charging inverter the floor only raises the allowed cap, so it permits (never forces) absorbing more than the raw forecast predicts. Scenario 4b now quantifies the resulting trade-off in both directions: surplus headroom 1.25 + headroom floor recovers 94.7% at a +25% forecast error but costs 2.8 kWh when the forecast is correct (capacity-scarce days: exportable energy displaces clip energy 1:1); headroom 1.1 is the robust compromise (44.9% / 94.5%). The evaluation doc recommends default 1.0, suggested 1.1, and lists a live measurement as a future option requiring a new data path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
- Rewrite docs/development/solar-limit-evaluation.md in English and add a terminology section defining clip, cap, floor, reservation and headroom. - Add three explanatory figures to docs/assets/ (clipping concept, solar_cap rule behaviour on the reference day with reservation cap / floor / SoC comparison, and a headroom explainer showing how scaling the forecast surplus reconstructs an underestimated production curve). - Add scripts/plot_solar_limit_day.py which generates the figures from the simulation's profiles and candidate algorithm (requires matplotlib, not a project dependency). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
feed_in_limit_headroom stays part of the config design (maintainer decision). Add a section comparing it to the existing global production_offset_percent: measured inside the solar rule the two knobs are equivalent, but the offset applies globally before the forecast enters the logic and distorts grid-recharge and discharge decisions when raised above 1, while its documented purpose is the opposite direction (winter mode). Warn against using the offset to tune clip absorption and describe how the two compose. Drop v1/v2 phasing language from the roadmap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
The algorithm section still showed the headroom factor applied to the clip energy; scenario 4b settled on applying it to the forecast surplus with the floor derived from the corrected clip. Also state that the "everything fits" check uses the raw surplus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
Rework the peak_shaving reference block in the dummy config to the switch-based design (time_active, price_active, solar_cap_active plus feed_in_limit_w and feed_in_limit_headroom, mode deprecated with mapping) and update docs/features/peak-shaving.md accordingly: rule switches section, solar feed-in limit (Solarspitzengesetz) section with figures, priority rule and headroom trade-off guidance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
…ption) Implement the rule designed in docs/development/solar-limit-evaluation.md: - logic/solar_limit.py (new): pure compute_solar_limit() emitting a reservation cap ahead of the predicted clipping window and a charge floor plus capacity-preserving cap inside it; merge_limits() implementing final = max(floor, min(caps)). Headroom is applied to the forecast surplus; the floor uses the headroom-adjusted clip. - logic/logic_interface.py: PeakShavingConfig gains per-rule switches time_active, price_active, solar_cap_active plus feed_in_limit_w (0 = neutral) and feed_in_limit_headroom (>= 1.0), with validation. mode is deprecated and mapped onto the switches. - logic/next.py: _apply_peak_shaving() gates on the switches instead of mode strings; new _apply_solar_limit() post-processing step with a deliberately smaller skip list (still active at high SoC and past allow_full_battery_after); extracted _remaining_interval_hours(). - core.py: startup warning when feed_in_limit_w combines with a static max_pv_charge_rate; api_set_peak_shaving_mode keeps working by updating the mapped switches. 787 tests pass, pylint 9.46. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
50 new tests: pure-function coverage for compute_solar_limit (neutral cases, reservation case A, floor/cap case B, headroom, partial slot 0, 15-min interval, reference-day spot check) and merge_limits priority semantics; NextLogic integration (floor overrides time and blocking price caps, neutral by default, active at high SoC and past the target hour, force-charge and no-discharge skips, 500 W minimum); config parsing (mode deprecation mapping, switches win over mode, defaults, validation errors). helpers.make_logic gains peak_shaving and interval_minutes passthrough kwargs. Full suite: 837 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
There was a problem hiding this comment.
Pull request overview
Adds a new peak-shaving “solar_cap” rule to absorb PV energy that would be curtailed by a configured feed-in limit (German Solarspitzengesetz), and refactors peak-shaving configuration from a single mode string to explicit per-rule switches while keeping backward compatibility.
Changes:
- Introduce
solar_caplogic (floor + reservation/cap) and integrate it intoNextLogicas a post-processing step after existing peak-shaving. - Replace peak-shaving
modesemantics withtime_active/price_active/solar_cap_activeswitches (with validation and deprecatedmodemapping). - Add extensive tests, simulation/plot scripts, and documentation/config reference updates for the new rule and configuration model.
Reviewed changes
Copilot reviewed 15 out of 18 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/batcontrol/test_peak_shaving_config.py | Adds tests for deprecated mode → switches mapping and new solar-cap config validation/defaults. |
| tests/batcontrol/logic/test_solar_limit.py | New unit tests for compute_solar_limit() and merge_limits() pure functions. |
| tests/batcontrol/logic/test_peak_shaving.py | Adds integration tests verifying solar floor/cap interactions with existing peak shaving. |
| tests/batcontrol/logic/helpers.py | Extends make_logic() helper to accept full PeakShavingConfig and variable interval minutes. |
| src/batcontrol/logic/solar_limit.py | New pure-function implementation of the solar feed-in limit rule. |
| src/batcontrol/logic/next.py | Integrates _apply_solar_limit(), refactors remaining-interval computation, and updates peak-shaving logic to use switches. |
| src/batcontrol/logic/logic_interface.py | Extends PeakShavingConfig with switches + solar parameters, mapping/precedence rules, and validation. |
| src/batcontrol/core.py | Adds startup warning for conflicting static max_pv_charge_rate with feed-in limit; keeps deprecated mode setter working via mapping. |
| scripts/simulate_solar_limit_day.py | Adds day simulator for solar feed-in limit scenarios and algorithm evaluation. |
| scripts/README.md | Documents new simulation and plotting scripts. |
| scripts/plot_solar_limit_day.py | Adds script to generate evaluation figures for documentation. |
| mkdocs.yml | Registers the new development doc page in MkDocs navigation. |
| docs/features/peak-shaving.md | Updates peak-shaving documentation for rule switches and the new solar feed-in limit rule. |
| docs/development/solar-limit-evaluation.md | Adds detailed evaluation/design doc (but contains a now-stale “not integrated” status line). |
| config/batcontrol_config_dummy.yaml | Updates reference config with new switches and solar feed-in limit parameters. |
Comments suppressed due to low confidence (1)
docs/features/peak-shaving.md:210
- The table says the solar floor still applies when "Discharge not allowed", but the implementation explicitly skips
_apply_solar_limitwhenallow_dischargeis false (in that mode batcontrol doesn't apply Mode 8 charge limiting). Update the table row to match the code so users don't expect the solar rule to run in avoid-discharge mode.
| 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 | Bypassed | Still applies (if clipping predicted) |
| evcc is actively charging the EV | Bypassed | Not applied |
- Use %.0f instead of %d for feed_in_limit_w log placeholders (float-typed config value) in core.py and next.py. - docs/features/peak-shaving.md: correct the MQTT runtime-control section (price_limit and the deprecated mode setter exist), fix the price_limit default in the parameter table (None, not 0.05), and correct the skip table row for the discharge-not-allowed state (solar floor is not applied there). - config dummy yaml: same MQTT runtime-control correction. - solar-limit-evaluation.md: update the stale "not yet integrated" status header, the rule now ships in logic/solar_limit.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
| - Contains the candidate algorithm (`compute_solar_limit`, `merge_limits`) | ||
| intended to move to `src/batcontrol/logic/solar_limit.py` |
| # --------------------------------------------------------------------------- | ||
| # Proposed algorithm (candidate for src/batcontrol/logic/solar_limit.py) | ||
| # --------------------------------------------------------------------------- |
- Gate the max_pv_charge_rate startup warning on solar_cap_active so configs with feed_in_limit_w set but the rule disabled do not get a misleading warning. - scripts: stop calling the simulation's algorithm copy a "candidate" for src/ -- the authoritative implementation now lives in logic/solar_limit.py; the script keeps a standalone reference copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
| 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) |
There was a problem hiding this comment.
Intentional behavior, keeping it: "ignored" means the value has no effect on the switches, not that it escapes validation. Failing fast on an invalid mode value surfaces configuration typos instead of silently swallowing them — the same fail-fast stance the other config fields take. Clarified the from_config docstring accordingly in 482c53f.
Generated by Claude Code
| 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. If neither is | ||
| present, the defaults apply (equivalent to ``combined``). |
Copilot round 3 flagged two docstring inaccuracies; the behavior itself is correct and stays unchanged: the mode-only deprecation notice is deliberately logged at debug level so unmigrated configs keep loading quietly, and an invalid mode value raises ValueError even when explicit switches are present (fail-fast on config typos). The docstring now says both explicitly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
Raise the mode-only deprecation notice in PeakShavingConfig.from_config from debug to warning (maintainer decision): users still on the deprecated mode key should see the migration hint at config load. The warning fires once at startup; the per-cycle dataclasses.replace path does not go through from_config. The three fallback-warning tests now filter out the deprecation notice (they guard the price_limit fallback warning only), and a new test asserts the deprecation warning explicitly. Docs and dummy config mention the warning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
| 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') |
| | `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. | |
| 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). |
| 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 |
| - **Before this hour:** the PV charge rate may be limited (depending on mode). | ||
| - **At or after this hour:** no limit is applied -- the battery charges as fast as possible. | ||
|
|
||
| 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. |
Solar feed-in limit (Solarspitzengesetz): absorb clipped PV energy into the battery
Motivation
Since 2025-02-25 the German Solarspitzengesetz limits uncontrolled PV plants (no iMSys + control box) to feeding at most 60% of installed power into the grid. The inverter enforces this hard: production above the limit is curtailed and lost unless it is self-consumed or stored. For a 10 kWp plant on a clear summer day that is ~7.5 kWh of lost energy — concentrated exactly in the midday window where a naively charged battery has long been full.
Worse, the existing time/price peak-shaving caps can cause additional curtailment on clipping days: they limit battery charging even when the surplus is above the feed-in limit (1.8 kWh extra loss on the simulated reference day).
What this PR does
New peak-shaving rule
solar_cap(src/batcontrol/logic/solar_limit.py+_apply_solar_limit()inNextLogic):final = max(solar_floor, min(all active caps)). Caps optimize economics; the floor prevents physical loss, so it wins — also afterallow_full_battery_afterand at high SoC (the clip window outlasts the target hour).Switch-based peak-shaving configuration (replaces the
modestring, which became ambiguous with three rules):modestays backward compatible: it is deprecated and mapped onto the switches at load time (time/price/combined); explicit switches win overmodewith a warning. The MQTTmodesetter keeps working via the same mapping.Validation (simulation,
scripts/simulate_solar_limit_day.py)Six simulated scenarios (10 kWp south / 6 kW limit / 10 kWh battery reference; east-west; small battery; forecast errors; consumption spike; 15-min resolution):
Forecast sensitivity and the
feed_in_limit_headroomtrade-off are quantified indocs/development/solar-limit-evaluation.md(with figures), including the relation toproduction_offset_percent(not a substitute — it acts globally on all decisions).Changes
logic/solar_limit.py(new): purecompute_solar_limit()/merge_limits()logic/logic_interface.py:PeakShavingConfigswitches + validation,modedeprecation mappinglogic/next.py: switch gating,_apply_solar_limit()post-processing, extracted_remaining_interval_hours()core.py: startup warning whenfeed_in_limit_wcombines with a staticmax_pv_charge_rate; mode setter mappingdocs/features/peak-shaving.md(rule switches + solar section with figures),docs/development/solar-limit-evaluation.md(design spec + simulation results), dummy config reference blocksimulate_solar_limit_day.py(6-scenario day simulator),plot_solar_limit_day.py(figure generation)Follow-ups (not in this PR)
MaStr/batcontrol_ha_addon:options:+schema:)predicted_clip_wh🤖 Generated with Claude Code
https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4
Generated by Claude Code