hass.tibber_prices/tests/services/test_power_scheduler.py
Julian Pawlowski b8e40bfa3b fix(services): prevent plan_charging overcharge from segment-constraint bridging
apply_segment_constraints could add far more grid energy than requested when
max_cycles_per_day or min_charge_duration_minutes forced bridging across
expensive gaps between cheap intervals. With max_cycles_per_day=1, isolated
cheap intervals were merged into one continuous segment by filling every gap
in between, without ever trimming the surplus back down, resulting in
achieved_soc_percent far above 100%.

Add a post-bridging trim step that removes segment-edge intervals (highest
price first) until total grid energy matches the requested target again,
while still respecting max_cycles_per_day/min_charge_duration_minutes and
never dropping below the target itself (fixed-power mode's expected
last-interval rounding overshoot is preserved).

A related edge case is also fixed: trimming could previously remove an
interval required to satisfy a must_reach_by deadline, silently flipping
deadline_met to False even though the overall energy target was still
reached. Deadline-critical intervals are now passed through as
protected_starts and are never removed during trimming.

Fixes #167

Impact: plan_charging no longer overcharges the battery/EV past the
requested target SoC when max_cycles_per_day or min_charge_duration_minutes
is set, and must_reach_by deadlines are honored even when those constraints
require bridging across expensive price gaps.
2026-07-04 17:43:04 +00:00

161 lines
5.7 KiB
Python

"""Unit tests for charging power scheduling helpers."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
from custom_components.tibber_prices.services.charging.deadline_solver import resolve_deadline
from custom_components.tibber_prices.services.charging.power_scheduler import (
apply_segment_constraints,
build_power_schedule,
)
def _make_intervals(prices: list[float]) -> list[dict[str, object]]:
base = datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
return [
{
"startsAt": (base + timedelta(minutes=15 * index)).isoformat(),
"total": price,
"level": "NORMAL",
}
for index, price in enumerate(prices)
]
def test_continuous_mode_uses_partial_last_interval_power() -> None:
"""Continuous mode should reduce the last interval power when possible."""
result = build_power_schedule(
_make_intervals([0.20, 0.10, 0.15]),
2.5,
max_charge_power_w=4000,
min_charge_power_w=1000,
charging_efficiency=1.0,
)
assert result["mode"] == "continuous"
assert result["total_grid_energy_kwh"] == 2.5
assert sorted(interval["power_w"] for interval in result["intervals"]) == [2000, 4000, 4000]
def test_stepped_mode_uses_smallest_sufficient_step() -> None:
"""Stepped mode should choose the smallest allowed step that finishes the plan."""
result = build_power_schedule(
_make_intervals([0.20, 0.10, 0.15]),
2.5,
max_charge_power_w=4000,
charge_power_steps_w=[1000, 2000, 4000],
charging_efficiency=1.0,
)
assert result["mode"] == "stepped"
assert result["total_grid_energy_kwh"] == 2.5
assert sorted(interval["power_w"] for interval in result["intervals"]) == [2000, 4000, 4000]
def test_apply_segment_constraints_bridges_and_trims_to_target() -> None:
"""Bridging isolated cheap intervals must not leave more energy than requested.
Direct unit-level regression for the ``max_cycles_per_day`` overcharge bug: bridging
fills the gaps between segments with extra (non-essential) intervals, and the
subsequent trim step must remove exactly that surplus again.
"""
candidates = _make_intervals([0.10, 0.80, 0.11, 0.90, 0.12, 0.95, 0.50, 0.60])
schedule = build_power_schedule(candidates, 3.0, max_charge_power_w=4000, charging_efficiency=1.0)
assert schedule["total_grid_energy_kwh"] == 3.0
assert len(schedule["segments"]) == 3 # three isolated cheap intervals
schedule, warnings = apply_segment_constraints(
schedule,
candidates,
charging_efficiency=1.0,
max_cycles_per_day=1,
target_grid_energy_kwh=3.0,
interval_minutes=15,
)
assert warnings == []
assert len(schedule["segments"]) == 1
assert schedule["total_grid_energy_kwh"] == 3.0
assert [interval["total"] for interval in schedule["intervals"]] == [0.10, 0.80, 0.11]
def test_apply_segment_constraints_never_trims_below_target() -> None:
"""Trimming must never drop the plan below the required energy.
Fixed-power mode cannot reduce the last interval's power, so it can legitimately
overshoot the target by up to one interval's energy. This is expected physical
behavior (not constraint bridging) and must survive trimming unchanged.
"""
candidates = _make_intervals([0.10, 0.20])
schedule = build_power_schedule(candidates, 1.5, max_charge_power_w=4000, charging_efficiency=1.0)
assert schedule["total_grid_energy_kwh"] == 2.0 # rounded up from 1.5
schedule, warnings = apply_segment_constraints(
schedule,
candidates,
charging_efficiency=1.0,
target_grid_energy_kwh=1.5,
interval_minutes=15,
)
assert warnings == []
assert schedule["total_grid_energy_kwh"] == 2.0 # both intervals kept
assert len(schedule["intervals"]) == 2
def test_apply_segment_constraints_respects_protected_starts() -> None:
"""Protected intervals (e.g. deadline-critical) must survive trimming.
Even when a protected interval is the most expensive edge of the merged segment,
trimming must skip it and remove the next-best removable edge instead.
"""
candidates = _make_intervals([0.80, 0.95, 0.95, 0.05])
protected_start = candidates[0]["startsAt"]
schedule = build_power_schedule(candidates, 2.0, max_charge_power_w=4000, charging_efficiency=1.0)
assert [interval["total"] for interval in schedule["intervals"]] == [0.80, 0.05]
schedule, warnings = apply_segment_constraints(
schedule,
candidates,
charging_efficiency=1.0,
max_cycles_per_day=1,
target_grid_energy_kwh=2.0,
protected_starts=frozenset({protected_start}),
interval_minutes=15,
)
assert warnings == []
assert schedule["total_grid_energy_kwh"] == 2.0
starts = {interval["startsAt"] for interval in schedule["intervals"]}
assert protected_start in starts
def test_resolve_deadline_next_peak_period() -> None:
"""Deadline helper should resolve the next future peak period start."""
now = datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
home_tz = ZoneInfo("UTC")
coordinator_data = {
"pricePeriods": {
"peak_price": {
"periods": [
{
"start": datetime(2026, 1, 1, 1, 0, tzinfo=UTC),
"end": datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
}
]
}
}
}
deadline, source = resolve_deadline(
coordinator_data=coordinator_data,
now=now,
home_tz=home_tz,
must_reach_by_event="next_peak_period",
)
assert deadline == datetime(2026, 1, 1, 1, 0, tzinfo=UTC)
assert source == "next_peak_period"