Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/thermal-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions tests/test_wallmonitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions wallmonitor/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
27 changes: 27 additions & 0 deletions wallmonitor/thermal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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(),
}


Expand Down
Loading