Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/peak-limit-validated-against-fuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"ftw": patch
---

The peak-shaving import limit is now checked against the site's fuse. It
used to be stored exactly as sent, from the API and from the Home
Assistant number alike, so a limit above the breaker was accepted and
then never bound: every import clamp already stops at the fuse, so the
operator read their number back from the status page and believed a
tariff peak was defended that nothing was defending. A negative limit was
worse than useless — the shaving arm treats it as an error to correct and
commands the battery to push power out, from a setting named for import.
Both are refused now, with a message naming the value sent and the
ceiling that beat it. A limit of 0 still means what it always meant in
peak shaving, "correct everything above zero import"; peak shaving is
switched off by leaving the mode, not by zeroing its threshold. A site
whose fuse is not described in the config keeps the old behaviour. When a
config reload lowers the fuse under a limit that was legal when it was
set, the log says so.
22 changes: 20 additions & 2 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -807,7 +807,20 @@ func main() {
// explicit 0 → disabled. See EffectiveSafetyMarginA.
ctrl.SiteFuseSafetyA = newCfg.Fuse.EffectiveSafetyMarginA()
ctrl.MaxExportW = newCfg.Site.MaxExportW
// Lowering the fuse can strand a peak limit that was legal when it
// was set. SetPeakLimit refuses to create that state; only a reload
// can arrive at it from the other side, and it is silent — the
// threshold simply stops being the first thing to bind. Say so
// while the operator is still looking at the config they just saved.
// Peak shaving only: in every other mode the threshold is unread,
// so warning about it would be noise.
peakCeilingW, peakDead := ctrl.PeakLimitIsDead()
peakLimitW, peakMode := ctrl.PeakLimitW, ctrl.Mode == control.ModePeakShaving
ctrlMu.Unlock()
if peakDead && peakMode {
slog.Warn("peak limit is now above the site's import ceiling and cannot bind",
"peak_limit_w", peakLimitW, "import_ceiling_w", peakCeilingW)
}

// Keep the loadpoint controller's per-phase EV fuse clamp in
// sync with hot-reloaded fuse params — previously startup-only,
Expand Down Expand Up @@ -3893,11 +3906,16 @@ func haCallbacks(ctx context.Context, ctrl *control.State, ctrlMu *sync.Mutex, s
ctrl.SetGridTarget(w)
return st.SaveConfig("grid_target_w", strconv.FormatFloat(w, 'f', 1, 64))
},
// Same validation as POST /api/peak_limit — an HA number can be
// dragged past the site's fuse just as easily as an API caller can
// post past it, and the two setters must not diverge (#mode-drift
// again, one field down). The returned error reaches the bridge,
// which logs it; HA's own state topic republishes the value FTW
// actually holds, so the operator sees the number snap back.
SetPeakLimit: func(w float64) error {
ctrlMu.Lock()
defer ctrlMu.Unlock()
ctrl.PeakLimitW = w
return nil
return ctrl.SetPeakLimit(w)
},
SetEVCharging: func(w float64, active bool) error {
ctrlMu.Lock()
Expand Down
13 changes: 11 additions & 2 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1412,7 +1412,11 @@ func (s *Server) handleSetTarget(w http.ResponseWriter, r *http.Request) {
}

// ---- /api/peak_limit ----

//
// Peak-shaving mode's import threshold. Validated against the site's
// fuse — a limit above the breaker can never bind, and a negative one
// would order export. See control.State.SetPeakLimit for the rules and
// why 0 stays a real threshold here rather than "disabled".
func (s *Server) handleSetPeakLimit(w http.ResponseWriter, r *http.Request) {
var req struct {
PeakLimitW float64 `json:"peak_limit_w"`
Expand All @@ -1422,8 +1426,13 @@ func (s *Server) handleSetPeakLimit(w http.ResponseWriter, r *http.Request) {
return
}
s.deps.CtrlMu.Lock()
s.deps.Ctrl.PeakLimitW = req.PeakLimitW
err := s.deps.Ctrl.SetPeakLimit(req.PeakLimitW)
s.deps.CtrlMu.Unlock()
if err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
slog.Info("peak limit changed", "w", req.PeakLimitW)
writeJSON(w, 200, map[string]any{"status": "ok", "peak_limit_w": req.PeakLimitW})
}

Expand Down
100 changes: 100 additions & 0 deletions go/internal/api/api_peak_limit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package api

import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"

"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/telemetry"
)

// POST /api/peak_limit is one of the two operator paths into the
// peak-shaving threshold (the other is the Home Assistant number). Both
// share control.State.SetPeakLimit; these tests pin that the endpoint
// actually routes through it and reports a rejection instead of storing
// a limit the fuse guard would make meaningless.

// 16 A × 230 V × 3 phases = 11040 W, less the 0.5 A margin = 10695 W.
func newPeakLimitServer(t *testing.T) (*Server, *control.State) {
t.Helper()
st := control.NewState(0, 50, "ferroamp")
st.SiteFuseAmps = 16
st.SiteFuseVoltage = 230
st.SiteFusePhases = 3
st.SiteFuseSafetyA = 0.5
srv := New(&Deps{Ctrl: st, CtrlMu: &sync.Mutex{}, Tel: telemetry.NewStore()})
return srv, st
}

func postPeakLimit(t *testing.T, srv *Server, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/peak_limit", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
return rr
}

func TestPeakLimitEndpointAcceptsValueUnderTheFuse(t *testing.T) {
srv, st := newPeakLimitServer(t)
if rr := postPeakLimit(t, srv, `{"peak_limit_w":7000}`); rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", rr.Code, rr.Body.String())
}
if st.PeakLimitW != 7000 {
t.Errorf("PeakLimitW = %.0f, want 7000", st.PeakLimitW)
}
}

func TestPeakLimitEndpointRejectsValueAboveTheFuse(t *testing.T) {
srv, st := newPeakLimitServer(t)
rr := postPeakLimit(t, srv, `{"peak_limit_w":20000}`)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", rr.Code, rr.Body.String())
}
// The caller has to learn why, here and not from dispatch later.
if !strings.Contains(rr.Body.String(), "10695") {
t.Errorf("response must name the ceiling that beat it, got %s", rr.Body.String())
}
if st.PeakLimitW != 5000 {
t.Errorf("rejected value must not land: PeakLimitW = %.0f, want the untouched 5000", st.PeakLimitW)
}
}

func TestPeakLimitEndpointRejectsNegative(t *testing.T) {
srv, st := newPeakLimitServer(t)
if rr := postPeakLimit(t, srv, `{"peak_limit_w":-2000}`); rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", rr.Code, rr.Body.String())
}
if st.PeakLimitW != 5000 {
t.Errorf("rejected value must not land: PeakLimitW = %.0f, want 5000", st.PeakLimitW)
}
}

// Zero is a real threshold for peak shaving ("shave everything above
// 0 W"), not the disabled sentinel PeakImportCeilingW uses. The endpoint
// takes it.
func TestPeakLimitEndpointAcceptsZero(t *testing.T) {
srv, st := newPeakLimitServer(t)
if rr := postPeakLimit(t, srv, `{"peak_limit_w":0}`); rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", rr.Code, rr.Body.String())
}
if st.PeakLimitW != 0 {
t.Errorf("PeakLimitW = %.0f, want 0", st.PeakLimitW)
}
}

// A site whose fuse the operator never described keeps the old
// permissive behaviour rather than inheriting an invented breaker.
func TestPeakLimitEndpointWithoutFuseAcceptsAnyNonNegative(t *testing.T) {
st := control.NewState(0, 50, "ferroamp")
srv := New(&Deps{Ctrl: st, CtrlMu: &sync.Mutex{}, Tel: telemetry.NewStore()})
if rr := postPeakLimit(t, srv, `{"peak_limit_w":20000}`); rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", rr.Code, rr.Body.String())
}
if st.PeakLimitW != 20000 {
t.Errorf("PeakLimitW = %.0f, want 20000", st.PeakLimitW)
}
}
101 changes: 101 additions & 0 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package control

import (
"encoding/json"
"fmt"
"log/slog"
"math"
"time"
Expand Down Expand Up @@ -996,6 +997,106 @@ func (s *State) SetGridTarget(w float64) {
s.PI.Setpoint = w
}

// SetPeakLimit applies the peak-shaving import threshold, rejecting the
// values a site can never act on. Both operator paths — POST
// /api/peak_limit and the Home Assistant peak_limit_w number — go
// through here, so the rule has one home; there is no YAML key for it.
// Callers hold the control mutex.
//
// Two rejections, both about a setting that reads as armed and is not:
//
// - Negative. The threshold is the import level above which shaving
// starts correcting. Below zero, 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 the same setting disagree. A knob named for an import
// peak must not be able to order export.
//
// - Above the fuse. Every import-side clamp already binds at the fuse
// less its safety margin, so a threshold above that can never be the
// first thing to bind: peak-shaving mode would do nothing the fuse
// guard was not already doing, while the operator reads their number
// back from /api/status and believes the tariff is defended.
//
// Zero is accepted and keeps the meaning dispatch already gives it —
// "correct everything above 0 W of import". This is deliberately NOT the
// zero-means-disabled convention that PeakImportCeilingW and MaxExportW
// use. Peak shaving is a mode: it is switched off by leaving the mode,
// not by zeroing the threshold. Reading 0 as "disabled" here would let a
// site running peak_shaving import without limit, and a wire value that
// means two things is how the Ferroamp pplim=0 lock bit us.
//
// The comparison is against the fuse, not effectiveImportCeilingW, even
// though PeakImportCeilingW can bind lower. The fuse is a property of the
// site; the ceiling is another operator knob that may be set after this
// one, and validating a knob against a knob makes acceptance depend on
// the order the two were typed. 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.
//
// There is no lower bound beyond zero. A threshold under the site's base
// load binds hard rather than silently, the battery covers what it can,
// and the rest shows up as import over the limit — visible, and the
// operator's business. We have no quantified hardware or control risk to
// point at, so we do not clamp it.
//
// A site whose fuse is not described (SiteFuseAmps <= 0, as in test and
// e2e harnesses) gets the negative check only, matching fuseSafetyMarginW
// and perPhaseOverageW: an incomplete fuse description yields no clamp
// rather than an invented one.
func (s *State) SetPeakLimit(w float64) error {
if w < 0 {
return fmt.Errorf("peak_limit_w must be ≥ 0, got %.0f W", w)
}
if ceiling := s.peakLimitCeilingW(); ceiling > 0 && w > ceiling {
return fmt.Errorf(
"peak_limit_w %.0f W is above the site's import ceiling %.0f W "+
"(fuse %.0f A × %.0f V × %d phases, less a %.1f A safety margin) "+
"— a peak limit above the fuse can never bind",
w, ceiling, s.SiteFuseAmps, s.SiteFuseVoltage, s.SiteFusePhases, s.SiteFuseSafetyA)
}
s.PeakLimitW = w
return nil
}

// PeakLimitIsDead reports whether the current peak-shaving threshold sits
// above the site's import ceiling, i.e. can never bind. SetPeakLimit
// refuses to create that state, but a config reload that lowers
// fuse.max_amps can arrive at it from the other direction, under a value
// that was legal when it was set. Returns false when the fuse is not
// described. Caller holds the control mutex.
func (s *State) PeakLimitIsDead() (float64, bool) {
ceiling := s.peakLimitCeilingW()
return ceiling, ceiling > 0 && s.PeakLimitW > ceiling
}

// peakLimitCeilingW is the highest peak limit that can still be the first
// thing to bind: the fuse less the margin every import clamp already
// keeps. 0 means "the site's fuse is not described", not "no headroom".
func (s *State) peakLimitCeilingW() float64 {
fuseW := s.siteFuseMaxW()
if fuseW <= 0 {
return 0
}
ceiling := fuseW - s.fuseSafetyMarginW()
if ceiling < 0 {
return 0
}
return ceiling
}

// siteFuseMaxW is the aggregate breaker budget in watts, from the same
// three fields the control tick multiplies together. Returns 0 when any
// of them is unset — no invented 230 V / 3 phases here, matching
// fuseSafetyMarginW.
func (s *State) siteFuseMaxW() float64 {
if s == nil || s.SiteFuseAmps <= 0 || s.SiteFuseVoltage <= 0 || s.SiteFusePhases <= 0 {
return 0
}
return s.SiteFuseAmps * s.SiteFuseVoltage * float64(s.SiteFusePhases)
}

// batteryInfo is internal state read from telemetry per dispatch cycle.
type batteryInfo struct {
driver string
Expand Down
Loading