Skip to content

fix(control): the peak limit is checked against the site's fuse - #813

Merged
frahlg merged 1 commit into
masterfrom
agent/validate-peak-limit-fuse
Aug 5, 2026
Merged

fix(control): the peak limit is checked against the site's fuse#813
frahlg merged 1 commit into
masterfrom
agent/validate-peak-limit-fuse

Conversation

@frahlg

@frahlg frahlg commented Aug 5, 2026

Copy link
Copy Markdown
Member

The rider deliberately left out of #803: "SetPeakLimit accepts an operator value with no validation against the fuse rating. A peak limit above the fuse is meaningless; one at zero silently turns every import into an error. Separate change, separate risk."

What the setting is, and every path into it

PeakLimitW is peak-shaving mode's import threshold: above it the PI corrects, below it the site runs free. Not to be confused with PeakImportCeilingW, the hard cross-mode tariff ceiling.

Two operator paths reach it, and neither validated anything:

Path Before
POST /api/peak_limit stored the value as sent
HA MQTT number peak_limit_whaCallbacks.SetPeakLimit stored the value as sent, and the bridge discarded the returned error
config load / hot reload no YAML key exists — nothing to validate
web UI the slider posts /api/peak_import_ceiling; setPeakLimit() in app.js is defined and never called

The rules, and why each one

Above the fuse → reject. Every import-side clamp already binds at fuseMaxW − safety margin. A threshold above that can never be the first thing to bind, so peak-shaving mode does nothing the fuse guard was not already doing — while the operator reads their number back from /api/status and believes the tariff is defended. That is worse than useless; it is misleading.

Reject rather than clamp: clamping stores a number the operator never typed and the only signal is a status page they may not open. Rejection is the repo idiom (config.Validate, and /api/peak_import_ceiling's own negative check) and it can say why.

Compared against the fuse, not effectiveImportCeilingW. PeakImportCeilingW can bind lower, so a peak limit under it is also dead — but that ceiling is another operator knob. Validating a knob against a knob makes acceptance depend on the order the two were typed: set the limit first and it passes, drop the ceiling afterwards and the same value is now dead but stored. The fuse is a property of the site. A peak limit under the fuse but over a tighter tariff ceiling is redundant, not misleading — the tighter number is already doing the operator's stated job.

Negative → reject. With a negative threshold, dispatch's gridW > PeakLimitW branch turns a site sitting at zero grid into a positive error and commands discharge to force export; the band between the limit and zero meanwhile falls into the gridW < 0 charge arm, so the two halves of one setting disagree. A knob named for an import peak must not be able to order export.

Zero → accepted, meaning unchanged. This is the part worth reading twice. 0 here is not the zero-means-disabled sentinel that PeakImportCeilingW and MaxExportW use. In peak shaving, PeakLimitW = 0 means "correct everything above 0 W of import", and dispatch has always read it that way — TestPeakShavingClampsToIdealTarget and friends pin it on master. Peak shaving is a mode: you switch it off by leaving the mode, not by zeroing its threshold. Redefining 0 as "disabled" would have let a site running peak_shaving import without limit. One wire value must not mean two things — that is the Ferroamp pplim=0 lesson, applied in the opposite direction.

No lower bound beyond zero. A ceiling below the site's base load binds hard, not silently: the battery covers what it can and the rest shows up as import over the limit, visible in the data. We have no quantified hardware or control risk to point at, so per the repo's own invariant ("every clamp protects a quantified hardware or control risk") we do not police it.

Undescribed fuse → sign check only. SiteFuseAmps <= 0 means the site's breaker was never described (test and e2e harnesses). Same back-compat rule fuseSafetyMarginW and perPhaseOverageW already keep: an incomplete fuse description yields no clamp rather than an invented one.

The operator has to hear about it

  • API: 400 with the value sent and the ceiling that beat it.
  • HA: the bridge stops discarding the error and logs it the way SetMode does. HA's retained state topic republishes the value FTW actually holds, so the slider snaps back.
  • Hot reload: a config reload can lower the fuse under a limit that was legal when it was set — SetPeakLimit cannot see that coming from the other direction. PeakLimitIsDead() is checked after the reload re-wires the fuse fields, and warns. Gated on peak_shaving mode, because in every other mode the threshold is unread and the warning would be noise.

Both setters share one method, (*State).SetPeakLimit — same reason #803 collapsed three copies of the fuse-saver into one helper.

Golden corpus: predicted before running, and it held

Prediction, written down before the replay: zero records move. The change adds a setter and a fuse-watts helper; neither is reachable from ComputeDispatch. No dispatch arithmetic changes. And the golden harness assigns st.PeakLimitW by direct field write (golden_dump_test.go:286, 1225, 1289, 1416), so the new validation is not even on the replay path.

Result: TestGoldenCorpusReplay passes, git status on testdata/golden/ is empty. No fixture regenerated, no tolerance touched.

Siblings: same hole, deliberately not fixed here

  • PeakImportCeilingW (POST /api/peak_import_ceiling) rejects negatives but not above-fuse — the same hole, and it is the field the live web slider writes. Not fixed here on purpose: the UI's .catch() swallows the response, so a 400 would tell the operator nothing, and the slider's max is hardcoded 11000 rather than derived from the fuse. That fix needs a UI error path and a fuse-derived slider bound in the same change; doing half of it would trade a silent dead setting for a silent failed save.
  • site.max_export_w has no validation at all in config.Validate — negative silently reads as disabled (> 0 guard), above-fuse is dead. Genuinely the same rule and it belongs in Validate. Left out because fix(config): enforce owner-only ACL on Windows saves #797 is open on go/internal/config/config.go and has right of way.
  • DSO caps: no such setting exists in this repo. The only "DSO" reference is a comment about feed-in fees in the price model.
  • PeakLimitW is never persisted. /api/target and /api/peak_import_ceiling both SaveConfig; this one does not, so an operator's peak limit silently reverts to the built-in 5000 W on restart. Real bug, different bug — it changes restart behaviour and deserves its own risk review.

Testing

  • go/internal/control/peak_limit_test.go — the rule: accept under the fuse, reject above it, reject inside the safety margin, accept the ceiling exactly, accept 0 and prove dispatch still shaves against it, reject negative, no-fuse back-compat, untouched default, and PeakLimitIsDead after a fuse shrink.
  • go/internal/api/api_peak_limit_test.go — the endpoint: 2 of these 5 fail on master (status = 200, want 400 for above-fuse and for negative); the 3 accept-cases pass on master and here, which is the point — the unchanged behaviour stayed unchanged.
  • make verify clean (it also runs in this repo's pre-commit hook).

🤖 Generated with Claude Code

SetPeakLimit stored whatever the operator sent. A threshold above the
breaker can never be the first thing to bind — every import clamp already
stops at fuse minus margin — so the setting read as armed and did
nothing. A negative one is worse: the shaving arm treats it as an error
to correct and commands discharge to force export, from a knob named for
import.

Both are refused now, in one place both operator paths share
(POST /api/peak_limit and the Home Assistant number), with a message
naming the value sent and the ceiling that beat it. The HA bridge stops
discarding that error.

Zero keeps the meaning dispatch already gives it — correct everything
above 0 W of import. It is deliberately not the zero-means-disabled
convention PeakImportCeilingW and MaxExportW use: peak shaving is a mode,
switched off by leaving it, not by zeroing its threshold. The comparison
is against the fuse rather than effectiveImportCeilingW so acceptance
does not depend on the order two operator knobs were typed. A site whose
fuse is not described gets the sign check only.

A config reload that lowers the fuse can strand a limit that was legal
when set; that path warns instead of going quiet.

Golden corpus: predicted zero movement before running — no dispatch
arithmetic changes and the harness writes PeakLimitW directly, bypassing
the setter. Replay passes, no fixture touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@frahlg
frahlg merged commit b16016e into master Aug 5, 2026
13 checks passed
@frahlg
frahlg deleted the agent/validate-peak-limit-fuse branch August 5, 2026 06:20
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