@@ -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+
22522258def 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