Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 31 additions & 19 deletions config/batcontrol_config_dummy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added docs/assets/solar_limit_algorithm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/solar_limit_clipping.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/solar_limit_headroom.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
348 changes: 348 additions & 0 deletions docs/development/solar-limit-evaluation.md

Large diffs are not rendered by default.

159 changes: 123 additions & 36 deletions docs/features/peak-shaving.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,30 +28,42 @@ 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

| 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 |
Comment thread
MaStr marked this conversation as resolved.
|-------|---------|-------------|
| `{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.

Expand All @@ -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:**

```
Expand All @@ -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.

Expand All @@ -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

Expand All @@ -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

Expand All @@ -171,15 +237,17 @@ 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:
type: next

peak_shaving:
enabled: true
mode: time
time_active: true
price_active: false
solar_cap_active: false
allow_full_battery_after: 14
```

Expand All @@ -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
```
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
Loading