Skip to content

Commit 9d62539

Browse files
committed
fix: gate efficiency calibration on actually executed DP plan
Charge/discharge efficiency calibration compared the planned SoC delta against the actual delta whenever the DP *planned* charging or discharging. But the plan is not always executed: hybrid mode can resolve a planned charge to zero_grid, the commitment filter can lock a different power, and zero_grid/manual control modes ignore the schedule entirely. In those cases the actual/planned ratio reflects mode resolution rather than battery efficiency, dragging the correction towards the 0.5 clip floor and making the DP plan far less charge per step than the battery can handle. Samples are now only collected when the previous run commanded the planned first step unchanged (same mode, power within 10%/50 W). https://claude.ai/code/session_01ViddzhUiQT1rMibFjc2pn6
1 parent 607709a commit 9d62539

2 files changed

Lines changed: 133 additions & 0 deletions

File tree

custom_components/battery_controller/coordinator_optimization.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,11 +1278,32 @@ def _previous_charge_step_complete(self) -> bool:
12781278
step_end_utc = dt_util.as_utc(step_start) + timedelta(hours=step_duration_hours)
12791279
return bool(dt_util.utcnow() >= step_end_utc)
12801280

1281+
def _planned_first_step_was_executed(self, planned_action: str) -> bool:
1282+
"""Return whether the previous run's planned first step was commanded as-is.
1283+
1284+
The DP plan is not always what gets executed: hybrid mode can resolve a
1285+
planned charge/discharge to zero_grid, the commitment filter can lock a
1286+
different power, and zero_grid/manual control modes ignore the schedule
1287+
entirely. Efficiency calibration must only sample steps where the plan
1288+
was sent to the controller unchanged — otherwise the actual SoC delta
1289+
reflects the mode resolution, not battery efficiency.
1290+
"""
1291+
if self._effective_mode != planned_action:
1292+
return False
1293+
if self._last_result is None or not self._last_result.power_schedule_kw:
1294+
return False
1295+
planned_kw = self._last_result.power_schedule_kw[0]
1296+
executed_kw = self._controller_schedule_w / 1000
1297+
tolerance_kw = max(0.05, 0.1 * abs(planned_kw))
1298+
return abs(executed_kw - planned_kw) <= tolerance_kw
1299+
12811300
def _update_charge_eff_calibration(self, battery_state: BatteryState) -> None:
12821301
"""Compare previous planned SoC to actual SoC and update charge efficiency correction.
12831302
12841303
Samples are only collected when:
12851304
- The previous optimizer step planned active charging (mode == 'charging')
1305+
- The planned step was actually commanded unchanged (no hybrid/zero_grid
1306+
override, no commitment-filter power lock)
12861307
- The full planned step has elapsed
12871308
- The planned SoC delta is large enough to be reliable (>= 0.1 kWh)
12881309
- DC-coupled PV is not active (passive PV charging would inflate actual delta)
@@ -1308,6 +1329,12 @@ def _update_charge_eff_calibration(self, battery_state: BatteryState) -> None:
13081329
if self._last_result.mode_schedule[0] != ACTION_CHARGING:
13091330
return
13101331

1332+
# Only sample when the plan was actually commanded; mode resolution
1333+
# (hybrid → zero_grid, commitment filter, zero_grid/manual control)
1334+
# would otherwise drag the correction towards the 0.5 clip floor.
1335+
if not self._planned_first_step_was_executed(ACTION_CHARGING):
1336+
return
1337+
13111338
if not self._previous_charge_step_complete():
13121339
return
13131340

@@ -1414,6 +1441,8 @@ def _update_discharge_eff_calibration(self, battery_state: BatteryState) -> None
14141441
14151442
Samples are only collected when:
14161443
- The previous optimizer step planned active discharging (mode == 'discharging')
1444+
- The planned step was actually commanded unchanged (no hybrid/zero_grid
1445+
override, no commitment-filter power lock)
14171446
- The full planned step has elapsed
14181447
- The planned SoC delta is large enough to be reliable (>= 0.1 kWh)
14191448
- DC-coupled PV is not active (passive PV charging during a discharge step
@@ -1437,6 +1466,12 @@ def _update_discharge_eff_calibration(self, battery_state: BatteryState) -> None
14371466
if self._last_result.mode_schedule[0] != ACTION_DISCHARGING:
14381467
return
14391468

1469+
# Only sample when the plan was actually commanded; mode resolution
1470+
# (hybrid → zero_grid, commitment filter, zero_grid/manual control)
1471+
# would otherwise drag the correction towards the 0.5 clip floor.
1472+
if not self._planned_first_step_was_executed(ACTION_DISCHARGING):
1473+
return
1474+
14401475
if not self._previous_charge_step_complete():
14411476
return
14421477

tests/test_coordinator_optimization.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2249,6 +2249,12 @@ def _make_fake_result(
22492249
)
22502250

22512251

2252+
def _mark_plan_executed(coord) -> None:
2253+
"""Simulate that the previous DP plan was commanded to the controller unchanged."""
2254+
coord._effective_mode = coord._last_result.mode_schedule[0]
2255+
coord._controller_schedule_w = coord._last_result.power_schedule_kw[0] * 1000
2256+
2257+
22522258
def test_calibration_no_sample_without_previous_result(hass):
22532259
"""No calibration sample when _last_result is None."""
22542260
coord = _make_coordinator(hass)
@@ -2303,6 +2309,7 @@ def test_calibration_perfect_efficiency_no_correction(hass):
23032309
mode_schedule=["charging"],
23042310
soc_schedule_kwh=[5.0, 6.0], # planned delta = 1.0 kWh
23052311
)
2312+
_mark_plan_executed(coord)
23062313

23072314
# Actual SoC reached exactly the planned value
23082315
battery_state = BatteryState(
@@ -2331,6 +2338,7 @@ def test_calibration_waits_until_previous_step_has_elapsed(hass, monkeypatch):
23312338
"custom_components.battery_controller.coordinator_optimization.dt_util.utcnow",
23322339
lambda: datetime(2026, 4, 15, 10, 15, tzinfo=timezone.utc),
23332340
)
2341+
_mark_plan_executed(coord)
23342342

23352343
battery_state = BatteryState(
23362344
soc_kwh=5.25, soc_percent=52.5, power_kw=1.0, mode="charging"
@@ -2357,6 +2365,7 @@ def test_calibration_samples_after_previous_step_has_elapsed(hass, monkeypatch):
23572365
"custom_components.battery_controller.coordinator_optimization.dt_util.utcnow",
23582366
lambda: datetime(2026, 4, 15, 11, 0, tzinfo=timezone.utc),
23592367
)
2368+
_mark_plan_executed(coord)
23602369

23612370
battery_state = BatteryState(
23622371
soc_kwh=6.0, soc_percent=60.0, power_kw=1.0, mode="charging"
@@ -2375,6 +2384,7 @@ def test_calibration_low_efficiency_updates_correction(hass):
23752384
mode_schedule=["charging"],
23762385
soc_schedule_kwh=[4.0, 6.0], # planned delta = 2.0 kWh
23772386
)
2387+
_mark_plan_executed(coord)
23782388

23792389
# Actual delta = 1.6 kWh → ratio = 0.8
23802390
battery_state = BatteryState(
@@ -2396,6 +2406,7 @@ def test_calibration_rolling_average_converges(hass):
23962406
mode_schedule=["charging"],
23972407
soc_schedule_kwh=[0.0, 1.0],
23982408
)
2409+
_mark_plan_executed(coord)
23992410
# actual delta = 0.85 kWh → ratio = 0.85
24002411
battery_state = BatteryState(
24012412
soc_kwh=0.85, soc_percent=8.5, power_kw=1.0, mode="charging"
@@ -2417,6 +2428,7 @@ def test_calibration_floor_clips_extreme_low_ratio(hass):
24172428
mode_schedule=["charging"],
24182429
soc_schedule_kwh=[5.0, 7.0], # planned delta = 2.0 kWh
24192430
)
2431+
_mark_plan_executed(coord)
24202432

24212433
# Actual delta = 0.5 kWh → uncipped ratio = 0.25, clipped to 0.5
24222434
battery_state = BatteryState(
@@ -2434,6 +2446,7 @@ def test_calibration_ceiling_clips_extra_charge(hass):
24342446
mode_schedule=["charging"],
24352447
soc_schedule_kwh=[5.0, 6.0], # planned delta = 1.0 kWh
24362448
)
2449+
_mark_plan_executed(coord)
24372450

24382451
# Actual delta = 1.5 kWh → unclipped ratio = 1.5, clipped to 1.05
24392452
battery_state = BatteryState(
@@ -2489,6 +2502,7 @@ def test_calibration_not_applied_for_dc_coupled(hass):
24892502
mode_schedule=["charging"],
24902503
soc_schedule_kwh=[4.0, 6.0],
24912504
)
2505+
_mark_plan_executed(coord)
24922506

24932507
battery_state = BatteryState(
24942508
soc_kwh=5.5, soc_percent=55.0, power_kw=1.0, mode="charging"
@@ -2910,3 +2924,87 @@ async def fake_refresh():
29102924
# Previously _last_price stayed stuck at 0 because the whole branch was
29112925
# skipped for a zero price.
29122926
assert coordinator._last_price == pytest.approx(0.001)
2927+
2928+
2929+
# ---------------------------------------------------------------------------
2930+
# Calibration gating: only sample when the DP plan was actually executed
2931+
# ---------------------------------------------------------------------------
2932+
2933+
2934+
def test_calibration_no_sample_when_plan_overridden_by_zero_grid(hass):
2935+
"""No sample when hybrid/zero_grid resolved the planned charge to zero_grid."""
2936+
coord = _make_coordinator(hass)
2937+
coord._last_result = _make_fake_result(
2938+
mode_schedule=["charging"],
2939+
soc_schedule_kwh=[4.0, 6.0],
2940+
)
2941+
# Hybrid resolved the planned charge to zero_grid: schedule not executed.
2942+
coord._effective_mode = "zero_grid"
2943+
coord._controller_schedule_w = 0.0
2944+
2945+
battery_state = BatteryState(
2946+
soc_kwh=4.1, soc_percent=41.0, power_kw=0.0, mode="idle"
2947+
)
2948+
coord._update_charge_eff_calibration(battery_state)
2949+
2950+
assert len(coord._charge_eff_samples) == 0
2951+
assert coord._charge_eff_correction == 1.0
2952+
2953+
2954+
def test_calibration_no_sample_when_commitment_locked_other_power(hass):
2955+
"""No sample when the commitment filter locked a different power."""
2956+
coord = _make_coordinator(hass)
2957+
coord._last_result = _make_fake_result(
2958+
mode_schedule=["charging"],
2959+
soc_schedule_kwh=[4.0, 6.0],
2960+
)
2961+
# Same direction but commitment filter locked 0.4 kW instead of planned 1.0 kW.
2962+
coord._effective_mode = "charging"
2963+
coord._controller_schedule_w = 400.0
2964+
2965+
battery_state = BatteryState(
2966+
soc_kwh=4.4, soc_percent=44.0, power_kw=0.4, mode="charging"
2967+
)
2968+
coord._update_charge_eff_calibration(battery_state)
2969+
2970+
assert len(coord._charge_eff_samples) == 0
2971+
assert coord._charge_eff_correction == 1.0
2972+
2973+
2974+
def test_discharge_calibration_no_sample_when_plan_overridden(hass):
2975+
"""Discharge calibration is gated on the executed plan as well."""
2976+
coord = _make_coordinator(hass)
2977+
coord._last_result = _make_fake_result(
2978+
mode_schedule=["discharging"],
2979+
soc_schedule_kwh=[6.0, 4.0],
2980+
)
2981+
coord._effective_mode = "zero_grid"
2982+
coord._controller_schedule_w = 0.0
2983+
2984+
battery_state = BatteryState(
2985+
soc_kwh=5.9, soc_percent=59.0, power_kw=0.0, mode="idle"
2986+
)
2987+
coord._update_discharge_eff_calibration(battery_state)
2988+
2989+
assert len(coord._discharge_eff_samples) == 0
2990+
assert coord._discharge_eff_correction == 1.0
2991+
2992+
2993+
def test_discharge_calibration_samples_when_plan_executed(hass):
2994+
"""Discharge calibration samples normally when the plan was executed."""
2995+
coord = _make_coordinator(hass)
2996+
coord._last_result = _make_fake_result(
2997+
mode_schedule=["discharging"],
2998+
soc_schedule_kwh=[6.0, 4.0], # planned delta = 2.0 kWh down
2999+
)
3000+
_mark_plan_executed(coord)
3001+
3002+
# Actual delta = 1.6 kWh down → ratio = 0.8
3003+
battery_state = BatteryState(
3004+
soc_kwh=4.4, soc_percent=44.0, power_kw=-1.0, mode="discharging"
3005+
)
3006+
coord._update_discharge_eff_calibration(battery_state)
3007+
3008+
assert len(coord._discharge_eff_samples) == 1
3009+
assert coord._discharge_eff_samples[0] == pytest.approx(0.8)
3010+
assert coord._discharge_eff_correction == pytest.approx(0.8)

0 commit comments

Comments
 (0)