From 6e4a794c38e77a28dac8d716ae3a873b71c8d3e2 Mon Sep 17 00:00:00 2001 From: Fernando Gonzalez Date: Mon, 14 Sep 2026 20:16:13 -0400 Subject: [PATCH] feat: fit how rise scales with current, and restore straight to the sustainable rate The first live calibration probe held 32 A for 40 min and then released to 48 A on a day the idle forecast had already said full rate would trip. The controller re-capped 3.5 min later: 32 -> 48 -> 42 A, and those minutes at 48 A pushed the handle from 48.8 to 54.4 C. Two things were wrong, one in the model and one in the controller. The model assumed rise scales with I^2. It does not on this install: the 32 A probe settled 4 C above the I^2 prediction, and the two 39.6 A fits read 42.7 and 43.3 C "at 48 A" against ~36 for every 48 A window. Part of the handle's heat is current-independent, so plateaus fall off more gently than I^2 as current drops. The exponent is now fitted per install by log-log regression over free-running fits (>= 4 fits spanning >= 6 A, else the I^2 prior stands), every fit's rise_ref_c is re-normalized with it, and every forecast at an off-reference current goes through it. On the production database: n = 1.20 +/- 0.08 from 13 fits; the 39.6 A fits re-read as 36.7 and 37.2; the drift regression's current coefficient drops from -0.80 to -0.06 C/A, because there is no normalization error left for it to absorb. Verdict unchanged. The controller restored to normal_amps on probe completion, and climbed 2 A per confirmed-clear cycle everywhere else. Each rung resets the trajectory window, so a climb from 32 A took half an hour to find a number the model could name at once. The server now reports sustainable_max_a on every forecast - the highest current whose plateau stays under the trip point at today's ambient - and the controller restores straight to it: on probe completion, and on the first confirmed-clear restore of a session. After a quick reversal the model has already been wrong about today and the climb falls back to single steps; against a server without the field the ladder is unchanged. Which ambient feeds that number matters. Replaying the probe's end on production data, the trajectory-implied ambient (n = 1.2 extrapolated to 32 A over-reads the rise by 2 C) named 47 A, which would have tripped; the LAN sensor's 30.25 C named 44 A, which is what held. So the sustainable current is worked from a measured ambient whenever a sensor reports - interpolation inside the fitted data rather than a round trip to 32 A and back - and from the implied ambient only without one. Today's session under this code: 32 A probe, then 44 A in one move. Co-Authored-By: Claude Fable 5.1 --- contrib/derate_amp_control.py | 89 ++++++++++++------ docs/amp-control.md | 35 ++++++-- docs/thermal-model.md | 22 +++-- tests/test_derate_amp_control.py | 93 +++++++++++++++++++ tests/test_wallmonitor.py | 51 ++++++++++- wallmonitor/static/app.js | 11 ++- wallmonitor/thermal.py | 149 ++++++++++++++++++++++++++----- 7 files changed, 384 insertions(+), 66 deletions(-) diff --git a/contrib/derate_amp_control.py b/contrib/derate_amp_control.py index 071ac02..d052ba6 100755 --- a/contrib/derate_amp_control.py +++ b/contrib/derate_amp_control.py @@ -21,14 +21,24 @@ **Restoring up is the risky direction** — it's what pushes the equilibrium back toward the trip point — so it stays conservative on every axis: only -``trajectory`` basis (this session's own proven data, never ``model``), only -one ``--restore-step-a`` at a time rather than snapping straight back to -``--normal-amps``, and never while the handle is still within -``--restore-margin-c`` of the trip point even if the trajectory reads clear. -The same 2026-08-03 session showed why: capping straight back to 48A the -moment a trajectory read clear, twice, immediately restarted the climb both -times, converting a caught derate into three near-misses before the third -one wasn't caught in time. +``trajectory`` basis (this session's own proven data, never ``model``), never +while the handle is still within ``--restore-margin-c`` of the trip point +even if the trajectory reads clear, and never straight back to +``--normal-amps`` on trust. The same 2026-08-03 session showed why: capping +straight back to 48A the moment a trajectory read clear, twice, immediately +restarted the climb both times, converting a caught derate into three +near-misses before the third one wasn't caught in time. + +Where it restores *to* is the server's ``sustainable_max_a``: the highest +current whose modelled plateau stays under the trip point at today's ambient +(the LAN sensor when one reports, else the ambient the live trajectory +implies). One move there, then the trajectory and the confidence guard trim +the last amp or two. The alternative — climbing +``--restore-step-a`` at a time — resets the trajectory window at every rung +and took half an hour to find the same number. The model is trusted once per +session: after a quick reversal it has already been wrong about today, and +the climb falls back to single ``--restore-step-a`` steps (and to that ladder +entirely against a server that doesn't report the field). Earlier live testing (2026-08-01, a full 48A session) is why ``hypothetical`` basis is never trusted at all: it leans on the historical per-install @@ -43,8 +53,7 @@ A cap fully lifts three ways, in order of how eagerly they should fire: 1. the trajectory forecast reports ``will_trip: false`` for ``--confirm-ticks`` consecutive polls *and* the handle has real margin - below the trip point, stepped up ``--restore-step-a`` at a time — see - above; + below the trip point, restored to the sustainable current — see above; 2. the charging session ends (``state`` leaves ``charging``) — the normal, expected end of any cap, restored immediately since there's no more climb to protect against; @@ -346,8 +355,8 @@ def _decide_thermal(thermal: dict, state: State, cfg: Config) -> tuple[Action, S ) # Restore path: deliberately narrower than the cap path. Only - # `trajectory` basis (never `model`), only a step at a time, gated on - # real thermal margin, and backed off exponentially after repeated + # `trajectory` basis (never `model`), never on trust to full rate, gated + # on real thermal margin, and backed off exponentially after repeated # quick reversals — see the module docstring for the incidents that # justify every one of these guards. if basis == "trajectory" and will_trip is False: @@ -382,7 +391,16 @@ def _decide_thermal(thermal: dict, state: State, cfg: Config) -> tuple[Action, S f"(need {cfg.restore_margin_c:g}C): holding {state.cap_value:g}A" ), ) - next_value = min(cfg.normal_amps, state.cap_value + cfg.restore_step_a) + # One move to the model's sustainable current when the server + # reports one, instead of a 2 A ladder that resets the trajectory + # window at every rung. The model is trusted once per session: + # after a quick reversal it has already been wrong about this + # session, so the climb falls back to single steps. + step_value = state.cap_value + cfg.restore_step_a + sustainable = forecast.get("sustainable_max_a") + jump = isinstance(sustainable, (int, float)) and state.restore_attempts == 0 + next_value = min(cfg.normal_amps, max(step_value, float(sustainable)) if jump else step_value) + how = "jumping" if jump and next_value > step_value else "stepping up" step_state = replace(next_state, clear_streak=0, last_step_up_ts=now_ts) if next_value >= cfg.normal_amps: final_state = replace(step_state, capped=False, cap_value=None) @@ -396,7 +414,7 @@ def _decide_thermal(thermal: dict, state: State, cfg: Config) -> tuple[Action, S Action("cap", next_value), final_state, ( - f"trajectory clear, {margin_c:.1f}C of margin: stepping up to {next_value:g}A " + f"trajectory clear, {margin_c:.1f}C of margin: {how} to {next_value:g}A " f"(still under {cfg.normal_amps:g}A)" ), ) @@ -470,17 +488,37 @@ def _apply_probe( return Action("none"), new_state, "probe holding (no timestamp to age it against)" held_min = (now_ts - prev.probe_started_ts) / 60.0 if held_min >= cfg.probe_hold_min: + done = replace( + new_state, probe_started_ts=None, last_probe_ts=now_ts, clear_streak=0, trip_streak=0 + ) + # The probe's own plateau is the best measurement of today's + # conditions the session will ever have, and the server has + # already turned it into the sustainable current. Go there. + # Snapping to normal_amps instead, on a day the model already + # knew full rate would trip, cost a 32 -> 48 -> 42 A oscillation. + sustainable = (thermal.get("forecast") or {}).get("sustainable_max_a") + if isinstance(sustainable, (int, float)) and sustainable < cfg.normal_amps: + target = float(sustainable) + if target <= cfg.probe_amps: + return ( + Action("none"), + replace(done, capped=True, cap_value=cfg.probe_amps), + ( + f"probe complete: held {cfg.probe_amps:g}A for {held_min:.0f}min; " + f"sustainable {target:g}A is no higher, holding" + ), + ) + return ( + Action("cap", target), + replace(done, capped=True, cap_value=target, last_step_up_ts=now_ts), + ( + f"probe complete: held {cfg.probe_amps:g}A for {held_min:.0f}min, " + f"restoring to the sustainable {target:g}A" + ), + ) return ( Action("restore", cfg.normal_amps), - replace( - new_state, - probe_started_ts=None, - last_probe_ts=now_ts, - capped=False, - cap_value=None, - clear_streak=0, - trip_streak=0, - ), + replace(done, capped=False, cap_value=None), ( f"probe complete: held {cfg.probe_amps:g}A for {held_min:.0f}min, " f"restoring to {cfg.normal_amps:g}A" @@ -643,8 +681,9 @@ def main(argv: list[str] | None = None) -> int: "--restore-step-a", type=float, default=2.0, - help="raise the cap by at most this much per confirmed-clear cycle, " - "instead of snapping straight back to --normal-amps (default %(default)s)", + help="raise the cap by this much per confirmed-clear cycle when the server reports no " + "sustainable current, or after the model has already been wrong once this session " + "(default %(default)s)", ) parser.add_argument( "--restore-margin-c", diff --git a/docs/amp-control.md b/docs/amp-control.md index e6b60ec..362cf6e 100644 --- a/docs/amp-control.md +++ b/docs/amp-control.md @@ -34,12 +34,25 @@ reality. `model` and `trajectory` are trusted, but **not symmetrically**: alert 40 fired inside exactly that gap during live testing. - **Restoring up is the risky direction** (it's what pushes the equilibrium back toward the trip point), so it stays conservative on every axis: only - `trajectory` basis, one `--restore-step-a` at a time rather than snapping - straight back to `--normal-amps`, and never while the handle is within - `--restore-margin-c` of the trip point even if the trajectory reads clear. - Snapping straight back to full current, twice, immediately restarted the - climb both times during live testing — turning a caught derate into - repeated near-misses before a third one wasn't caught in time. + `trajectory` basis, never straight back to `--normal-amps` on trust, and + never while the handle is within `--restore-margin-c` of the trip point + even if the trajectory reads clear. Snapping straight back to full + current, twice, immediately restarted the climb both times during live + testing — turning a caught derate into repeated near-misses before a + third one wasn't caught in time. +- **Where it restores *to* is the model's answer, in one move.** The server + reports `sustainable_max_a` on every forecast: the highest current whose + modelled plateau stays under the trip point at today's ambient (the LAN + sensor when one reports, else the ambient the live trajectory implies — + the sensor is preferred because a plateau measured at a low current + carries the current law's extrapolation error, and it comes back doubled + when rescaled to a high one). The daemon restores straight to that (or to full rate + when that is what it says), and lets the trajectory and the confidence + guard trim the last amp or two. It used to climb `--restore-step-a` at a + time instead — but every rung resets the trajectory window, so a climb + from 32 A took half an hour to find the same number. The model is trusted + once per session: after a quick reversal it has already been wrong about + today, and the climb falls back to single `--restore-step-a` steps. Either direction needs a signal held for `--confirm-ticks` consecutive polls (default 3) before acting — a single noisy fit can't flip a real amp change. @@ -81,8 +94,8 @@ untrustworthy forecast is. As a window matures its SE shrinks, and the guard relaxes tick by tick on its own. A cap fully lifts three ways: the trajectory forecast reports the risk has -passed *and* the handle has real thermal margin (stepped up gradually, see -above), the charging session ends (restored immediately — no more climb to +passed *and* the handle has real thermal margin (restored to the +sustainable current, see above), the charging session ends (restored immediately — no more climb to protect against), or — a safety net — a new session starts while the daemon's on-disk state still says "capped" from a run that never saw its session close out (crash, restart, etc.). That last case always restores @@ -125,7 +138,11 @@ sudo ./deploy/install-derate-amp-control.sh --tesla-ble http:// --pr Once every `--probe-interval-days` (default 30), the first charging session to come along is held at `--probe-amps` for `--probe-hold-min` (default 40) -minutes, then released. Pick a current low enough that neither this daemon +minutes, then restored to the sustainable current its own plateau implies — +the probe is the best measurement of today's conditions the session will +get, so its end is the one moment a restore target is most trustworthy. +(Releasing to full rate instead, on a day the model already knew full rate +would trip, produced a 32 → 48 → 42 A oscillation.) Pick a current low enough that neither this daemon nor the vehicle wants to reduce it — on a 48 A install where foldback starts around 61 °C, 32 A plateaus near 53 °C with room to spare. Hold it for more than ~3x the install's time constant (`model.tau_min` in `/api/thermal`) so diff --git a/docs/thermal-model.md b/docs/thermal-model.md index cc1b24d..f9d02d5 100644 --- a/docs/thermal-model.md +++ b/docs/thermal-model.md @@ -23,6 +23,17 @@ the trip happens. the default has a standing cost — the fitter judges a charge's window against the default τ, so only charges of ~22 min or more at steady current teach the model there. +- **How rise scales with current is fitted too.** Joule heating says rise + ∝ I², and that is the prior — but a real handle carries heat that does + not scale with current (the charger's own electronics, cable heat soak), + so measured plateaus fall off more gently as current drops. On one + install a 32 A probe settled 4 °C above what I² predicted, and every + forecast at an off-reference current inherited the error — including + the current the amp controller was told to restore to. Once the + free-running fits span ≥ 6 A of current, the exponent *n* in + rise = rise₄₈ · (I/48)ⁿ is fitted by log-log regression (`current_exp` + in `/api/thermal`, with its standard error), every fit's `rise_ref_c` is + re-normalized with it, and the model note says so. - **The charger is its own thermometer.** Idle, the handle sits ~1–2 °C above ambient (an ambient-dependent offset), so ambient can be read without any extra sensor. The offset model ships as a seed from one install and is @@ -165,7 +176,7 @@ coefficient. The reported Δ is that slope times the observed span. It did once compare a recent median against a baseline median, and that asks the wrong question. "Are the last few fits higher?" is answered for you by anything that moved with the calendar: a garage that cooled between -the two halves, or a vehicle capped to a lower current whose (48/I)² +the two halves, or a vehicle capped to a lower current whose (48/I)ⁿ normalization then lifts every recent fit at once. On one install the split reported **+7.2 °C with a 95 % CI of [5.4, 9.1]** — "statistically confirmed" — for a connector whose rise, regressed on time with ambient and @@ -223,10 +234,11 @@ row. More sessions either confirm it or dissolve it. typical and pooled the operating current out of its own comparison). - **Pooled across a wide current band when the fits are clean.** Ambient-bracketed fits join from a wider band, and the regression's own - current term then *adjusts* them: residual error in the I² normalization - lands on that coefficient instead of masquerading as a trend. On the - install above that coefficient read −0.99 °C per amp, which is the whole - of the phantom +7.2 °C. The band is wide enough on purpose to admit a + current term then *adjusts* them: residual error in the current + normalization lands on that coefficient instead of masquerading as a + trend. On the install above, under the I² prior, that coefficient read + −0.99 °C per amp, which is the whole of the phantom +7.2 °C — and is + what the fitted exponent now removes at the source. The band is wide enough on purpose to admit a [calibration probe](amp-control.md#the-calibration-probe). - **Never only stale sessions.** If none of the newest few free-running charges make it into the comparison, the install has moved to a current diff --git a/tests/test_derate_amp_control.py b/tests/test_derate_amp_control.py index 65e592a..0ba71d3 100644 --- a/tests/test_derate_amp_control.py +++ b/tests/test_derate_amp_control.py @@ -58,6 +58,9 @@ def _thermal( steady_state_se_c: float | None = None, fit_rmse_c: float | None = 0.3, trip_c: float = 65.0, + # Absent by default: the ladder is the behavior against a server that + # doesn't report a sustainable current, and most tests pin that. + sustainable: float | None = None, ): return { "state": state, @@ -72,6 +75,7 @@ def _thermal( "suggested_max_a": suggested, "steady_state_c": steady_state_c, "steady_state_se_c": steady_state_se_c, + "sustainable_max_a": sustainable, }, } @@ -175,6 +179,54 @@ def test_restore_steps_up_gradually_instead_of_snapping_to_full(): assert action.kind == "none" and state.clear_streak == 1 and state.cap_value == 42.0 +def test_restore_jumps_to_the_sustainable_current_in_one_move(): + # 2026-09-14: climbing 2 A at a time from a 32 A probe would have taken + # ~30 min to find the 44 A the model could name at once — every rung + # resets the trajectory window. With the server reporting a sustainable + # current, the first confirmed-clear restore goes straight there. + cfg = _cfg(confirm_ticks=1, restore_step_a=2.0, restore_margin_c=3.0) + state = dac.State(capped=True, cap_value=32.0, last_session_state="charging") + clear = _thermal(will_trip=False, mtt=None, suggested=None, handle_c=50.0, sustainable=44.0) + action, state, reason = dac.decide(clear, state, cfg) + assert action.kind == "cap" and action.value == 44.0 + assert state.capped and state.cap_value == 44.0 and state.last_step_up_ts == clear["ts"] + assert "jumping to 44A" in reason + + +def test_restore_jump_to_normal_amps_fully_lifts(): + cfg = _cfg(confirm_ticks=1, restore_step_a=2.0, restore_margin_c=3.0) + state = dac.State(capped=True, cap_value=32.0, last_session_state="charging") + clear = _thermal(will_trip=False, mtt=None, suggested=None, handle_c=50.0, sustainable=48.0) + action, state, reason = dac.decide(clear, state, cfg) + assert action.kind == "restore" and action.value == 48.0 + assert not state.capped and "fully restoring" in reason + + +def test_restore_never_jumps_below_a_ladder_step(): + # The trajectory is the measurement; if it reads clear with margin, a + # model that says "you are already at the limit" doesn't get to hold + # the cap where it is. The old single step is the floor for progress. + cfg = _cfg(confirm_ticks=1, restore_step_a=2.0, restore_margin_c=3.0) + state = dac.State(capped=True, cap_value=40.0, last_session_state="charging") + clear = _thermal(will_trip=False, mtt=None, suggested=None, handle_c=50.0, sustainable=39.0) + action, state, reason = dac.decide(clear, state, cfg) + assert action.kind == "cap" and action.value == 42.0 + assert "stepping up to 42A" in reason + + +def test_restore_falls_back_to_the_ladder_after_a_quick_reversal(): + # The model is trusted once per session. A jump that got capped again + # is the model having been wrong about today; the next climb crawls. + cfg = _cfg(confirm_ticks=1, restore_step_a=2.0, restore_margin_c=3.0) + state = dac.State(capped=True, cap_value=40.0, last_session_state="charging", restore_attempts=1) + clear = _thermal(will_trip=False, mtt=None, suggested=None, handle_c=50.0, sustainable=46.0) + action, state, _ = dac.decide(clear, state, cfg) + assert action.kind == "none" # backoff: 1 * 2^1 = 2 confirming polls now + action, state, reason = dac.decide(clear, state, cfg) + assert action.kind == "cap" and action.value == 42.0 + assert "stepping up" in reason + + def test_restore_fully_lifts_once_the_step_reaches_normal_amps(): cfg = _cfg(confirm_ticks=1, restore_step_a=2.0, restore_margin_c=3.0) state = dac.State(capped=True, cap_value=47.0, last_session_state="charging") @@ -530,6 +582,8 @@ def test_probe_holds_against_the_restore_path(): def test_probe_completes_after_the_hold_and_restores(): + # No sustainable current from the server (older wallmonitor): release + # to full rate as before. held = dac.State( last_session_state="charging", capped=True, cap_value=32.0, probe_started_ts=1_000_000.0 - 41 * 60, @@ -541,6 +595,45 @@ def test_probe_completes_after_the_hold_and_restores(): assert not new.capped +def test_probe_completes_to_the_sustainable_current(): + # 2026-09-14, first live probe: released to 48 A on a day the idle + # forecast had already said full rate would trip, so the controller had + # to cap again 3.5 min later (32 -> 48 -> 42 A). The probe's own plateau + # is the best measurement of the day; its end goes straight to the + # current that plateau implies is sustainable. + held = dac.State( + last_session_state="charging", capped=True, cap_value=32.0, + probe_started_ts=1_000_000.0 - 41 * 60, + ) + action, new, reason = dac.decide(_thermal(will_trip=False, sustainable=44.0), held, _probe_cfg()) + assert action.kind == "cap" and action.value == 44.0 + assert "probe complete" in reason and "sustainable 44A" in reason + assert new.capped and new.cap_value == 44.0 + assert new.probe_started_ts is None and new.last_probe_ts == 1_000_000.0 + # Counts as a step-up, so a cap soon after is a quick reversal and backs off. + assert new.last_step_up_ts == 1_000_000.0 + + +def test_probe_completes_holding_when_nothing_higher_is_sustainable(): + held = dac.State( + last_session_state="charging", capped=True, cap_value=32.0, + probe_started_ts=1_000_000.0 - 41 * 60, + ) + action, new, reason = dac.decide(_thermal(will_trip=False, sustainable=30.0), held, _probe_cfg()) + assert action.kind == "none" + assert new.capped and new.cap_value == 32.0 and "holding" in reason + assert new.probe_started_ts is None and new.last_probe_ts == 1_000_000.0 + + +def test_probe_completes_to_full_rate_when_that_is_sustainable(): + held = dac.State( + last_session_state="charging", capped=True, cap_value=32.0, + probe_started_ts=1_000_000.0 - 41 * 60, + ) + action, new, _ = dac.decide(_thermal(will_trip=False, sustainable=48.0), held, _probe_cfg()) + assert action.kind == "restore" and action.value == 48.0 and not new.capped + + def test_safety_cap_below_probe_current_wins_and_abandons_the_probe(): # A real thermal decision outranks calibration, and a window whose # current just moved teaches nothing — so it is abandoned, not banked. diff --git a/tests/test_wallmonitor.py b/tests/test_wallmonitor.py index 59d5512..6bd6f94 100644 --- a/tests/test_wallmonitor.py +++ b/tests/test_wallmonitor.py @@ -335,7 +335,8 @@ def _seed_idle(db, t_from, t_to, ambient_c, dt=10.0): def _seed_thermal_session(db, start_ts, ambient_c, tau_s=720.0, rise_ref_c=36.0, amps=48.6, charge_s=1500.0, dt=10.0, - ambient_end_c=None, cooldown_s=0.0, sag_to_a=None): + ambient_end_c=None, cooldown_s=0.0, sag_to_a=None, + current_exp=2.0): """Idle lead-in plus a charging ramp that follows the first-order model. With ambient_end_c set, ambient drifts linearly across the charge (the @@ -353,7 +354,7 @@ def _seed_thermal_session(db, start_ts, ambient_c, tau_s=720.0, rise_ref_c=36.0, _seed_idle(db, start_ts - 1800, start_ts, ambient_c, dt) sid = db.start_session(start_ts) t0_temp = thermal.idle_handle_c(ambient_c) - rise_at = rise_ref_c * (amps / thermal.REF_CURRENT_A) ** 2 + rise_at = rise_ref_c * (amps / thermal.REF_CURRENT_A) ** current_exp integrate = ambient_end_c is not None or sag_to_a is not None temp = t0_temp ts = start_ts @@ -370,7 +371,7 @@ def _seed_thermal_session(db, start_ts, ambient_c, tau_s=720.0, rise_ref_c=36.0, if integrate: ambient_now = ambient_c if ambient_end_c is None else \ ambient_c + (ambient_end_c - ambient_c) * elapsed - rise_now = rise_ref_c * (amps_now / thermal.REF_CURRENT_A) ** 2 + rise_now = rise_ref_c * (amps_now / thermal.REF_CURRENT_A) ** current_exp temp += dt * ((ambient_now + rise_now - temp) / tau_s) ts += dt db.close_session(sid, start_ts + charge_s, "vehicle_disconnected") @@ -392,6 +393,43 @@ async def test_thermal_fit_recovers_model(db): assert params.fitted and params.tau_fits == 1 and params.rise_fits == 1 assert abs(params.tau_min - 12.0) < 1.5 assert abs(params.rise_ref_c - 36.0) < 3.0 + # One current cannot identify how rise scales with current: the prior stands. + assert params.current_exp == thermal.DEFAULT_CURRENT_EXP and params.current_exp_fits == 0 + + +async def test_thermal_fits_the_current_exponent_from_a_current_spread(db): + # A handle whose rise falls off more gently than I^2 as current drops + # (part of the heat is current-independent). Seeded with n = 1.5 at + # three currents; the fitter must recover n rather than assume 2, and + # re-normalize every fit's rise_ref_c with it so the 48 A number comes + # out the same from every current. + now = time.time() + for i, amps in enumerate((48.6, 32.0, 40.0, 48.6, 32.0, 40.0)): + _seed_thermal_session(db, now - (7 - i) * 7200, ambient_c=25.0, amps=amps, current_exp=1.5) + fits = thermal.fit_sessions(db, now) + params = thermal.fit_history(db, now, fits=fits) + assert params.current_exp_fits == 6 + assert abs(params.current_exp - 1.5) < 0.15 + assert params.current_exp_se is not None and params.current_exp_se < 0.15 + assert abs(params.rise_ref_c - 36.0) < 2.0 + for fit in fits: + assert abs(fit["rise_ref_c"] - 36.0) < 2.5, fit + # Under the I^2 prior the same 32 A windows would have read ~5 C high. + low = [fit for fit in fits if fit["current_a"] < 33.0] + assert all(fit["rise_c"] * (48.0 / fit["current_a"]) ** 2 > 40.0 for fit in low) + + +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) + # 40 C ambient leaves 23 C of headroom under trip minus margin. + assert thermal.sustainable_max_current(40.0, square) == 38.0 + assert thermal.sustainable_max_current(40.0, gentle) == 35.0 + # Cool enough for full rate: sustainable saturates at the reference + # current, and there is no cap to suggest. + assert thermal.sustainable_max_current(20.0, square) == 48.0 + assert thermal.suggest_max_current(20.0, square) is None + assert thermal.suggest_max_current(40.0, gentle) == 35.0 async def test_thermal_predict_charging_trajectory(db): @@ -410,6 +448,8 @@ async def test_thermal_predict_charging_trajectory(db): # Seeded ambient 35.4 C implies a ~42 A cap avoids the trip entirely. assert forecast["suggested_max_a"] is not None assert abs(forecast["suggested_max_a"] - 42.0) <= 1.0 + # ...and that same number is what a restore should aim for. + assert forecast["sustainable_max_a"] == forecast["suggested_max_a"] async def test_thermal_predict_cooling_after_current_cut(db): @@ -443,6 +483,11 @@ async def test_thermal_predict_cooling_after_current_cut(db): assert out["state"] == "charging" forecast = out["forecast"] assert forecast["basis"] == "trajectory" + # No trip predicted, so no cap is *suggested* — but the sustainable + # current is reported regardless: it is what a restore climbs back to. + assert forecast.get("suggested_max_a") is None + assert isinstance(forecast["sustainable_max_a"], float) + assert 30.0 < forecast["sustainable_max_a"] < 48.0 assert forecast["will_trip"] is False # Cooling toward ~49 C while the handle still reads ~56 C. assert forecast["steady_state_c"] < out["handle_c"] - 3.0 diff --git a/wallmonitor/static/app.js b/wallmonitor/static/app.js index 9212a9c..a5e4bf6 100644 --- a/wallmonitor/static/app.js +++ b/wallmonitor/static/app.js @@ -1045,6 +1045,9 @@ async function viewLive(root) { chip = chipFor("good", "no derate expected"); lines.push(`Handle is at ${fmtNum(data.handle_c, 1)} °C, settling near ~${fmtNum(forecast.steady_state_c, 1)} °C — ` + `below the ${fmtNum(model.trip_c, 0)} °C alert-40 threshold.`); + if (forecast.sustainable_max_a && forecast.sustainable_max_a < model.ref_current_a) { + lines.push(`At today's ambient the highest rate that stays under it is ~${fmtNum(forecast.sustainable_max_a, 0)} A.`); + } } lines.push(forecast.basis === "trajectory" ? "Based on the handle's temperature trajectory over the last few minutes." @@ -1139,8 +1142,14 @@ async function viewLive(root) { `${io.segments} idle segments over ${io.days} days, ±${fmtNum(io.ambient_se_c, 1)} °C).` : ` Idle-offset model is the built-in seed from one install (±${fmtNum(io.ambient_se_c, 1)} °C on handle-derived ambient); ` + "a stationary sensor posting to /api/ambient calibrates it here automatically."; + // The current law is a prior (I²) until this install's own fits span + // enough current to measure it; say which one every forecast rests on. + const expNote = model.current_exp_fits > 0 + ? ` Heat rise scales as I^${fmtNum(model.current_exp, 2)} here (fitted across ${model.current_exp_fits} free-running ` + + "sessions; the I² prior over-reads how much lower currents cool the handle)." + : ""; const modelNote = `Model: τ ≈ ${fmtNum(model.tau_min, 1)} min, +${fmtNum(model.rise_ref_c, 0)} °C at ${fmtNum(model.ref_current_a, 0)} A — ` + - (model.fitted ? `fitted from ${model.tau_fits} recorded session ramp${model.tau_fits === 1 ? "" : "s"}.` + priorNote + (model.fitted ? `fitted from ${model.tau_fits} recorded session ramp${model.tau_fits === 1 ? "" : "s"}.` + priorNote + expNote : "defaults from one verified install, used until this charger has fits of its own; refits automatically as sessions accumulate.") + (drift && !drift.drifting && !drift.lead ? ` Heat rise stable across ${drift.n} free-running fitted sessions` + `${drift.off_current_n ? ` (${drift.off_current_n} off-current session${drift.off_current_n === 1 ? "" : "s"} excluded)` : ""}.` : "") + diff --git a/wallmonitor/thermal.py b/wallmonitor/thermal.py index 1746526..27ed3b4 100644 --- a/wallmonitor/thermal.py +++ b/wallmonitor/thermal.py @@ -185,6 +185,21 @@ def ambient_from_idle_handle(handle_c: float, model: IdleOffset = BUILTIN_IDLE_O DEFAULT_TAU_MIN = 12.0 DEFAULT_RISE_REF_C = 36.0 +# How heat rise scales with charge current: rise(I) = rise_ref * (I/48)^n. +# Joule heating alone says n = 2, and that is the prior. Real handles read +# lower: part of the rise is current-independent (the charger's own +# electronics, cable heat soak) and convection stiffens as the handle warms, +# so measured plateaus fall off more gently than I^2 as current drops. On one +# install a 32 A probe settled 4 C above what n = 2 predicted, and every +# forecast at an off-reference current inherited that error — including the +# cap the amp controller was told to restore to. The exponent is therefore +# fitted per install from free-running fits once they span enough current +# to identify it, and stays at the prior until they do. +DEFAULT_CURRENT_EXP = 2.0 +CURRENT_EXP_MIN_FITS = 4 +CURRENT_EXP_MIN_SPAN_A = 6.0 +CURRENT_EXP_RANGE = (1.0, 2.5) + # Fit acceptance gates: a segment must actually contain a thermal ramp and # the exponential must describe it well, or it teaches the model nothing. MIN_SEGMENT_S = 480.0 @@ -268,11 +283,22 @@ class ThermalParams: tau_fits: int = 0 rise_fits: int = 0 fit_rmse_c: float | None = None + current_exp: float = DEFAULT_CURRENT_EXP + current_exp_fits: int = 0 + current_exp_se: float | None = None @property def fitted(self) -> bool: return self.tau_fits > 0 and self.rise_fits > 0 + def rise_at(self, current_a: float) -> float: + """Steady-state rise above ambient at a charge current.""" + return self.rise_ref_c * (current_a / REF_CURRENT_A) ** self.current_exp + + def current_for_rise(self, rise_c: float) -> float: + """The charge current whose steady-state rise is rise_c — rise_at inverted.""" + return REF_CURRENT_A * (rise_c / self.rise_ref_c) ** (1.0 / self.current_exp) + # How far a fitted value may sit from the default before the dashboard # says the priors were a poor fit for this install. A heuristic, not a # statistic: 30% is roughly where the default-driven forecast's plateau @@ -315,6 +341,9 @@ def as_dict(self) -> dict: "fit_rmse_c": round(self.fit_rmse_c, 3) if self.fit_rmse_c is not None else None, "fitted": self.fitted, "prior_deviation": self.prior_deviation(), + "current_exp": round(self.current_exp, 2), + "current_exp_fits": self.current_exp_fits, + "current_exp_se": round(self.current_exp_se, 2) if self.current_exp_se is not None else None, } @@ -734,7 +763,9 @@ def fit_sessions(db: Database, now: float, lookback_days: float = 120.0) -> list ambient_end = None # refit failed gates; fall back rise = None if ambient is not None: - rise = (t_inf - ambient) * (REF_CURRENT_A / i_med) ** 2 + # Normalized with the I^2 prior here; fit_history re-normalizes + # every fit with the install's own exponent once it has one. + rise = (t_inf - ambient) * (REF_CURRENT_A / i_med) ** DEFAULT_CURRENT_EXP if not (RISE_RANGE_C[0] <= rise <= RISE_RANGE_C[1]): rise = None sag_a = _current_sag_a(prefix) @@ -744,6 +775,7 @@ def fit_sessions(db: Database, now: float, lookback_days: float = 120.0) -> list "start_ts": seg[0][0], "tau_min": round(tau_s / 60.0, 2), "rise_ref_c": round(rise, 2) if rise is not None else None, + "rise_c": round(t_inf - ambient, 2) if rise is not None else None, "rmse_c": round(rmse, 3), "current_a": round(i_med, 1), "current_sag_a": round(sag_a, 2), @@ -766,11 +798,51 @@ def fit_sessions(db: Database, now: float, lookback_days: float = 120.0) -> list return fits +def _fit_current_exponent(fits: list[dict]) -> tuple[float, int, float | None]: + """The exponent n in rise = rise_ref * (I/48)^n, from free-running fits: + (n, fits used, standard error). The prior with no fits used when the + history cannot identify it — too few free-running fits, or all at one + current, where any n explains the data equally well. + + Log-log least squares: ln(rise) = ln(rise_ref) + n * ln(I/48). Only + free-running windows count — a regulated window's plateau is lower for a + reason that has nothing to do with current, and those windows cluster at + high current, which would bend n downward for the wrong reason.""" + points = [ + (math.log(fit["current_a"] / REF_CURRENT_A), math.log(fit["rise_c"])) + for fit in fits + if fit.get("rise_c") is not None and fit["rise_c"] > 0 + and fit.get("current_a") and fit.get("free_plateau", True) + ] + if len(points) < CURRENT_EXP_MIN_FITS: + return DEFAULT_CURRENT_EXP, 0, None + currents = [REF_CURRENT_A * math.exp(x) for x, _ in points] + if max(currents) - min(currents) < CURRENT_EXP_MIN_SPAN_A: + return DEFAULT_CURRENT_EXP, 0, None + result = _ols([y for _, y in points], [[1.0, x] for x, _ in points]) + if result is None: + return DEFAULT_CURRENT_EXP, 0, None + beta, se, _resid, _dof = result + exponent = min(CURRENT_EXP_RANGE[1], max(CURRENT_EXP_RANGE[0], beta[1])) + return exponent, len(points), se[1] + + def fit_history(db: Database, now: float, lookback_days: float = 120.0, fits: list[dict] | None = None) -> ThermalParams: - """Aggregate per-session fits into model parameters; defaults where thin.""" + """Aggregate per-session fits into model parameters; defaults where thin. + + Fits the install's current exponent first and re-normalizes every fit's + rise_ref_c with it, in place — so the fits handed on to the API and the + degradation watch, and the rise_ref_c median taken here, all share one + current law. With the I^2 prior, off-reference fits carry a bias that the + watch's current term then has to absorb; with a fitted exponent that + term has nothing left to explain.""" if fits is None: fits = fit_sessions(db, now, lookback_days) + exponent, exp_fits, exp_se = _fit_current_exponent(fits) + for fit in fits: + if fit.get("rise_c") is not None and fit.get("current_a"): + fit["rise_ref_c"] = round(fit["rise_c"] * (REF_CURRENT_A / fit["current_a"]) ** exponent, 2) taus = [fit["tau_min"] for fit in fits] rises = [fit["rise_ref_c"] for fit in fits if fit["rise_ref_c"] is not None] rmses = [fit["rmse_c"] for fit in fits] @@ -780,6 +852,9 @@ def fit_history(db: Database, now: float, lookback_days: float = 120.0, tau_fits=len(taus), rise_fits=len(rises), fit_rmse_c=median(rmses) if rmses else None, + current_exp=exponent, + current_exp_fits=exp_fits, + current_exp_se=exp_se, ) @@ -1155,21 +1230,36 @@ def _minutes_to_trip(t_now: float, t_inf: float, tau_min: float) -> float | None SUGGEST_MARGIN_C = 2.0 # keep the suggested current's steady state this far under the trip -def suggest_max_current(ambient_c: float, params: ThermalParams) -> float | None: +def sustainable_max_current(ambient_c: float, params: ThermalParams) -> float | None: """Highest charge current whose steady-state handle temp stays safely - below the trip point at the given ambient — the alternative to letting - the charger fold back to a blunt 50%. Vehicles take whole amps, so the - value is floored. None when even a minimal rate would trip (or when no - cap is needed at all, i.e. full rate is already safe).""" + below the trip point at the given ambient, capped at the reference rate + (the Gen 3's own maximum, and as far as the model is validated). Vehicles + take whole amps, so the value is floored. None when even the J1772 + minimum would trip. + + Reported on every forecast so the amp controller can restore straight to + this rather than climb toward full rate in steps: one move to the + model's answer, with the trajectory and its confidence guard left to + trim the last amp or two. Worked from a measured ambient when a sensor + is reporting — see predict() for why the trajectory-implied one is only + the fallback.""" headroom = TRIP_HANDLE_C - SUGGEST_MARGIN_C - ambient_c if headroom <= 0: return None - amps = math.floor(REF_CURRENT_A * math.sqrt(headroom / params.rise_ref_c)) + amps = math.floor(params.current_for_rise(headroom)) if amps < 6: # J1772 floor — below this the vehicle won't charge anyway return None - if amps >= REF_CURRENT_A: - return None # full rate is safe; no cap to suggest - return float(amps) + return float(min(amps, REF_CURRENT_A)) + + +def suggest_max_current(ambient_c: float, params: ThermalParams) -> float | None: + """The sustainable current as a *cap suggestion*: the alternative to + letting the charger fold back to a blunt 50%. None when no cap is needed + (full rate is already safe) or when even a minimal rate would trip.""" + amps = sustainable_max_current(ambient_c, params) + if amps is None or amps >= REF_CURRENT_A: + return None + return amps def _project_t_inf(window: list[tuple[float, float]], tau_min: float) -> tuple[float, float | None]: @@ -1222,8 +1312,8 @@ def _recent_steady_ambient(recent: list[dict], params: ThermalParams) -> float | mid-session current change resets the live trajectory window — but the buffer usually still holds an earlier steady run (this session's stretch before the change, or the previous session's tail). Its projected steady - state at that run's current implies the ambient, which the I^2 model then - rescales to the present current. + state at that run's current implies the ambient, which the current law + then rescales to the present current. """ runs: list[list[dict]] = [[]] for sample in recent: @@ -1244,7 +1334,7 @@ def _recent_steady_ambient(recent: list[dict], params: ThermalParams) -> float | window = [(sample["ts"], sample["handle_temp_c"]) for sample in run] t_inf, _se = _project_t_inf(window, params.tau_min) run_current = median(sample["vehicle_current_a"] for sample in run) - ambient = t_inf - params.rise_ref_c * (run_current / REF_CURRENT_A) ** 2 + ambient = t_inf - params.rise_at(run_current) if -30.0 <= ambient <= TRIP_HANDLE_C: return ambient return None @@ -1304,10 +1394,10 @@ def predict(db: Database, now: float, params: ThermalParams) -> dict: forecast["steady_state_se_c"] = round(max(t_inf_se, 0.1), 2) if t_inf_se is not None else None else: # Too early at this current for a slope: model from ambient and - # the present current scaled by I^2. Ambient comes from the LAN - # sensor when one is reporting, else the idle stretch before the - # session, or — when sessions run back-to-back and there was - # none — from the newest steady run in the buffer. + # the present current through the install's current law. Ambient + # comes from the LAN sensor when one is reporting, else the idle + # stretch before the session, or — when sessions run back-to-back + # and there was none — from the newest steady run in the buffer. measured = _latest_measured_ambient(db, now) ambient, source = measured if measured is not None else (None, None) if ambient is None: @@ -1325,7 +1415,7 @@ def predict(db: Database, now: float, params: ThermalParams) -> dict: gap_reason = "warming_up" out["forecast"] = {"basis": "insufficient", "will_trip": None, "reason": gap_reason} return out - t_inf = ambient + params.rise_ref_c * (current / REF_CURRENT_A) ** 2 + t_inf = ambient + params.rise_at(current) forecast["basis"] = "model" forecast["ambient_source"] = source # A model-basis plateau is only as good as its ambient: a sensor @@ -1336,19 +1426,31 @@ def predict(db: Database, now: float, params: ThermalParams) -> dict: # below the handle is real, not noise — it's what cooling toward a # lower equilibrium looks like after a current cut or a derate. minutes = _minutes_to_trip(last["handle_temp_c"], t_inf, tau_min) + # Ambient implied by the steady state at this current: today's + # conditions read back through the model. Accurate near the + # reference current, where most fits are; at a low current it + # carries the current law's extrapolation error, and that error + # returns doubled when rescaled to a high current. So the sustainable + # current is worked from a measured ambient whenever a sensor is + # reporting — interpolation inside the fitted data rather than a + # round trip to 32 A and back (on one install, after a 32 A probe, + # the implied route named 47 A; the measured one 44 A, which held). + implied_ambient = t_inf - params.rise_at(current) + measured = _latest_measured_ambient(db, now) + sustain_ambient, sustain_source = measured if measured is not None else (implied_ambient, "implied") forecast.update( { "steady_state_c": round(t_inf, 1), "will_trip": minutes is not None, "minutes_to_trip": round(minutes, 1) if minutes is not None else None, "trip_ts": last["ts"] + minutes * 60.0 if minutes is not None else None, + "sustainable_max_a": sustainable_max_current(sustain_ambient, params), + "sustainable_ambient_source": sustain_source, } ) if minutes is not None: - # Ambient implied by the steady state at this current; from it, - # the highest cap that avoids the trip (and the 50% foldback). - ambient = t_inf - params.rise_ref_c * (current / REF_CURRENT_A) ** 2 - forecast["suggested_max_a"] = suggest_max_current(ambient, params) + # The highest cap that avoids the trip (and the 50% foldback). + forecast["suggested_max_a"] = suggest_max_current(implied_ambient, params) out["forecast"] = forecast return out @@ -1381,6 +1483,7 @@ def predict(db: Database, now: float, params: ThermalParams) -> dict: "trip_ts": None, "safe_ambient_max_c": round(TRIP_HANDLE_C - params.rise_ref_c, 1), "suggested_max_a": suggest_max_current(ambient, params) if minutes is not None else None, + "sustainable_max_a": sustainable_max_current(ambient, params), } return out