fix(control): the peak limit is checked against the site's fuse - #813
Merged
Conversation
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>
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.
The rider deliberately left out of #803: "
SetPeakLimitaccepts 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
PeakLimitWis peak-shaving mode's import threshold: above it the PI corrects, below it the site runs free. Not to be confused withPeakImportCeilingW, the hard cross-mode tariff ceiling.Two operator paths reach it, and neither validated anything:
POST /api/peak_limitpeak_limit_w→haCallbacks.SetPeakLimit/api/peak_import_ceiling;setPeakLimit()inapp.jsis defined and never calledThe 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/statusand 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.PeakImportCeilingWcan 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 > PeakLimitWbranch 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 thegridW < 0charge 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.
0here is not the zero-means-disabled sentinel thatPeakImportCeilingWandMaxExportWuse. In peak shaving,PeakLimitW = 0means "correct everything above 0 W of import", and dispatch has always read it that way —TestPeakShavingClampsToIdealTargetand friends pin it on master. Peak shaving is a mode: you switch it off by leaving the mode, not by zeroing its threshold. Redefining0as "disabled" would have let a site runningpeak_shavingimport without limit. One wire value must not mean two things — that is the Ferroamppplim=0lesson, 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 <= 0means the site's breaker was never described (test and e2e harnesses). Same back-compat rulefuseSafetyMarginWandperPhaseOverageWalready keep: an incomplete fuse description yields no clamp rather than an invented one.The operator has to hear about it
400with the value sent and the ceiling that beat it.SetModedoes. HA's retained state topic republishes the value FTW actually holds, so the slider snaps back.SetPeakLimitcannot see that coming from the other direction.PeakLimitIsDead()is checked after the reload re-wires the fuse fields, and warns. Gated onpeak_shavingmode, 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 assignsst.PeakLimitWby direct field write (golden_dump_test.go:286, 1225, 1289, 1416), so the new validation is not even on the replay path.Result:
TestGoldenCorpusReplaypasses,git statusontestdata/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 a400would tell the operator nothing, and the slider'smaxis hardcoded11000rather 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_whas no validation at all inconfig.Validate— negative silently reads as disabled (> 0guard), above-fuse is dead. Genuinely the same rule and it belongs inValidate. Left out because fix(config): enforce owner-only ACL on Windows saves #797 is open ongo/internal/config/config.goand has right of way.PeakLimitWis never persisted./api/targetand/api/peak_import_ceilingbothSaveConfig; 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, andPeakLimitIsDeadafter a fuse shrink.go/internal/api/api_peak_limit_test.go— the endpoint: 2 of these 5 fail on master (status = 200, want 400for above-fuse and for negative); the 3 accept-cases pass on master and here, which is the point — the unchanged behaviour stayed unchanged.make verifyclean (it also runs in this repo's pre-commit hook).🤖 Generated with Claude Code