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
5 changes: 5 additions & 0 deletions .changeset/import-ceiling-hour-mean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

The import ceiling now caps the mean grid import over each local clock hour, as a power tariff bills it, instead of every second. Only the site meter counts toward the hour's budget, and a restart keeps what the hour has already imported. The EV and battery are cut only when the hour's budget needs it, so a short house peak no longer throttles the car, and a quiet start to the hour lets it charge above the ceiling later in that hour. Increases wait for a step of room, at most once a minute, five minutes after a cut and not in the hour's last quarter, so the charger does not hunt. One reading over the ceiling no longer stops a car: an overage smaller than one charging step must last 20 seconds before the hour's budget cuts. A stopped car restarts when the house's mean load over the restart wait leaves room for its minimum under the ceiling itself, so it never starts on one hour's slack only to stop when the next hour begins. A ceiling set during an hour counts from then. A meter gap never makes room. The physical fuse stays an instant limit. `/api/status` shows the hour's budget left (`peak_import_hour_left_wh`) and the import limit in force (`peak_import_limit_w`).
14 changes: 14 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,7 @@ func main() {
ctrl.PeakImportCeilingW = f
}
}
seedImportHour(st, ctrl, time.Now())

// ---- Driver catalog (pure text scan, no Lua VM) ----
// Loaded once here so the pre-flight capacity + warning logic can
Expand Down Expand Up @@ -2901,6 +2902,7 @@ func main() {
// telemetry exists, closes every physical dispatch path. Safe
// standdown/default commands remain permitted.
ctrlMu.Lock()
ctrl.ObserveSiteImport(tel)
siteMeterDriver := ctrl.SiteMeterDriver
siteFuseAmps := ctrl.SiteFuseAmps
siteFusePhases := ctrl.SiteFusePhases
Expand Down Expand Up @@ -3321,6 +3323,18 @@ func rolloffLoop(ctx context.Context, st *state.Store, coldDir string, dataMaint
}
}

// seedImportHour gives the import ceiling the clock hour's import that
// state.db recorded before this start, so a restart keeps it.
func seedImportHour(st *state.Store, ctrl *control.State, now time.Time) {
start := control.ImportHourStart(now)
wh, covered, err := st.ImportWhIntervals(context.Background(), [][2]int64{{start.UnixMilli(), now.UnixMilli()}})
Comment on lines +3329 to +3330

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 Preserve a mid-hour ceiling baseline across restarts

When the ceiling is enabled or changed partway through an hour and Core restarts before that hour ends, this query restores import from the beginning of the hour. At runtime stepHourImportLimits intentionally discards import from before the new setting, so after the restart that earlier import is incorrectly charged against the new ceiling and can sharply reduce the limit or stop charging. Persist the activation/baseline time or otherwise seed only the portion accumulated since the current ceiling took effect.

Useful? React with 👍 / 👎.

if err != nil || len(wh) != 1 || len(covered) != 1 {
slog.Warn("import ceiling: the hour's recorded import is unavailable; it counts at the ceiling", "err", err)
return
}
ctrl.SeedHourImport(now, wh[0], time.Duration(covered[0])*time.Millisecond)
}

func flushHistoryOnStop(st *state.Store) {
if err := st.StopHistory(); err != nil {
slog.Warn("history flush on shutdown", "err", err)
Expand Down
18 changes: 12 additions & 6 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,12 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
// prefers it to the plan's per-slot cap, so the Plan card needs it to say
// where extra sun goes.
resp["pv_surplus_absorb_soc_cap"] = ctrl.PVSurplusAbsorbSoCCap
// The import ceiling caps each clock hour's mean import: what is left of
// this hour's budget and the import limit it sets now. Null while off.
resp["peak_import_hour_left_wh"], resp["peak_import_limit_w"] = nil, nil
if left, limit, ok := ctrl.ImportHourStatus(ctrl.SiteFuseAmps * ctrl.SiteFuseVoltage * float64(ctrl.SiteFusePhases)); ok {
resp["peak_import_hour_left_wh"], resp["peak_import_limit_w"] = left, limit
}
// A stale or missing site meter is not 0 W. Publishing zero made the
// dashboard and the FTW app draw "balanced" / "0 W" as if the house
// were idle. JSON null is what the flow mapping already treats as
Expand Down Expand Up @@ -1811,12 +1817,12 @@ func (s *Server) handleSetPeakLimit(w http.ResponseWriter, r *http.Request) {

// ---- /api/peak_import_ceiling ----
//
// Hard import ceiling enforced in every mode. Default 0 = disabled. When
// > 0, dispatch's import-side clamps (joint EV/battery allocator,
// applyFuseGuard import branch, forceFuseDischarge) use min(fuse, peak)
// as the binding threshold. Persisted in state.db so the operator's
// tariff stays armed across restarts. See control.State.PeakImportCeilingW
// for the full rationale.
// Import ceiling on each clock hour's mean import, in every mode. Default
// 0 = disabled. When > 0, dispatch's import-side clamps (joint EV/battery
// allocator, applyFuseGuard import branch, forceFuseDischarge, the EV hard
// limit) use the tighter of the fuse and the hour's import limit.
// Persisted in state.db so the operator's tariff stays armed across
// restarts. See control.State.PeakImportCeilingW.
func (s *Server) handleSetPeakImportCeiling(w http.ResponseWriter, r *http.Request) {
var req struct {
PeakImportCeilingW float64 `json:"peak_import_ceiling_w"`
Expand Down
31 changes: 31 additions & 0 deletions go/internal/api/api_peak_limit_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package api

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

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/telemetry"
)
Expand Down Expand Up @@ -98,3 +100,32 @@ func TestPeakLimitEndpointWithoutFuseAcceptsAnyNonNegative(t *testing.T) {
t.Errorf("PeakLimitW = %.0f, want 20000", st.PeakLimitW)
}
}

// GET /api/status shows the clock hour's import budget left and the import
// limit beside the ceiling, and null while the ceiling is off. Before the
// first meter reading of an hour, the whole hour's budget is left.
func TestStatusShowsImportHourBudget(t *testing.T) {
srv, st := newPeakLimitServer(t)
srv.deps.CapMu, srv.deps.CfgMu, srv.deps.Cfg = &sync.RWMutex{}, &sync.RWMutex{}, &config.Config{}
status := func() map[string]any {
t.Helper()
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/status", nil))
var body map[string]any
if rr.Code != http.StatusOK || json.Unmarshal(rr.Body.Bytes(), &body) != nil {
t.Fatalf("status = %d, body: %s", rr.Code, rr.Body.String())
}
return body
}
body := status()
for _, key := range []string{"peak_import_hour_left_wh", "peak_import_limit_w"} {
if v, ok := body[key]; !ok || v != nil {
t.Fatalf("%s = %v with the ceiling off, want null", key, v)
}
}
st.PeakImportCeilingW = 7500
body = status()
if body["peak_import_hour_left_wh"] != 7500.0 || body["peak_import_limit_w"] != 7500.0 {
t.Fatalf("left %v Wh, limit %v W; want 7,500 each", body["peak_import_hour_left_wh"], body["peak_import_limit_w"])
}
}
35 changes: 18 additions & 17 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,10 +319,13 @@ type State struct {
// Peak limit — enforced only in PeakShaving mode
PeakLimitW float64

// PeakImportCeilingW is an import cap in every mode, at or below the
// fuse. Zero disables it. The battery covers the overrun while the EV
// ramps, then stops. Export and the per-phase clamp still use the fuse.
// PeakImportCeilingW caps the mean import over each local clock hour,
// in every mode. Zero disables it. The hour's budget sets the import
// limit (import_hour.go); the fuse stays an instant limit. Export and
// the per-phase clamp use the fuse only.
PeakImportCeilingW float64
importHour hourImport
importHourZone *time.Location // nil: the box's zone

// MaxExportW is an export cap in every mode, in watts of magnitude.
// Zero leaves only the fuse. Ferroamp EnergyHub 0x8030 trips after
Expand Down Expand Up @@ -2037,8 +2040,8 @@ func ComputeDispatch(
evInMeterW := state.evInMeterW()
if fuseMaxW > 0 && math.Max(evInMeterW, plannedEVW) > 0 {
// Use the effective import ceiling (fuse minus safety margin,
// or tariff peak when tighter) so PeakImportCeilingW throttles
// the EV through the same surface as the fuse. Steady-state
// or the hour's import limit when tighter) so PeakImportCeilingW
// throttles the EV through the same surface as the fuse. Steady-state
// with peak active and BatteryCoversEV=false: the EV settles
// at this scaled rate; the battery's transient discharge to
// bridge the ramp-down is handled by forceFuseDischarge below.
Expand Down Expand Up @@ -2399,8 +2402,7 @@ func republishFuseEVCapAfterFuseDischarge(targets []DispatchTarget, store *telem
return
}
peakBindingW := fuseMaxW - state.fuseSafetyMarginW()
peakBinding := state.PeakImportCeilingW > 0 && state.PeakImportCeilingW < peakBindingW
if peakBinding {
if state.effectiveImportCeilingW(fuseMaxW) < peakBindingW {
return
}

Expand Down Expand Up @@ -3602,7 +3604,7 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta
// own per-phase limiter doesn't fire first and cause a flap.
//
// Import ceiling is the tighter of (fuse − safety margin) and the
// operator's tariff peak; export ceiling is the tighter of (fuse −
// clock hour's import limit; export ceiling is the tighter of (fuse −
// safety margin) and the operator's max_export_w protection limit —
// set when an inverter trips on sustained export below the breaker
// (recurring Ferroamp 0x8030 fault). Both share one enforcement surface.
Expand Down Expand Up @@ -3766,22 +3768,21 @@ func (s *State) fuseSafetyMarginW() float64 {
}

// effectiveImportCeilingW returns the binding ceiling for grid import in
// watts: the fuse limit minus its safety margin, further capped by
// PeakImportCeilingW when the operator has opted into a tariff peak
// (PeakImportCeilingW > 0). Used by every import-side clamp in the
// dispatch path so peak and fuse share one enforcement surface.
// watts: the fuse limit minus its safety margin, further capped by the
// clock hour's import limit when the operator has opted into a tariff
// ceiling (PeakImportCeilingW > 0). Used by every import-side clamp in the
// dispatch path so the hour's budget and the fuse share one surface.
//
// Peak is taken at face value (no additional safety subtraction) — the
// operator typed the contract limit, so the controller honours exactly
// that. The fuse retains its independent safety margin because it
// The hour's budget is the ceiling at face value — the operator typed the
// contract limit. The fuse retains its independent safety margin because it
// guards against a hardware breaker, not a billing line.
func (s *State) effectiveImportCeilingW(fuseMaxW float64) float64 {
eff := fuseMaxW - s.fuseSafetyMarginW()
if eff < 0 {
eff = 0
}
if s != nil && s.PeakImportCeilingW > 0 && s.PeakImportCeilingW < eff {
eff = s.PeakImportCeilingW
if limitW, _, ok := s.importHourLimitsW(); ok && limitW < eff {
eff = limitW
}
return eff
}
Expand Down
3 changes: 2 additions & 1 deletion go/internal/control/ev_battery_headroom.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st
latest[target.Driver] = -state.DriverLimits[target.Driver].dischargeCap()
}
}
spare := state.effectiveImportCeilingW(fuseMaxW) - meter.RawW - needW
// Battery charge stays under the level the EV may rise to.
spare := state.importRiseCeilingW(fuseMaxW) - meter.RawW - needW
// During measured export overflow, the existing fuse pipeline may
// require charge to reduce export. Magnitudes alone cannot label that
// worst phase as import; leave this direction to the existing guard.
Expand Down
151 changes: 111 additions & 40 deletions go/internal/control/ev_fuse_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ type evMeterLimit struct {
readingAt time.Time // newest site reading seen
roomSince time.Time // first reading of the current run with room to start
raised bool
raisedGridW float64 // site import when the last rise was sent
raisedGridW float64 // site import when the last rise was sent
overSince time.Time // first reading of a run over the hour's import limit
idleWh float64 // import since roomSince, for the ceiling's start test
}

type evPool struct {
Expand Down Expand Up @@ -132,8 +134,8 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State

func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, pool evPool, fuseMaxW float64) {
lim, now, at := &s.evLimit, s.now(), meter.UpdatedAt
first := !lim.set
gap := first || at.Sub(lim.readingAt) > evSettle
first, prevAt := !lim.set, lim.readingAt
gap := first || at.Sub(prevAt) > evSettle
lim.readingAt = at
prev := math.Inf(1)
if !first {
Expand All @@ -144,40 +146,45 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo
if _, high, _, ok := s.evOffers.span(now); ok {
peakW = math.Max(peakW, high)
}
roomW, measured, worstA := s.evMeterRoomW(meter, gridW, pool.phases, fuseMaxW)
if roomW < 0 {
r := s.evMeterRoom(meter, gridW, pool.phases, fuseMaxW)
step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases
// The fuse cuts on every reading. The hour's budget absorbs a short
// overage of less than a step: it cuts once the overage has lasted
// evSettle, so one reading over the ceiling changes nothing.
cutW := r.fuseW
if r.limitW >= 0 {
lim.overSince = time.Time{}
} else {
if lim.overSince.IsZero() || gap {
lim.overSince = at
}
if !r.hourly || gap || -r.limitW >= step || at.Sub(lim.overSince) >= evSettle {
cutW = math.Min(cutW, r.limitW)
}
}
if cutW < 0 {
// The meter may still show any offer of the last evSettle. Taking
// the overage from the highest cuts each measured watt once.
lim.capW, lim.roomSince = math.Min(prev, math.Max(0, peakW+roomW)), time.Time{}
lim.capW, lim.roomSince = math.Min(prev, math.Max(0, peakW+cutW)), time.Time{}
if lim.capW < prev {
lim.changedAt, lim.raised = now, false
slog.Info("EV hard limit: site meter over limit", "grid_w", gridW, "worst_phase_a", worstA,
"overage_w", -roomW, "offer_w", peakW, "cap_w", lim.capW)
slog.Info("EV hard limit: site meter over limit", "grid_w", gridW, "worst_phase_a", r.worstA,
"import_limit_w", s.effectiveImportCeilingW(fuseMaxW), "overage_w", -cutW, "offer_w", peakW, "cap_w", lim.capW)
}
return
}
// An increase needs a fresh reading and room left after our own battery
// commands that the meter may not show yet.
// An increase needs a fresh reading and room below the level increases
// may reach, left after our own battery commands that the meter may not
// show yet. Between that level and the import limit the cap holds.
pending := s.pendingBatteryCommandsW(now)
_, fresh := qualifiedFuseObservation(meter, now)
fresh = fresh && r.measured
room := 0.0
if _, fresh := qualifiedFuseObservation(meter, now); fresh && measured {
room = math.Max(0, roomW-s.pendingBatteryCommandsW(now))
if fresh {
room = math.Max(0, math.Min(r.fuseW, r.riseW)-pending)
}
step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases
if pool.offerW <= 0 {
lim.capW, lim.changedAt, lim.raised = 0, now, false
if room < pool.minW+step {
lim.roomSince = time.Time{}
return
}
if lim.roomSince.IsZero() || gap {
lim.roomSince = at
if first {
lim.roomSince = at.Add(-evRestartDwell) // No stop of ours to wait out.
}
}
if at.Sub(lim.roomSince) >= evRestartDwell {
lim.capW = room - step
}
s.stepEVIdleStart(r, gridW, pool, first, gap, fresh, at, prevAt, pending, step)
return
}
// Keeping the current offers is never a cut. Headroom above them stays
Expand All @@ -197,19 +204,83 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo
}
}

// evMeterRoomW is the measured room below the nearer limit, total import or
// worst phase, in EV watts. Negative is an overage. measured is false when
// the meter reports phase currents that cannot be read.
func (s *State) evMeterRoomW(meter *telemetry.DerReading, gridW, phases, fuseMaxW float64) (roomW float64, measured bool, worstA float64) {
roomW = s.effectiveImportCeilingW(fuseMaxW) - gridW
worstA, present, valid := worstPhaseA(meter, s)
if !present {
return roomW, true, 0
}
if !valid {
return roomW, false, 0
}
return math.Min(roomW, (s.SiteFuseAmps-evPhaseMarginA-worstA)*s.SiteFuseVoltage*phases), true, worstA
// stepEVIdleStart starts an idle pool once the meter has kept showing room
// for evRestartDwell: room on every reading for its minimum plus a step
// below the fuse. Under a measured hour, the ceiling's part is judged on the
// dwell's mean import, the house's load rather than one reading of it, and
// the minimum needs no step of margin there: a short overage waits.
func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first, gap, fresh bool, at, prevAt time.Time, pending, step float64) {
lim := &s.evLimit
lim.capW, lim.changedAt, lim.raised = 0, s.now(), false
perReadingW := r.fuseW
if !r.hourly {
perReadingW = math.Min(perReadingW, r.riseW)
}
room := 0.0
if fresh {
room = math.Max(0, perReadingW-pending)
}
if room < pool.minW+step {
lim.roomSince = time.Time{}
return
}
switch {
case first:
// No stop of ours to wait out.
lim.roomSince, lim.idleWh = at.Add(-evRestartDwell), math.Max(0, gridW)*evRestartDwell.Hours()
case lim.roomSince.IsZero() || gap:
lim.roomSince, lim.idleWh = at, 0
default:
lim.idleWh += math.Max(0, gridW) * at.Sub(prevAt).Hours()
}
dwell := at.Sub(lim.roomSince)
if dwell < evRestartDwell {
return
}
lim.capW = room - step
if !r.hourly {
return
}
ceilingRoomW := r.startLevelW - lim.idleWh/dwell.Hours() - pending
if ceilingRoomW < pool.minW {
lim.capW, lim.roomSince, lim.idleWh = 0, at, 0 // Judge the next dwell.
return
}
lim.capW = math.Min(lim.capW, ceilingRoomW)
}

// evMeterRoom is the measured room in EV watts. The fuse's part holds on
// every reading. The import ceiling's parts are +Inf while it is off.
type evMeterRoom struct {
fuseW float64 // below the fuse less its margin and the worst phase
limitW float64 // below the hour's import limit; negative is an overage
riseW float64 // below the level an increase may reach
startLevelW float64 // import an idle start may reach: the rise level, at most the ceiling
hourly bool // the hour's import is measured, so a short overage may wait
measured bool // false when the meter reports phase currents that cannot be read
worstA float64
}

func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW float64) evMeterRoom {
inf := math.Inf(1)
r := evMeterRoom{fuseW: math.Max(0, fuseMaxW-s.fuseSafetyMarginW()) - gridW, limitW: inf, riseW: inf, startLevelW: inf}
if limitW, riseW, ok := s.importHourLimitsW(); ok {
r.limitW, r.riseW = limitW-gridW, riseW-gridW
// A car that could run only on one hour's slack would stop when
// the next hour starts at the ceiling.
r.startLevelW = math.Min(riseW, s.PeakImportCeilingW)
r.hourly = s.importHourMeasured()
}
worst, present, valid := worstPhaseA(meter, s)
if present && !valid {
return r
}
r.measured = true
if present {
r.fuseW = math.Min(r.fuseW, (s.SiteFuseAmps-evPhaseMarginA-worst)*s.SiteFuseVoltage*phases)
r.worstA = worst
}
return r
}

// worstPhaseA reads the site's configured phases. present is false when the
Expand Down
Loading
Loading