diff --git a/tests/test_wallmonitor.py b/tests/test_wallmonitor.py index 6bd6f94..761c5b7 100644 --- a/tests/test_wallmonitor.py +++ b/tests/test_wallmonitor.py @@ -419,6 +419,27 @@ async def test_thermal_fits_the_current_exponent_from_a_current_spread(db): assert all(fit["rise_c"] * (48.0 / fit["current_a"]) ** 2 > 40.0 for fit in low) +def test_steady_prefix_restarts_after_a_ramp_up_overshoot(): + # 2026-09-14, first live calibration probe: the car overshot toward 48 A + # for two samples while the 32 A cap was taking effect, then held 32.5 A + # for forty minutes. The prefix ended at the overshoot, four samples in, + # and the whole window was lost. + def samples(currents): + return [ + {"ts": 1000.0 + 2.0 * i, "contactor_closed": 1, "vehicle_current_a": amps, "handle_temp_c": 32.0} + for i, amps in enumerate(currents) + ] + + ramp = [6.1, 14.6, 23.9, 31.1, 32.6, 32.7, 32.9, 37.6, 43.3] + [32.5] * 600 + prefix = thermal._steady_current_prefix(samples(ramp)) + assert len(prefix) == 600 and prefix[0]["vehicle_current_a"] == 32.5 + # A current change once the run has settled still ends it (a derate, a + # cap): the leniency is for the first minute only. + settled = [32.5] * 400 + [44.0] * 100 + prefix = thermal._steady_current_prefix(samples(settled)) + assert len(prefix) == 400 and prefix[-1]["vehicle_current_a"] == 32.5 + + def test_thermal_sustainable_current_follows_the_fitted_exponent(): square = thermal.ThermalParams(rise_ref_c=36.0, current_exp=2.0) gentle = thermal.ThermalParams(rise_ref_c=36.0, current_exp=1.5) diff --git a/wallmonitor/thermal.py b/wallmonitor/thermal.py index 27ed3b4..09e7699 100644 --- a/wallmonitor/thermal.py +++ b/wallmonitor/thermal.py @@ -222,6 +222,14 @@ def ambient_from_idle_handle(handle_c: float, model: IdleOffset = BUILTIN_IDLE_O MIN_SPAN_TAU = 1.8 PREFIX_SPAN_TAU = 2.5 PREFIX_SPAN_MIN_S = 1800.0 +# A steady run that ends within its first minute did not end; the ramp-up +# was still wobbling. Vehicles overshoot on the way to a cap: one session +# read 32.6, 32.7, 32.9, 37.6, 43.3 and then 32.5 A for forty minutes — the +# two overshoot samples being the car heading for 48 A in the seconds +# before the amp controller's probe cap took effect — and ending the run at +# the overshoot kept a four-sample window and lost the real one. Every +# probe lands mid-ramp, so every probe would have been lost the same way. +PREFIX_SETTLE_S = 60.0 # The steady-prefix band (10% of the reference current) is wide enough to # hide a substantial current reduction: 48.6 A trimmed to 44.7 A never @@ -383,7 +391,10 @@ def _steady_current_prefix(samples: list[dict], max_span_s: float = PREFIX_SPAN_ its samples at the reduced current, and a whole-session median would put the initial full-rate ramp — the part with the thermal signal — outside the band. Leading samples still ramping up to the plateau are skipped - rather than treated as the end of the run. + rather than treated as the end of the run, and a run that leaves the + band within PREFIX_SETTLE_S of starting is discarded and restarted + rather than ended: that is the ramp overshooting, not a steady run + ending. """ charging = [ sample @@ -404,9 +415,10 @@ def _steady_current_prefix(samples: list[dict], max_span_s: float = PREFIX_SPAN_ if prefix and sample["ts"] - prefix[-1]["ts"] > SEGMENT_SPLIT_GAP_S: break # a charging gap: the next samples belong to a later segment if abs(sample["vehicle_current_a"] - i_ref) > band: - if prefix: + if prefix and sample["ts"] - prefix[0]["ts"] >= PREFIX_SETTLE_S: break # the steady run ended (derate or charge stop) - continue # still ramping up to the plateau + prefix = [] # still ramping up, or the ramp overshot: start over + continue prefix.append(sample) if sample["ts"] - prefix[0]["ts"] > max_span_s: # the ramp lives in the first few tau break