diff --git a/docs/thermal-model.md b/docs/thermal-model.md index 878c4df..0cd39d3 100644 --- a/docs/thermal-model.md +++ b/docs/thermal-model.md @@ -15,8 +15,14 @@ the trip happens. - **Parameters are fitted per install** (`wallmonitor/thermal.py`) from your own recorded charging ramps: the time constant τ and the steady-state rise - at 48 A. Defaults come from a telemetry-verified alert-40 event and are - replaced as sessions accumulate. + at 48 A. Defaults come from a telemetry-verified alert-40 event on one + install and are replaced as sessions accumulate. Once fitted, the + dashboard's model note says so when this install landed far (>30%) from + those priors: forecasts before the first fit were governed by numbers + that did not describe this charger, and an install with a τ well under + 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. - **The charger is its own thermometer.** Idle, the handle sits ~1–2 °C above ambient (a calibrated, ambient-dependent offset — see `contrib/calibrate_idle_offset.py`), so ambient can be read without any diff --git a/tests/test_wallmonitor.py b/tests/test_wallmonitor.py index d3b6fe6..ba301a6 100644 --- a/tests/test_wallmonitor.py +++ b/tests/test_wallmonitor.py @@ -634,6 +634,21 @@ async def test_thermal_fit_slow_tau_install_still_fits(db): assert fit["rise_ref_c"] is not None and abs(fit["rise_ref_c"] - rise) < 3.0 +def test_thermal_params_report_prior_deviation(): + # Unfitted: nothing to compare. Fitted near the defaults: reported but + # not notable. Fitted far off (a fast-tau, low-rise install): notable, + # with the sign the UI needs to warn that short charges won't fit. + assert thermal.ThermalParams().prior_deviation() is None + near = thermal.ThermalParams(tau_min=12.5, rise_ref_c=34.0, tau_fits=3, rise_fits=3) + dev = near.prior_deviation() + assert dev["notable"] is False and abs(dev["tau_frac"]) < 0.1 + far = thermal.ThermalParams(tau_min=6.0, rise_ref_c=20.0, tau_fits=3, rise_fits=3) + dev = far.prior_deviation() + assert dev["notable"] is True and dev["tau_frac"] < -0.3 and dev["rise_frac"] < -0.3 + assert dev["default_tau_min"] == thermal.DEFAULT_TAU_MIN + assert far.as_dict()["prior_deviation"] == dev + + async def test_thermal_fit_covers_late_charging_segments(db): # A session shaped like real overnight use: a plug-in burst too short to # fit, hours of connected idle, then distinct charging segments (vehicle diff --git a/wallmonitor/static/app.js b/wallmonitor/static/app.js index bd7a7ac..b59125f 100644 --- a/wallmonitor/static/app.js +++ b/wallmonitor/static/app.js @@ -1101,9 +1101,26 @@ async function viewLive(root) { (drift.off_current_n ? ` (${drift.off_current_n} session${drift.off_current_n === 1 ? "" : "s"} away from the usual ` + `~${fmtNum(drift.typical_current_a, 0)} A excluded from the comparison.)` : "")); } + // The defaults come from one verified install. Once this install has + // fits of its own, say plainly when they landed far from those priors: + // the forecast before the first fit was governed by numbers that did + // not describe this charger, and a fast tau has a standing cost — the + // fitter's identifiability gate is floored at the default tau, so short + // charges on such an install never teach the model. + const dev = model.prior_deviation; + let priorNote = ""; + if (model.fitted && dev && dev.notable) { + const parts = []; + if (Math.abs(dev.tau_frac) > 0.3) parts.push(`τ ${fmtNum(model.tau_min, 1)} min vs the ${fmtNum(dev.default_tau_min, 0)} min default`); + if (Math.abs(dev.rise_frac) > 0.3) parts.push(`rise +${fmtNum(model.rise_ref_c, 0)} °C vs the +${fmtNum(dev.default_rise_ref_c, 0)} °C default`); + priorNote = ` This install differs from the built-in priors (${parts.join("; ")}) — forecasts before its first ` + + `fitted session were rough` + + (dev.tau_frac < -0.3 ? `, and with a τ this fast only charges of ≥ ${fmtNum(1.8 * dev.default_tau_min, 0)} min ` + + "at steady current teach the model." : "."); + } 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"}.` - : "defaults from the verified alert-40 event; refits automatically as sessions accumulate.") + + (model.fitted ? `fitted from ${model.tau_fits} recorded session ramp${model.tau_fits === 1 ? "" : "s"}.` + priorNote + : "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 the last ${drift.recent_n + drift.baseline_n} fitted sessions` + `${drift.off_current_n ? ` (${drift.off_current_n} off-current session${drift.off_current_n === 1 ? "" : "s"} excluded)` : ""}.` : ""); thermalCard.append(el("div", { class: "chart-card" }, diff --git a/wallmonitor/thermal.py b/wallmonitor/thermal.py index 2e1df64..2f4e980 100644 --- a/wallmonitor/thermal.py +++ b/wallmonitor/thermal.py @@ -153,6 +153,32 @@ class ThermalParams: def fitted(self) -> bool: return self.tau_fits > 0 and self.rise_fits > 0 + # 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 + # error exceeds the fit's own noise and early-session predictions were + # materially off. + PRIOR_DEVIATION_FRAC = 0.30 + + def prior_deviation(self) -> dict | None: + """How this install's fitted tau and rise compare to the defaults + that governed the forecast before its first fit landed — None until + fitted. The frontend renders it as an honesty note: the priors are + from one verified install, and a user whose charger differs should + know that early forecasts were rough and (for a fast tau) that short + charges no longer teach the model.""" + if not self.fitted: + return None + tau_frac = self.tau_min / DEFAULT_TAU_MIN - 1.0 + rise_frac = self.rise_ref_c / DEFAULT_RISE_REF_C - 1.0 + return { + "default_tau_min": DEFAULT_TAU_MIN, + "default_rise_ref_c": DEFAULT_RISE_REF_C, + "tau_frac": round(tau_frac, 3), + "rise_frac": round(rise_frac, 3), + "notable": max(abs(tau_frac), abs(rise_frac)) > self.PRIOR_DEVIATION_FRAC, + } + def as_dict(self) -> dict: """The `model` object served by /api/thermal and the SSE thermal frame — fitted values plus the fixed thresholds.""" @@ -168,6 +194,7 @@ def as_dict(self) -> dict: "rise_fits": self.rise_fits, "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(), }