Skip to content

feat: solar feed-in limit (Solarspitzengesetz) clip absorption in peak shaving#405

Open
MaStr wants to merge 13 commits into
mainfrom
claude/solar-peak-law-60-percent-ys6tmo
Open

feat: solar feed-in limit (Solarspitzengesetz) clip absorption in peak shaving#405
MaStr wants to merge 13 commits into
mainfrom
claude/solar-peak-law-60-percent-ys6tmo

Conversation

@MaStr

@MaStr MaStr commented Jul 23, 2026

Copy link
Copy Markdown
Owner

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() in NextLogic):

  • Before the predicted clipping window: a reservation cap spreads only the non-reserved battery capacity, so exportable energy cannot displace clip energy 1:1.
  • Inside the window: a charge floor guarantees the applied charge limit is at least the power above the feed-in limit, so the battery absorbs energy the inverter would otherwise curtail. With a greedy-charging inverter the floor only raises the allowed cap — nothing nonexistent is ever forced, and no new inverter mode is needed.
  • Priority rule: final = max(solar_floor, min(all active caps)). Caps optimize economics; the floor prevents physical loss, so it wins — also after allow_full_battery_after and at high SoC (the clip window outlasts the target hour).

Switch-based peak-shaving configuration (replaces the mode string, which became ambiguous with three rules):

peak_shaving:
  enabled: false
  time_active: true              # target-time rule (counter-linear ramp)
  price_active: true             # price rule (reserve for cheap windows)
  solar_cap_active: false        # NEW: clip absorption
  allow_full_battery_after: 14
  price_limit: 0.05
  feed_in_limit_w: 0             # W at the grid connection point; 0 = off.
                                 # Formula: 0.6 * kWp * 1000
  feed_in_limit_headroom: 1.0    # safety factor >= 1.0 on the forecast surplus

mode stays backward compatible: it is deprecated and mapped onto the switches at load time (time/price/combined); explicit switches win over mode with a warning. The MQTT mode setter 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):

Scenario Baseline loss With solar_cap
Reference day 7.50 kWh 0.00 kWh (100% recovery)
Legacy time-only shaving 1.80 kWh 0.00 kWh
Small battery (5 kWh) 7.50 kWh 2.50 kWh (= physical maximum recovered)
East-west, no clipping 0.00 kWh 0.00 kWh (rule bit-identical inert)

Forecast sensitivity and the feed_in_limit_headroom trade-off are quantified in docs/development/solar-limit-evaluation.md (with figures), including the relation to production_offset_percent (not a substitute — it acts globally on all decisions).

Changes

  • logic/solar_limit.py (new): pure compute_solar_limit() / merge_limits()
  • logic/logic_interface.py: PeakShavingConfig switches + validation, mode deprecation mapping
  • logic/next.py: switch gating, _apply_solar_limit() post-processing, extracted _remaining_interval_hours()
  • core.py: startup warning when feed_in_limit_w combines with a static max_pv_charge_rate; mode setter mapping
  • Tests: 50 new tests (pure functions, NextLogic integration, config parsing) — full suite 837 passed, pylint 9.46
  • Docs: docs/features/peak-shaving.md (rule switches + solar section with figures), docs/development/solar-limit-evaluation.md (design spec + simulation results), dummy config reference block
  • Scripts: simulate_solar_limit_day.py (6-scenario day simulator), plot_solar_limit_day.py (figure generation)

Follow-ups (not in this PR)

  • Mirror the new config keys into the HA add-on repo (MaStr/batcontrol_ha_addon: options: + schema:)
  • Optional read-only MQTT topic predicted_clip_wh
  • Future: live production measurement as forecast-independent floor source (requires a new data path)

🤖 Generated with Claude Code

https://claude.ai/code/session_011McoUchh4HNguDbWWmDqd4


Generated by Claude Code

claude added 9 commits July 22, 2026 07:55
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
Copilot AI review requested due to automatic review settings July 23, 2026 05:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_cap logic (floor + reservation/cap) and integrate it into NextLogic as a post-processing step after existing peak-shaving.
  • Replace peak-shaving mode semantics with time_active / price_active / solar_cap_active switches (with validation and deprecated mode mapping).
  • 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_limit when allow_discharge is 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 |

Comment thread src/batcontrol/core.py
Comment thread src/batcontrol/logic/next.py
Comment thread docs/features/peak-shaving.md Outdated
Comment thread docs/features/peak-shaving.md
Comment thread docs/development/solar-limit-evaluation.md
Comment thread config/batcontrol_config_dummy.yaml Outdated
- 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
Copilot AI review requested due to automatic review settings July 23, 2026 05:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 18 changed files in this pull request and generated 3 comments.

Comment thread src/batcontrol/core.py Outdated
Comment thread scripts/README.md Outdated
Comment on lines +34 to +35
- Contains the candidate algorithm (`compute_solar_limit`, `merge_limits`)
intended to move to `src/batcontrol/logic/solar_limit.py`
Comment on lines +77 to +79
# ---------------------------------------------------------------------------
# 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
Copilot AI review requested due to automatic review settings July 23, 2026 05:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 18 changed files in this pull request and generated 2 comments.

Comment on lines +144 to +158
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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/batcontrol/logic/logic_interface.py Outdated
Comment on lines +123 to +127
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
Copilot AI review requested due to automatic review settings July 23, 2026 06:09
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 18 changed files in this pull request and generated 5 comments.

Comment on lines +106 to +109
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
Comment on lines 74 to 77
- **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.
Copilot AI review requested due to automatic review settings July 23, 2026 06:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 18 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants