Repository navigation
feat: automate a monthly calibration probe, and stop discarding its fits - #30
Merged
Merged
Conversation
The degradation watch can only compare charges whose current held steady through the ramp. On this install those are scarce and land wherever the capping stopped — and the reason is the monitor itself: the amp controller caps on this model's own forecast, 285 times in a month, contaminating 7 of 18 fitted windows. The charger's internal foldback (lifetime thermal_foldbacks) had not fired once in the same period, so the previous commit's attribution of the current sag to the charger was wrong; the comments and docs are corrected. It is a feedback loop: the forecast caps the current, the cap contaminates the fit, the fit feeds the forecast. The component that moves charge current is also the one that can hold it still on purpose. --probe-amps holds a chosen current for --probe-hold-min every --probe-interval-days, giving the watch a plateau nothing trimmed at a repeatable operating point. It lands as a layer over decide() rather than a branch inside it: that logic is tuned by a string of live incidents and the probe has no business reaching into it. Precedence is the contract — a thermal cap below the probe current wins and abandons the probe, the probe outranks restoring, and only a completed hold updates the cadence, so an early unplug retries next time instead of consuming the month. The probe is useless if its fits are then discarded, and they were: at 32 A against a 48 A install they fell outside the pooling band. Three things fix that, all of which the regression made safe and none of which the median split could have afforded: - the pooling band widens (0.25 -> 0.45). It was narrow to stop one off-current fit swinging a median; there is no median now, every fit that reaches the comparison held its current steady, and the current term adjusts what is left. - "typical current" becomes the median of the whole comparable history rather than the newest three fits. Chasing the newest current was necessary when only like could be compared with like; now it just lets an occasional off-current charge become typical and invert the band. Two probes landing together did exactly that, pooling the operating current out of its own comparison. - not chasing the newest current needs its own guard against judging from stale data, so a verdict must now reach into the newest few free-running charges or report nothing. Deliberately "the newest few", not "the newest": one odd charge must not blank an otherwise current watch. Verdict on the production database is unchanged (delta 0.30 C, CI [-2.83, 3.43]); typical_current_a now reads 48.6 A, the install's actual operating current, rather than 39.6 A. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Follow-up to #29, and it starts with a correction to that PR.
#29 attributed the in-window current sag to the charger's own thermal
regulation. That was wrong. The charger's internal foldback is counted by
lifetime.thermal_foldbacks, and on this install it has not incrementedsince 08-03 — through every one of the 18 fitted sessions:
The sag is mostly the monitor's own doing. The amp controller caps on
this model's forecast — 285
amp_cappedevents in a month — and 6 of the 7windows the free-plateau gate excluded have controller events inside them
(the seventh looks like vehicle taper). So it is a feedback loop: the
forecast caps the current, the cap contaminates the fit, the fit feeds the
forecast. The gate and the verdict in #29 are unaffected — a window whose
current moved is contaminated whoever moved it — but the comments and docs
explaining why were wrong and are corrected here.
That also reframes the fix. Steady windows on this install are scarce (11 of
18) and land at whatever current the capping happened to stop at, split
across 39.6 / 44.4 / 48.6 A — a moving target, and one correlated with the
weather, since a hot garage triggers capping sooner.
The component that moves charge current is the one that can hold it still on
purpose.
Code touched
contrib/derate_amp_control.py—--probe-ampsholds a chosen currentfor
--probe-hold-min(default 40) every--probe-interval-days(default30). Off unless set.
Implemented as
_apply_probe, a layer over the renamed_decide_thermal,rather than a branch inside it: that logic is tuned by a string of live
incidents and the probe has no business reaching into it. Precedence is the
entire contract:
the probe — a window whose current just moved teaches nothing;
exactly what would ruin the measurement (it also pins
clear_streakatzero, so the restore path cannot bank confirming polls while the probe
runs and spend them the instant it ends);
session that needed a real cap retries next time rather than consuming the
month;
--probe-ampsis validated against--min-amps/--normal-amps.Stategains two fields;
load_statealready ignores unknown keys and defaultsmissing ones, so an existing state file upgrades silently (tested).
wallmonitor/thermal.py— a probe is useless if its fits are discarded,and they were: 32 A against a 48 A install fell outside the pooling band.
Three changes, each safe because #29 replaced the median split with a
regression, and none of which the median split could have afforded:
swinging a median; there is no median now, every fit reaching the
comparison held its current steady, and the current term adjusts the rest.
typical_current_ais now the median of the whole comparable history, notthe newest three fits. Chasing the newest current was necessary when only
like could be compared with like. Now it is actively harmful: two probes
landing in the newest three made the probe current "typical" and pooled
the operating current out of its own comparison. Caught by the new test,
not by inspection.
so a comparison must reach into the newest
DRIFT_RECENCY_N(3)free-running charges or report nothing — which also lets a stale alert
clear. Deliberately "the newest few", not "the newest": one odd charge
must not blank an otherwise current watch.
Assumption stated inline: 32 A is this install's safe probe current
(foldback starts ~61 °C; 32 A plateaus ~53 °C). The docs tell other
installs how to choose rather than presenting 32 as universal.
Deliberately left alone: the controller is currently stuck holding 40 A
("giving up after 3 quick reversals") and runs with
--forecast-confidence-k 4against a default of 2, which is why it caps sooften. Both are live-tuning questions, not code defects, and changing either
would alter derate protection — out of scope here, flagged for a decision.
Also still open from #29: regulated fits continue to feed the live forecast,
biasing it toward under-predicting derates.
Risk
That is the cost, and it is why the feature is off by default.
not complete. That is preferred to banking a window it did not hold.
thermal logic allows, cannot start below an active cap, and any cap under
the probe current takes precedence.
_decide_thermalis byte-for-byte theold
decide(); a--probe-amps 0config is asserted identical to thepre-change behavior.
band, different
typical_current_a. On the production database the verdictis unchanged (Δ 0.30 °C, CI [−2.83, 3.43]);
typical_current_amoves39.6 → 48.6 A, which is the install's actual operating current.
Nonewhere a verdict previously appeared,after a sustained current change. That clears the alert rather than
latching a stale one.
Stategains fields; old state files load.Verification
142 tests pass (
uv run pytest), 39 of them intests/test_derate_amp_control.py. The 2 pre-existingruffE731 warningsare untouched;
bash -nclean on the installer.Nine tests added. Probe: disabled-is-identical, starts when due, respects the
interval, holds against the restore path (and cannot bank
clear_streak), completesand restores, safety cap wins and abandons, unplug does not consume the
slot, never starts from a lower cap, old state file upgrades and is then due.
Analysis: a 32 A probe interleaved among 48 A charges reaches the regression
(
n == len(fits),off_current_n == 0) and keeps current separable from thecalendar (
collinear_with_timeempty) — the test that caught thetypical-current inversion.
Two existing tests updated where the mechanism changed (
typicalis now theusual current, not the newest; the "can't judge yet" case is now enforced by
the staleness guard rather than by chasing the current). Their intent is
preserved and re-stated in the comments.
Re-run against the production database with this branch's code:
Deploy:
--probe-amps 32has not been applied to the mini-PC — that is aseparate
install-derate-amp-control.shrun once this merges. Note also thatwallmonitor.servicethere is still running the pre-#29 code; the restart ispending and best done between charging sessions.
🤖 Generated with Claude Code