Skip to content

fix(loadmodel): anchor outlier rejection to the fuse, not to learned MAE - #742

Merged
frahlg merged 3 commits into
masterfrom
fix/loadmodel-outlier-lockout
Jul 31, 2026
Merged

fix(loadmodel): anchor outlier rejection to the fuse, not to learned MAE#742
frahlg merged 3 commits into
masterfrom
fix/loadmodel-outlier-lockout

Conversation

@frahlg

@frahlg frahlg commented Jul 31, 2026

Copy link
Copy Markdown
Member

Closes #739 — though not for the reason that issue originally gave. See the correction comment there.

The defect

A rejected sample updates neither MAE nor Samples. So the band max(MAE × 10, 200) can never grow in response to being persistently wrong. Measured on a clean NewModel(10000):

after one overnight hour at 400 W:  samples=60  MAE=57.0  band=570.5
then four hours at 5 kW:            accepted=0  rejected=240
prediction for that hour:           1794 W  (truth 5000 W)
after a full week at 5 kW:          1794 W  — MAE still 57.0

100% rejection, permanently. MAE only moves on accepted samples and nothing is accepted.

Why the band goes, rather than getting fixed

My first attempt kept the band and let a run of same-direction rejections widen it. It worked — spike still rejected, sustained shift learned within ten minutes. It was also propping up the wrong instrument, and review caught that.

Household load is multimodal. A few hundred watts of baseline for most of the day, then 11 kW when the sauna, oven and car overlap. Both are true readings. Nothing about a residual's size separates "unusual but real" from "wrong", so a band fitted to the quiet hours always excludes the busy ones. The lockout was the visible failure; discarding the top of the distribution was the normal operation.

It was also redundant. Telemetry already runs a Kalman filter per signal, and this model reads SmoothedW. Short-term measurement noise is handled one layer down, where it belongs. The band was a second filter at the wrong altitude, doing damage the first one had already made unnecessary.

What replaces it

The rejection physics licenses, and no other: a house cannot draw more than its main fuse passes.

MaxPlausibleW = fuse capacity × 1.25, threaded in from configuration. Deliberately not derived from loadPeakW — that is a tunable proxy for "typical peak" (currently fuse × 0.5), while this is physics. Deriving one from the other would silently move a safety bound the next time somebody retunes the proxy. With no fuse configured the check disables itself rather than inventing a limit.

Because the ceiling comes from hardware and never from what the model has learned, it holds from the first sample and a mislearned model cannot talk it down — which is exactly the property the old band lacked.

Behaviour after

  • a sustained shift to a new level is learned directly, no concession mechanism needed
  • 200 W → 11 kW → 300 W → 9.5 kW all train; that is a household, not a fault
  • a one-minute spike still trains, because it is real, and the bucket EMA damps it to a tenth of the gap — which is what keeps an hour from being defined by its loudest minute
  • 50 kW past a 16 A service is still rejected

Five tests cover those. TestRejectsOutliers is kept and updated rather than deleted: same behaviour asserted, now with the physical ceiling set, plus a comment on why the band version also rejected genuine peaks.

make verify clean.

Note on scope

This does not prove the reported site hit this defect; /api/loadmodel from that box would settle it. It reproduces from a clean model in seconds and its signature matches what was reported.

🤖 Generated with Claude Code

A rejected sample updates neither MAE nor Samples, so the band
max(MAE × 10, 200) could never grow in response to being persistently
wrong. With the filter arming after 50 samples — 51 minutes at the 60 s
cadence — a model calibrated on one quiet hour rejected the real house
permanently.

Measured on a clean NewModel(10000):

  after one overnight hour at 400 W:  samples=60  MAE=57.0  band=570.5
  then four hours at 5 kW:            accepted=0  rejected=240
  prediction for that hour:           1794 W  (truth 5000 W)
  after a full week at 5 kW:          1794 W  — MAE still 57.0

100% rejection, permanently, because MAE only moves on accepted samples
and nothing is accepted. The failure is silent and reads as a forecast
problem from outside: a small reported error next to an arbitrarily large
real one.

Three changes:

- Hard bound at 3 × PeakW, always on. Above the site's rated draw a
  reading is a fault, not a household. Absolute and derived from
  configured hardware, so it holds from the first sample and cannot be
  talked down by a model that has mislearned — this is what keeps the
  first day safe now that the soft filter arms later.
- The soft filter arms after a day rather than an hour, so its band spans
  a night-and-day cycle instead of whichever hour followed a restart.
- Ten consecutive same-direction rejections widen the band by exactly
  enough to admit the residual; the ordinary EMA takes over from there.
  Accepting the one sample and resetting was not enough — the band stayed
  narrow, the next nine were rejected too, and a day at a new level only
  reached 1650 of 3000 W.

After: the 3-minute 6 kW spike is still rejected in full, a sustained
shift is picked up within ten minutes, and every trained bucket tracks it
exactly.

Refs #739

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c74980a3ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/internal/loadmodel/model.go Outdated
Comment on lines +311 to +312
if m.PeakW > 0 && actualLoadW > implausibleLoadFactor*m.PeakW {
return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject hard faults before fitting the heating coefficient

When an implausible reading arrives during cold weather for a bucket with at least MinTrustSamples, the heating-fit block above has already applied its residual to HeatingW_per_degC before this check returns false. Consequently, even one rejected meter fault can alter cold-weather forecasts, and repeated faults can drive the coefficient to HeatingCoefMaxW and predictions to the 3 * PeakW ceiling while Update reports that no update was applied. Perform this absolute fault check before the heating fit; only the adaptive soft filter needs to run afterward.

Useful? React with 👍 / 👎.

The MAE band rejected the upper half of the real load distribution, and
could lock the model out of a level it had not seen before, permanently.

A rejected sample updates neither MAE nor Samples, so the band
max(MAE × 10, 200) never grew in response to being persistently wrong.
Measured on a clean NewModel(10000):

  after one overnight hour at 400 W:  samples=60  MAE=57.0  band=570.5
  then four hours at 5 kW:            accepted=0  rejected=240
  prediction for that hour:           1794 W  (truth 5000 W)
  after a full week at 5 kW:          1794 W  — MAE still 57.0

The first fix attempt kept the band and let a run of same-direction
rejections widen it. That worked, but it was propping up the wrong
instrument. Household load is multimodal — a few hundred watts of baseline,
then 11 kW when the sauna, oven and car overlap — and nothing about a
residual's size separates "unusual but real" from "wrong". A band fitted to
the quiet hours always excludes the busy ones, so the filter's normal
operation, not just its failure mode, was discarding real consumption.

It was also redundant. Telemetry already runs a Kalman filter per signal
and this model reads the smoothed values, so short-term noise is handled
one layer down, where it belongs.

What remains is the rejection physics licenses: a house cannot draw more
than its main fuse passes. The ceiling is fuse capacity × 1.25, threaded in
from configuration rather than derived from loadPeakW — that one is a
tunable proxy for "typical peak", this is physics, and deriving the second
from the first would silently move a safety bound whenever somebody
retuned the proxy. No fuse configured disables the check rather than
inventing a limit.

A sustained shift is now learned directly. A one-minute spike still trains,
because it is real, and the bucket EMA damps it to a tenth of the gap.

Refs #739

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@frahlg frahlg changed the title fix(loadmodel): stop the outlier filter locking out a new load level fix(loadmodel): anchor outlier rejection to the fuse, not to learned MAE Jul 31, 2026
The physical bound ran after the online heating fit, so a cold-weather
meter fault applied its residual to HeatingW_per_degC and only then got
rejected — with Update still reporting that no update was applied. A run
of faults could walk the coefficient toward its ceiling, inflating every
cold-weather prediction from readings the model had ostensibly discarded.

Measured with the bound in its old position: 40 rejected 50 kW samples at
0 °C moved the coefficient from 0 to 919 W/°C. At an 18 °C delta that is
16.5 kW of invented heating load.

A fault must touch nothing on its way out — not the buckets, not MAE, not
the heating coefficient — so the bound is now the first thing Update does
after the negative-load guard. The comment about fitting heating before
the outlier filter explained why a stale coefficient needs to be able to
recover from the adaptive band; it never applied to an absolute physical
limit, and that band is gone anyway.

The regression test keeps every sample inside one hour-of-week bucket. The
first version let the faults land in the next hour, where the fit is gated
on bucket trust rather than on the bound — it passed against the buggy
ordering and proved nothing.

Codex P2 on PR #742.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@frahlg

frahlg commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Codex P2 addressed in 7389102 — it was a real one, thanks.

The bound sat below the online heating fit, so a cold-weather fault applied its residual to HeatingW_per_degC and only then got rejected, with Update still reporting no update applied. Measured with the bound in its old position: 40 rejected 50 kW samples at 0 °C moved the coefficient from 0 to 919 W/°C — 16.5 kW of invented heating load at an 18 °C delta, from readings the model had ostensibly discarded.

A fault must touch nothing on its way out, so the bound is now the first thing Update does after the negative-load guard. The existing comment about fitting heating before the outlier filter was about letting a stale coefficient recover from the adaptive band; it never applied to an absolute physical limit, and that band is gone in this PR anyway.

Worth recording how close the regression test came to being useless: the first version let the faults land in the following hour, where the fit is gated on bucket trust rather than on the bound. It passed against the buggy ordering. It now asserts every sample stays inside one hour-of-week bucket, and fails with heating coefficient moved on rejected faults: 0.0000 → 919.0176 when the bound is moved back.

@frahlg
frahlg merged commit 41e59ef into master Jul 31, 2026
13 checks passed
@frahlg
frahlg deleted the fix/loadmodel-outlier-lockout branch July 31, 2026 12:47
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.

Load model systematically underestimates on sites with large PV

1 participant