Skip to content

feat: fit how rise scales with current, and restore straight to the sustainable rate - #31

Merged
zebraengine merged 1 commit into
mainfrom
feat/current-exponent-sustainable-restore
Sep 15, 2026
Merged

zebraengine merged 1 commit into
mainfrom
feat/current-exponent-sustainable-restore

Conversation

@zebraengine

Copy link
Copy Markdown
Owner

Problem

The first live calibration probe (2026-09-14) held 32 A for 40 min, then released to 48 A on a day the idle forecast had already said full rate would trip (safe_ambient_max_c 28.7 at 28.9 °C ambient). 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 defects behind it:

  1. The model assumed rise ∝ I². It doesn't on this install: the 32 A probe settled at 49.9 °C against a 45.6 °C prediction, and the two 39.6 A fits read 42.7 / 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² as current drops. Every off-reference forecast — including the cap the controller is told to restore to — inherited the error, and the drift regression's current term (−0.80 °C/A) was absorbing it.
  2. The controller restored on trust. Probe completion snapped to normal_amps; every other restore climbed 2 A per confirmed-clear cycle. Each rung resets the trajectory window, so a climb from 32 A took ~30 min to find a number the model could name at once.

Code touched

  • wallmonitor/thermal.py
    • ThermalParams gains current_exp (+ fits used, SE) with rise_at() / current_for_rise(); every (I/48)**2 site now goes through them. as_dict() exposes the three fields.
    • _fit_current_exponent(): log-log OLS over free-running fits; needs ≥ 4 fits spanning ≥ 6 A, clamped to [1.0, 2.5], else the I² prior with current_exp_fits: 0. Regulated fits excluded — they cluster at high current and would bend n down for the wrong reason.
    • fit_sessions() records raw rise_c per fit; fit_history() fits n first and re-normalizes every fit's rise_ref_c in place, so the API, the drift watch and the median all share one current law. No schema change — fits are recomputed, not stored.
    • sustainable_max_current(): highest current under trip − margin, capped at 48; suggest_max_current() becomes a thin wrapper (None when ≥ 48). predict() reports sustainable_max_a on every forecast (charging + idle) plus sustainable_ambient_source. Measured LAN ambient is preferred over the trajectory-implied one — see Verification for why.
  • contrib/derate_amp_control.py
    • Restore path: first confirmed-clear restore goes to min(normal, max(ladder step, sustainable)). Trusted once per session — restore_attempts > 0 falls back to single steps. Field absent → old ladder, byte-for-byte.
    • Probe completion: cap to the sustainable current (sets last_step_up_ts, so a re-cap is a quick reversal and backs off); hold at probe current if sustainable is no higher; full restore if it says 48 or the field is absent.
    • Module docstring, restore-path comment, --restore-step-a help updated. Left alone: cap path, confidence guard, backoff, suggested_max_a semantics.
  • wallmonitor/static/app.js: model note says Heat rise scales as I^n here (fitted across N free-running sessions) when fitted; charging "no derate" line shows the sustainable rate when it's under 48.
  • docs/thermal-model.md, docs/amp-control.md: current-law bullet, restore-target paragraph, probe release wording, I² mentions.

Risk

  • Restores move further in one step. Today: 32 → 44 in one move instead of six 2 A rungs. Guards unchanged: trajectory-only, 3 °C handle margin, confirm ticks, backoff, max_restore_attempts. A wrong jump is a quick reversal → ladder for the rest of the session.
  • With --forecast-confidence-k 4, a fresh trajectory window's wide SE may trim 2 A right after a jump, then the ladder returns — worst case one extra round, self-resolving.
  • rise_ref_c values on the API/scatter shift slightly for off-reference fits (the point of the change); 48 A fits move < 0.7 °C.
  • Older controller against new server: ignores the new field. New controller against older server: ladder as before.
  • Drift verdict on production: unchanged (delta −0.33, CI [−2.0, 1.34], not drifting).

Verification

  • uv run pytest: 151 passed. New: exponent recovered from an n = 1.5 seed across 32/40/48 A (±0.15, every fit re-normalized to ~36); prior stands with one current; sustainable_max_current follows n (38 vs 35 A at 40 °C ambient) and saturates at 48; predict reports the field on trajectory basis with and without a predicted trip; controller: jump, jump-to-48 lifts fully, never below a ladder step, ladder after a quick reversal, probe → sustainable / hold / full restore.
  • Replayed on the production DB (read-only, scratch worktree on the mini-PC): n = 1.20 ± 0.08 from 13 fits; 39.6 A fits re-read 36.7 / 37.2 °C; drift current coefficient −0.80 → −0.06 °C/A, residual sd 0.94 → 0.85.
  • The replay caught a bug in the first cut. Computing the sustainable current from the trajectory-implied ambient at the probe's end gave 47 A: n = 1.2 is the local slope over 39.6–48.6 A and over-reads the rise at 32 A by 2 °C, so implied ambient read 27.3 vs the sensor's 30.25, and the error comes back doubled at 47 A — which would have tripped. From the sensor's ambient it gives 44 A, matching what actually held (42.6 A plateaued at 60.3). Hence measured-ambient-first.

Deploy: pull + sudo systemctl restart wallmonitor.service. The amp-control oneshot re-reads its script every 30 s; no unit change.

🤖 Generated with Claude Code

…ustainable 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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant