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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ ipython_config.py
.pdm-python
.pdm-build/

# uv
# uv.lock is generated locally when running run_tests.sh; not tracked.
uv.lock

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

Expand Down
8 changes: 7 additions & 1 deletion config/batcontrol_config_dummy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@ battery_control_expert:
# treated as cheap PV windows. Battery capacity is reserved so the PV
# surplus during those cheap slots can be fully absorbed.
# Use -1 to disable the price component without changing mode.
# Required for mode 'price' and 'combined'; ignored for mode 'time'.
# 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'.
#
# Runtime control via MQTT is limited to 'enabled' and
# 'allow_full_battery_after'. Changes to 'mode' or 'price_limit' require
# a restart of batcontrol.
#--------------------------
peak_shaving:
enabled: false
Expand Down
8 changes: 8 additions & 0 deletions docs/WIKI_peak_shaving.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ The evcc integration derives `mode` and `connected` topics automatically from th
| `{base}/peak_shaving/enabled/set` | `true`/`false` | Enable/disable peak shaving |
| `{base}/peak_shaving/allow_full_battery_after/set` | int 0-23 | Set target hour |

Note: `mode` and `price_limit` are **not** settable at runtime via MQTT.
Changing either requires editing `batcontrol_config.yaml` and restarting
batcontrol. See "Known Limitations" below.

### Home Assistant Auto-Discovery

The following HA entities are automatically created:
Expand All @@ -161,3 +165,7 @@ The following HA entities are automatically created:
1. **No intra-day adjustment:** If clouds reduce PV significantly, the limit stays as calculated until the next evaluation cycle (every 3 minutes). The counter-linear ramp self-corrects automatically: high free capacity at the next cycle produces a higher allowed rate.

2. **Code duplication:** `NextLogic` is a copy of `DefaultLogic` with peak shaving added. Once stable, the two could be merged or refactored.

3. **Partial MQTT runtime control:** Only `enabled` and `allow_full_battery_after` can be changed at runtime via MQTT. `mode` and `price_limit` are read from the configuration file once at startup and require a restart to change. If you need to toggle the price component on-the-fly, set `price_limit: -1` in the config so no slots qualify as cheap; the time component continues to work (in `combined` mode) without further changes.

4. **`combined` mode without `price_limit`:** When `mode: combined` is configured but `price_limit` is omitted (or `null`), the price component is skipped and the logic falls back to time-only behaviour. A warning is logged so the fallback is visible. To use the price component, set a numeric `price_limit`; to disable peak shaving entirely, set `enabled: false`.
51 changes: 50 additions & 1 deletion src/batcontrol/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from .logic import Logic as LogicFactory
from .logic import CalculationInput, CalculationParameters
from .logic import CommonLogic
from .logic import PEAK_SHAVING_VALID_MODES

from .dynamictariff import DynamicTariff as tariff_factory
from .inverter import Inverter as inverter_factory
Expand Down Expand Up @@ -61,16 +62,64 @@ class PeakShavingConfig:
allow_full_battery_after: int = 14
price_limit: Optional[float] = None

def __post_init__(self):
"""Validate configuration values and raise ValueError with a clear,
config-key-based message on invalid input. Also emit a one-time
warning for the ``combined`` + missing ``price_limit`` fallback,
so the log message fires at config load (not every evaluation)."""
if self.mode not in PEAK_SHAVING_VALID_MODES:
raise ValueError(
f"peak_shaving.mode must be one of "
f"{PEAK_SHAVING_VALID_MODES}, got '{self.mode}'"
)
if not isinstance(self.allow_full_battery_after, int) \
or isinstance(self.allow_full_battery_after, bool):
raise ValueError(
f"peak_shaving.allow_full_battery_after must be an integer, "
f"got {type(self.allow_full_battery_after).__name__}"
)
if not 0 <= self.allow_full_battery_after <= 23:
raise ValueError(
f"peak_shaving.allow_full_battery_after must be between "
f"0 and 23, got {self.allow_full_battery_after}"
)
if self.price_limit is not None and (
isinstance(self.price_limit, bool)
or not isinstance(self.price_limit, (int, float))):
raise ValueError(
f"peak_shaving.price_limit must be numeric or None, "
f"got {type(self.price_limit).__name__}"
)
if self.enabled and self.mode == 'combined' and self.price_limit is None:
Comment thread
MaStr marked this conversation as resolved.
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."
)

@classmethod
def from_config(cls, config: dict) -> 'PeakShavingConfig':
""" Create a PeakShavingConfig instance from a configuration dict. """
Comment thread
MaStr marked this conversation as resolved.
ps = config.get('peak_shaving', {})
price_limit_raw = ps.get('price_limit', None)
if price_limit_raw is None or isinstance(price_limit_raw, bool):
# ``None`` stays ``None``; bool is rejected by __post_init__ with a
# key-prefixed message. Skip float() so we do not lose the type info.
price_limit = price_limit_raw
else:
try:
price_limit = float(price_limit_raw)
except (TypeError, ValueError) as exc:
raise ValueError(
f"peak_shaving.price_limit must be numeric or None, "
f"got {price_limit_raw!r}"
) from exc
return cls(
enabled=ps.get('enabled', False),
mode=ps.get('mode', 'combined'),
allow_full_battery_after=ps.get('allow_full_battery_after', 14),
price_limit=float(price_limit_raw) if price_limit_raw is not None else None,
price_limit=price_limit,
)


Expand Down
9 changes: 8 additions & 1 deletion src/batcontrol/logic/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
from .logic import Logic
from .logic_interface import LogicInterface, CalculationParameters, CalculationInput, CalculationOutput, InverterControlSettings
from .logic_interface import (
LogicInterface,
CalculationParameters,
CalculationInput,
CalculationOutput,
InverterControlSettings,
PEAK_SHAVING_VALID_MODES,
)
from .common import CommonLogic
from .next import NextLogic
11 changes: 8 additions & 3 deletions src/batcontrol/logic/logic_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
import datetime
import numpy as np

# Shared tuple of valid peak-shaving operating modes. Defined here so that
# both CalculationParameters (this module) and PeakShavingConfig
# (batcontrol.core) can reference a single source of truth.
PEAK_SHAVING_VALID_MODES = ('time', 'price', 'combined')

@dataclass
class CalculationInput:
""" Input for the calculation """
Expand Down Expand Up @@ -40,10 +45,10 @@ def __post_init__(self):
f"peak_shaving_allow_full_after must be 0-23, "
f"got {self.peak_shaving_allow_full_after}"
)
valid_modes = ('time', 'price', 'combined')
if self.peak_shaving_mode not in valid_modes:
if self.peak_shaving_mode not in PEAK_SHAVING_VALID_MODES:
raise ValueError(
f"peak_shaving_mode must be one of {valid_modes}, "
f"peak_shaving_mode must be one of "
f"{PEAK_SHAVING_VALID_MODES}, "
f"got '{self.peak_shaving_mode}'"
)
if (self.peak_shaving_price_limit is not None
Expand Down
26 changes: 20 additions & 6 deletions src/batcontrol/logic/next.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,23 +219,36 @@ def _apply_peak_shaving(self, settings: InverterControlSettings,
'combined' - both limits active, stricter one wins

Skipped when:
- 'price'/'combined' mode and price_limit is not configured
- 'price' mode and price_limit is not configured
- No PV production right now (nighttime)
- Past allow_full_battery_after hour (all modes)
- Battery in always_allow_discharge region (high SOC)
- Force-charge from grid active (MODE -1)
- Discharge not allowed (battery preserved for high-price hours)

In 'combined' mode with price_limit=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.
"""
mode = self.calculation_parameters.peak_shaving_mode
price_limit = self.calculation_parameters.peak_shaving_price_limit

# Price component needs price_limit configured
if mode in ('price', 'combined') and price_limit is None:
logger.debug('[PeakShaving] Skipped: price_limit not configured for mode %s', mode)
return settings
# 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':
logger.debug('[PeakShaving] Skipped: price_limit not '
'configured for mode price')
return settings
if mode == 'combined':
logger.debug('[PeakShaving] price_limit not configured; '
'combined mode using time-only component')
mode = 'time'

# No production right now: skip
if calc_input.production[0] <= 0:
Expand Down Expand Up @@ -290,7 +303,8 @@ def _apply_peak_shaving(self, settings: InverterControlSettings,
settings.limit_battery_charge_rate, charge_limit)

# Note: allow_discharge is already True here (checked above).
# MODE 8 requires allow_discharge=True to work correctly.
# 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 '
'(price-based=%s W, time-based=%s W, full by %d:00)',
Expand Down
42 changes: 38 additions & 4 deletions tests/batcontrol/logic/test_peak_shaving.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,15 @@ def test_discharge_not_allowed_skips_peak_shaving(self):
self.assertEqual(result.limit_battery_charge_rate, -1)
self.assertFalse(result.allow_discharge)

def test_price_limit_none_disables_peak_shaving(self):
"""price_limit=None with mode='combined' -> peak shaving disabled entirely."""
def test_price_limit_none_combined_falls_back_to_time_only(self):
"""price_limit=None with mode='combined' -> falls back to time-only.

The time component does not need price_limit, so combined mode
remains active with only the time-based limiter. At 08:00 with
target hour 14 (6 slots remaining) and production 5000 W,
consumption 500 W, free_capacity 5000 Wh, the counter-linear ramp
yields 2*5000/(6*7) ~= 238 W, raised to the 500 W floor.
"""
params = CalculationParameters(
max_charging_from_grid_limit=0.79,
min_price_difference=0.05,
Expand All @@ -355,6 +362,31 @@ def test_price_limit_none_disables_peak_shaving(self):
ts = datetime.datetime(2025, 6, 20, 8, 0, 0,
tzinfo=datetime.timezone.utc)
result = self.logic._apply_peak_shaving(settings, calc_input, ts)
self.assertEqual(result.limit_battery_charge_rate, 500)

def test_price_limit_none_price_mode_disables_peak_shaving(self):
"""price_limit=None with mode='price' -> peak shaving disabled.

'price' mode has no fallback: without a price_limit, there is no
component to apply, so the entire peak shaving is skipped.
"""
params = CalculationParameters(
max_charging_from_grid_limit=0.79,
min_price_difference=0.05,
min_price_difference_rel=0.2,
max_capacity=self.max_capacity,
peak_shaving_enabled=True,
peak_shaving_allow_full_after=14,
peak_shaving_mode='price',
peak_shaving_price_limit=None,
)
self.logic.set_calculation_parameters(params)
settings = self._make_settings()
calc_input = self._make_input([5000] * 8, [500] * 8,
stored_energy=5000, free_capacity=5000)
ts = datetime.datetime(2025, 6, 20, 8, 0, 0,
tzinfo=datetime.timezone.utc)
result = self.logic._apply_peak_shaving(settings, calc_input, ts)
self.assertEqual(result.limit_battery_charge_rate, -1)

def test_currently_in_cheap_slot_no_limit(self):
Expand All @@ -381,8 +413,10 @@ def test_currently_in_cheap_slot_surplus_overflow(self):
prices all 0 (cheap), production=3000W, consumption=0, 8 slots.
Total surplus = 8 * 3000 = 24000 Wh > free=5000 Wh.
Price-based: spread 5000 / 8 slots = 625 W.
Time-based (mode=combined): 6 slots to target, 6*3000=18000>5000 -> 5000/6=833 W.
min(625, 833) = 625.
Time-based (mode=combined): counter-linear ramp over 6 slots to
target hour, current-slot weight 2*5000/(6*7) ~= 238 W, raised to
the 500 W min_pv_charge_rate floor.
min(625, 500) = 500.
"""
settings = self._make_settings()
prices = np.zeros(8)
Expand Down
Loading
Loading