diff --git a/.changeset/import-ceiling-hour-mean.md b/.changeset/import-ceiling-hour-mean.md new file mode 100644 index 000000000..b717d0aa1 --- /dev/null +++ b/.changeset/import-ceiling-hour-mean.md @@ -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`). diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 4cf352a1f..fb9efc2c6 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -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 @@ -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 @@ -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()}}) + 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) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 7baaac9c2..7ca4cc05a 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -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 @@ -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"` diff --git a/go/internal/api/api_peak_limit_test.go b/go/internal/api/api_peak_limit_test.go index 7abbb3059..0cf0e408f 100644 --- a/go/internal/api/api_peak_limit_test.go +++ b/go/internal/api/api_peak_limit_test.go @@ -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" ) @@ -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"]) + } +} diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index a367b55d2..3f97ce1e4 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -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 @@ -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. @@ -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 } @@ -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. @@ -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 } diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 7f01bce2b..12a867c61 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -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. diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 0286fb922..0d0e5e127 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -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 { @@ -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 { @@ -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 @@ -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 diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go index 8256d12af..3941c095c 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -56,6 +56,8 @@ type evMeterSite struct { config memConfigStore // survives a restart sessionWh float64 ceilingW float64 + unplugged bool + onBoot, beforeTick func() // after each boot; before each controller tick } // memConfigStore stands in for state.db across a simulated Core restart. @@ -66,7 +68,12 @@ func (m memConfigStore) SaveConfig(key, value string) error { m[key] = value; func newEVMeterSite(t *testing.T, ceilingW, houseW float64) *evMeterSite { t.Helper() - s := &evMeterSite{t: t, now: time.Now().Truncate(time.Second), store: telemetry.NewStore(), + return newEVMeterSiteAt(t, ceilingW, houseW, time.Now().Truncate(time.Second)) +} + +func newEVMeterSiteAt(t *testing.T, ceilingW, houseW float64, start time.Time) *evMeterSite { + t.Helper() + s := &evMeterSite{t: t, now: start, store: telemetry.NewStore(), chargerDelay: 5 * time.Second, telemetryDelay: 10 * time.Second, carMaxW: math.Inf(1), config: memConfigStore{}, ceilingW: ceilingW} s.setHouse(houseW) @@ -91,7 +98,7 @@ func (s *evMeterSite) boot() { s.ctrl = loadpoint.NewController(s.mgr, func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false }, func(string) (loadpoint.EVSample, bool) { - return loadpoint.EVSample{Connected: true, RequestActive: true, PowerW: s.reportedW, PowerAt: s.reportedAt, + return loadpoint.EVSample{Connected: !s.unplugged, RequestActive: true, PowerW: s.reportedW, PowerAt: s.reportedAt, DeviceID: "easee:EH000001", SessionWh: s.sessionWh, EnergyAt: s.reportedAt}, true }, func(_ context.Context, _ string, payload []byte) error { @@ -117,6 +124,9 @@ func (s *evMeterSite) boot() { return EVFuseBudget(s.store, s.state, s.mgr.States(), s.mgr.Configs(), 17250) }) s.ctrl.SetManualHold(cfg.ID, loadpoint.ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) + if s.onBoot != nil { + s.onBoot() + } } func (s *evMeterSite) setHouse(w float64) { @@ -195,6 +205,9 @@ func (s *evMeterSite) step() { s.emit("meter", telemetry.DerMeter, grid, map[string]any{"l1_a": a[0], "l2_a": a[1], "l3_a": a[2]}) s.meterAt = s.now } + if s.beforeTick != nil { + s.beforeTick() + } s.ctrl.TickWithDispatch(context.Background(), s.now, s.now.Sub(s.meterAt) <= time.Minute) s.state.SetEVFuseDemands(s.ctrl.FuseDemands()) } diff --git a/go/internal/control/import_hour.go b/go/internal/control/import_hour.go new file mode 100644 index 000000000..bb69f7823 --- /dev/null +++ b/go/internal/control/import_hour.go @@ -0,0 +1,236 @@ +package control + +import ( + "log/slog" + "math" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// PeakImportCeilingW bills like a Swedish power tariff: on the mean import +// over each local clock hour, the hours the planner's demand charges use +// (mpc/demand.go). The hour's budget is the ceiling for one hour. Only the +// site meter counts toward it. The fuse stays an instant limit. +const ( + // importHourChangeEvery is the fastest the hour's limits move. + importHourChangeEvery = time.Minute + // importHourRiseGap keeps a cut and the next rise apart. + importHourRiseGap = 5 * time.Minute + // importHourRiseMinLeft keeps a rise for at least a quarter hour before + // the next hour starts again at the ceiling. + importHourRiseMinLeft = 15 * time.Minute +) + +// hourImport is the clock hour's import as the site meter measured it, and +// the two limits it sets. limitW is the mean import the rest of the hour may +// hold: above it the hour would overrun, so it cuts. riseW is the level an +// increase may reach. It rises in whole steps, keeps one step in hand and +// waits after a cut, so a charger does not hunt. +type hourImport struct { + start time.Time // local clock hour + importedWh float64 // measured import this hour + unknown time.Duration // time with no record; counts at the ceiling + lastAt time.Time // newest site reading counted + lastW float64 // its import; NaN when only the record is known + + ceilingW float64 // ceiling the limits were set for; 0 = not set + limitW, riseW float64 + changedAt, cutAt time.Time + + seen bool // a reading this hour has seen the ceiling setting + seenCeiling float64 // the setting it saw, 0 when off +} + +// ImportHourStart is the local clock hour that holds t. +func ImportHourStart(t time.Time) time.Time { return importHourStart(t, time.Local) } + +func importHourStart(t time.Time, zone *time.Location) time.Time { + l := t.In(zone) + return l.Add(-time.Duration(l.Minute())*time.Minute - time.Duration(l.Second())*time.Second - time.Duration(l.Nanosecond())) +} + +func (s *State) importHourStart(t time.Time) time.Time { + zone := s.importHourZone + if zone == nil { + zone = time.Local + } + return importHourStart(t, zone) +} + +// SeedHourImport restores the clock hour's import that state.db recorded up +// to at, so a restart keeps it. Time the record does not cover counts at the +// ceiling. Call it at boot, before the first ObserveSiteImport. +func (s *State) SeedHourImport(at time.Time, importWh float64, covered time.Duration) { + if s == nil || !finiteFuseValue(importWh) || importWh < 0 { + return + } + start := s.importHourStart(at) + unknown := max(0, at.Sub(start)-covered) + s.importHour = hourImport{start: start, importedWh: importWh, unknown: unknown, lastAt: at, lastW: math.NaN()} +} + +// ObserveSiteImport adds the newest site-meter reading to the clock hour's +// import and moves the hour's limits. No device reading takes part. Call it +// every control tick under the control lock. A reading counts once, so a +// silent meter adds nothing and moves nothing. +func (s *State) ObserveSiteImport(store *telemetry.Store) { + if s == nil || store == nil { + return + } + gridW, at, ok := store.OnlineMeterW(s.SiteMeterDriver) + h := &s.importHour + if !ok || at.IsZero() || !at.After(h.lastAt) || at.After(s.now()) { + return + } + w := math.Max(0, gridW) + gap := false + if h.lastAt.IsZero() { + // No record: the hour so far is unknown. + start := s.importHourStart(at) + *h = hourImport{start: start, unknown: at.Sub(start)} + } else { + from := h.lastAt + gap = at.Sub(from) > evSettle + if at.Sub(from) > time.Hour { + from = s.importHourStart(at) // Hours wholly unseen are over. + } + s.addHourImport(from, at, w, gap) + } + h.lastAt, h.lastW = at, w + s.stepHourImportLimits(at, gap) +} + +// addHourImport counts the import between two readings into each clock hour +// it spans. Across a gap the meter saw nothing; the gap counts at no less +// than the hour's rate when it began, so it can never make room. +func (s *State) addHourImport(from, to time.Time, w float64, gap bool) { + h := &s.importHour + prevW := h.lastW + if math.IsNaN(prevW) { + prevW = w + } + for from.Before(to) { + if start := s.importHourStart(from); !start.Equal(h.start) { + *h = hourImport{start: start, lastAt: h.lastAt, lastW: h.lastW} + } + end := h.start.Add(time.Hour) + if to.Before(end) { + end = to + } + rateW := (prevW + w) / 2 + if gap { + rateW = math.Max(math.Max(prevW, w), s.hourRateW(from)) + } + h.importedWh += rateW * end.Sub(from).Hours() + from = end + } + if start := s.importHourStart(to); !start.Equal(h.start) { + *h = hourImport{start: start, lastAt: h.lastAt, lastW: h.lastW} // A reading on the hour. + } +} + +// hourRateW is the mean import the rest of the hour may hold: the budget +// left over the time left. Zero while the ceiling is off. +func (s *State) hourRateW(at time.Time) float64 { + ceiling := s.PeakImportCeilingW + if !(ceiling > 0) { + return 0 + } + h := &s.importHour + leftWh := ceiling*(1-h.unknown.Hours()) - h.importedWh + left := max(time.Second, h.start.Add(time.Hour).Sub(at)) + return math.Max(0, leftWh/left.Hours()) +} + +// stepHourImportLimits moves the limits at most once a minute. The import +// limit follows the hour's rate. A rise needs a step in hand, a quarter hour +// left, and five minutes since the last cut. A gap never raises a limit. +func (s *State) stepHourImportLimits(at time.Time, gap bool) { + h, ceiling := &s.importHour, s.PeakImportCeilingW + if !(ceiling > 0) { + ceiling = 0 + } + if h.seen && ceiling != h.seenCeiling && ceiling > 0 { + // A ceiling set during the hour counts from now: the rest of the + // hour may average it, whatever the hour imported before. + h.importedWh, h.unknown = 0, at.Sub(h.start) + } + h.seen, h.seenCeiling = true, ceiling + if ceiling == 0 { + h.ceilingW = 0 + return + } + rateW := s.hourRateW(at) + if h.ceilingW != ceiling { + // A new hour, a boot or a new ceiling. + h.ceilingW, h.limitW, h.riseW = ceiling, rateW, math.Min(ceiling, rateW) + h.changedAt, h.cutAt = at, time.Time{} + return + } + if at.Sub(h.changedAt) < importHourChangeEvery || (gap && rateW >= h.limitW) { + return + } + if rateW < h.limitW-1 { + h.cutAt = at + slog.Info("import ceiling: hour's budget cuts the limit", "hour", h.start.Format("15:04"), + "imported_wh", h.importedWh, "limit_w", rateW, "was_w", h.limitW) + } + h.limitW, h.riseW, h.changedAt = rateW, math.Min(h.riseW, rateW), at + step := evPhaseMarginA * s.SiteFuseVoltage * float64(s.SiteFusePhases) + left := h.start.Add(time.Hour).Sub(at) + if gap || !(step > 0) || at.Sub(h.cutAt) < importHourRiseGap || left < importHourRiseMinLeft || rateW < h.riseW+2*step { + return + } + h.riseW += step * (math.Floor((rateW-h.riseW)/step) - 1) + slog.Info("import ceiling: hour's budget lets increases rise", "hour", h.start.Format("15:04"), + "imported_wh", h.importedWh, "limit_w", rateW, "rise_w", h.riseW) +} + +// importHourLimitsW returns the hour's import limit and the level an +// increase may reach. ok is false while the ceiling is off. Before the first +// reading, after a new ceiling or in a new hour they are the ceiling: that is +// the rate a whole hour may hold. +func (s *State) importHourLimitsW() (limitW, riseW float64, ok bool) { + if s == nil || !(s.PeakImportCeilingW > 0) { + return 0, 0, false + } + if !s.importHourMeasured() { + return s.PeakImportCeilingW, s.PeakImportCeilingW, true + } + return s.importHour.limitW, s.importHour.riseW, true +} + +// importHourMeasured reports whether this clock hour's import is measured +// against the ceiling in force. +func (s *State) importHourMeasured() bool { + h := &s.importHour + return s.PeakImportCeilingW > 0 && h.ceilingW == s.PeakImportCeilingW && s.importHourStart(s.now()).Equal(h.start) +} + +// importRiseCeilingW is the import battery charge may reach beside an EV +// that still wants power (EV first). Never above effectiveImportCeilingW. +func (s *State) importRiseCeilingW(fuseMaxW float64) float64 { + eff := s.effectiveImportCeilingW(fuseMaxW) + if _, riseW, ok := s.importHourLimitsW(); ok && riseW < eff { + eff = riseW + } + return eff +} + +// ImportHourStatus is the clock hour's import budget left, negative once it +// is overrun, and the import limit in force. ok is false while the ceiling is +// off. Callers hold the control lock or a copy of State. +func (s *State) ImportHourStatus(fuseMaxW float64) (leftWh, limitW float64, ok bool) { + if limitW, _, ok = s.importHourLimitsW(); !ok { + return 0, 0, false + } + leftWh = s.PeakImportCeilingW + if h := &s.importHour; s.importHourStart(s.now()).Equal(h.start) { + leftWh = s.PeakImportCeilingW*(1-h.unknown.Hours()) - h.importedWh + } + if fuseMaxW > 0 { + limitW = s.effectiveImportCeilingW(fuseMaxW) + } + return leftWh, limitW, true +} diff --git a/go/internal/control/import_hour_test.go b/go/internal/control/import_hour_test.go new file mode 100644 index 000000000..b91ff11bf --- /dev/null +++ b/go/internal/control/import_hour_test.go @@ -0,0 +1,437 @@ +package control + +import ( + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +var hourTestZone = time.FixedZone("CEST", 2*60*60) + +// hourSite is the #1674 site with the ceiling on the clock hour's mean +// import, fed every second as main.go feeds it every tick. hourWh is the +// true import per clock hour, as state.db records it. +type hourSite struct { + *evMeterSite + hourWh map[time.Time]float64 + from time.Time +} + +func newHourSite(t *testing.T, ceilingW, houseW float64, start time.Time) *hourSite { + t.Helper() + h := &hourSite{hourWh: map[time.Time]float64{}, from: start} + h.evMeterSite = newEVMeterSiteAt(t, ceilingW, houseW, start) + s := h.evMeterSite + s.onBoot = func() { s.state.importHourZone = hourTestZone } + s.onBoot() + s.beforeTick = func() { + h.hourWh[importHourStart(s.now, hourTestZone)] += math.Max(0, s.houseW+s.evW) / 3600 + s.state.ObserveSiteImport(s.store) + } + return h +} + +// restart boots Core again. With seed, it reads the hour's record first. +func (h *hourSite) restart(seed bool) { + h.boot() + if seed { + start := importHourStart(h.now, hourTestZone) + from := start + if h.from.After(from) { + from = h.from + } + h.state.SeedHourImport(h.now, h.hourWh[start], h.now.Sub(from)) + } +} + +func (h *hourSite) plugIn() { + h.unplugged = false + h.ctrl.SetManualHold("garage", loadpoint.ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) +} + +func (h *hourSite) meanW(hour time.Time) float64 { return h.hourWh[hour] } + +func (h *hourSite) noStopAfterStart() { + h.t.Helper() + started := false + for _, o := range h.offers { + if o.w > 0 { + started = true + } else if started { + h.t.Fatalf("the car stopped at %s: offers %v", o.at.In(hourTestZone).Format("15:04:05"), offerWatts(h.offers)) + } + } + if !started { + h.t.Fatal("the car never started") + } +} + +func (h *hourSite) logChanges() { + last := -1.0 + for _, o := range h.offers { + if o.w != last { + h.t.Logf("%s offer %.0f W", o.at.In(hourTestZone).Format("15:04:05"), o.w) + last = o.w + } + } + for hour, wh := range h.hourWh { + h.t.Logf("hour %s mean %.0f W", hour.Format("15:04"), wh) + } +} + +func clock(day, hour, minute int) time.Time { + return time.Date(2026, 10, day, hour, minute, 0, 0, hourTestZone) +} + +// #1674, 23:45: a 7.5 kW ceiling, 0.9 kW of house load and the car at 9 A, +// 6.2 kW. On the hour's mean the charge runs steady, a step above what a +// per-second ceiling allowed, and never stops. +func TestImportCeilingHourMeanKeeps2345ChargeSteady(t *testing.T) { + h := newHourSite(t, 7500, 900, clock(16, 22, 0)) + h.run(time.Hour) + steadyFrom := h.now.Add(time.Minute) + h.run(90 * time.Minute) // past 23:45 and midnight + h.logChanges() + h.noStopAfterStart() + for _, o := range h.offersSince(steadyFrom) { + if o.w != 6210 { + t.Fatalf("offer %.0f W at %s, want a steady 6,210 W", o.w, o.at.In(hourTestZone).Format("15:04:05")) + } + } + if h.evW != 6210 { + t.Fatalf("car draws %.0f W, want 6,210 W", h.evW) + } + for _, hour := range []time.Time{clock(16, 22, 0), clock(16, 23, 0)} { + if mean := h.meanW(hour); mean > 7500 { + t.Fatalf("hour %s mean import %.0f W over the 7,500 W ceiling", hour.Format("15:04"), mean) + } + } +} + +// A quiet first half-hour leaves budget. The car plugged in at 00:30 runs +// at full power, above the ceiling, and the hour's mean stays under it. +func TestImportCeilingQuietHalfHourLetsCarRunAboveCeiling(t *testing.T) { + h := newHourSite(t, 7500, 900, clock(17, 0, 0)) + h.unplugged = true + h.run(30 * time.Minute) + h.plugIn() + h.run(30*time.Minute - time.Second) // to 00:59:59 + h.logChanges() + h.noStopAfterStart() + if h.evW != 10350 || h.houseW+h.evW <= 7500 { + t.Fatalf("car draws %.0f W, grid %.0f W: want 10,350 W, above the ceiling", h.evW, h.houseW+h.evW) + } + h.step() // 01:00 + if mean := h.meanW(clock(17, 0, 0)); mean > 7500 { + t.Fatalf("hour mean import %.0f W over the 7,500 W ceiling", mean) + } +} + +// A house step late in the hour, as at 02:19 in #1674 but larger, takes +// the car down a step. The hour's budget still holds, so the car does not +// stop: under a per-second 7.5 kW ceiling, 4 kW of house load leaves less +// than its 4.14 kW minimum. +func TestImportCeilingHouseStepLateInHourStepsCarDown(t *testing.T) { + h := newHourSite(t, 7500, 900, clock(16, 22, 0)) + h.run(110 * time.Minute) // steady at 6,210 W from 23:00 + stepAt := h.now + h.setHouse(4000) + h.run(10*time.Minute - time.Second) // to 23:59:59 + h.logChanges() + h.noStopAfterStart() + got := h.offersSince(stepAt) + if len(got) == 0 || got[len(got)-1].w >= 6210 { + t.Fatalf("offers after the house step %v, want a step down", offerWatts(got)) + } + h.step() // midnight + if mean := h.meanW(clock(16, 23, 0)); mean > 7500 { + t.Fatalf("hour mean import %.0f W over the 7,500 W ceiling", mean) + } +} + +// A Core restart mid-hour keeps the hour's import from state.db. A sauna +// spent most of the budget; without the record the box would count the +// first half-hour at the ceiling and let the car overrun the hour. +func TestImportCeilingRestartKeepsHoursImport(t *testing.T) { + run := func(seed bool) (*hourSite, float64) { + h := newHourSite(t, 7500, 9500, clock(16, 23, 0)) + h.run(25 * time.Minute) + h.setHouse(900) + h.run(5 * time.Minute) + h.restart(seed) + h.run(30*time.Minute - time.Second) + h.step() // midnight + return h, h.meanW(clock(16, 23, 0)) + } + h, mean := run(true) + h.logChanges() + if mean > 7500 { + t.Fatalf("with the record the hour's mean import is %.0f W, over the 7,500 W ceiling", mean) + } + if _, without := run(false); without <= 7500 { + t.Fatalf("without the record the hour's mean is %.0f W; the test no longer needs the record", without) + } +} + +// The restart itself: the hour's import is the record, not zero, and the +// limits continue from it. +func TestImportCeilingSeedRestoresHoursImport(t *testing.T) { + h := newHourSite(t, 7500, 9500, clock(16, 23, 0)) + h.run(25 * time.Minute) + h.setHouse(900) + h.run(5 * time.Minute) + before := h.state.importHour.importedWh + limitBefore, _, _ := h.state.importHourLimitsW() + h.restart(true) + h.step() + after := h.state.importHour.importedWh + if math.Abs(after-before) > 10 { + t.Fatalf("hour's import %.0f Wh after the restart, %.0f Wh before", after, before) + } + limitAfter, _, _ := h.state.importHourLimitsW() + if limitAfter >= 7500 || math.Abs(limitAfter-limitBefore) > 100 { + t.Fatalf("limit %.0f W after the restart, %.0f W before", limitAfter, limitBefore) + } +} + +func newImportHourState(now *time.Time) (*State, *telemetry.Store) { + st := NewState(0, 0, "meter") + st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases, st.SiteFuseSafetyA = 25, 230, 3, 0.5 + st.PeakImportCeilingW = 7500 + st.importHourZone = hourTestZone + st.clock = func() time.Time { return *now } + return st, telemetry.NewStore() +} + +func feedMeter(st *State, store *telemetry.Store, now *time.Time, at time.Time, w float64) { + *now = at + store.Update("meter", telemetry.DerMeter, w, nil, nil) + store.Get("meter", telemetry.DerMeter).UpdatedAt = at + store.RecordDriverSuccess("meter") + st.ObserveSiteImport(store) +} + +// A meter gap never makes room: the time unseen counts at no less than the +// rate the hour had when it began, and the reading that ends it moves no +// limit up, even when it shows the house far lower. +func TestImportCeilingMeterGapNeverRaisesLimits(t *testing.T) { + var now time.Time + st, store := newImportHourState(&now) + at := clock(16, 23, 0) + for ; at.Before(clock(16, 23, 40)); at = at.Add(2 * time.Second) { + feedMeter(st, store, &now, at, 6420) + } + rate, imported := st.hourRateW(now), st.importHour.importedWh + limit, rise, _ := st.importHourLimitsW() + if rise <= 7500 { + t.Fatalf("setup: rise %.0f W, want room above the ceiling", rise) + } + gapEnd := now.Add(45 * time.Second) + feedMeter(st, store, &now, gapEnd, 900) + if got := st.importHour.importedWh - imported; got < rate*45/3600 { + t.Fatalf("the gap counted %.1f Wh, under the hour's rate %.0f W for 45 s", got, rate) + } + if st.hourRateW(now) > rate+1e-6 { + t.Fatalf("the gap raised the hour's rate from %.0f to %.0f W", rate, st.hourRateW(now)) + } + if l, r, _ := st.importHourLimitsW(); l > limit+1e-6 || r > rise+1e-6 { + t.Fatalf("limits %.0f/%.0f W after the gap, %.0f/%.0f W before", l, r, limit, rise) + } + // Steady readings at the same low import would have raised the rate. + steady, _ := newImportHourState(&now) + steadyStore := telemetry.NewStore() + for at := clock(16, 23, 0); !at.After(gapEnd); at = at.Add(2 * time.Second) { + w := 6420.0 + if at.After(clock(16, 23, 40)) { + w = 900 + } + feedMeter(steady, steadyStore, &now, at, w) + } + if steady.hourRateW(now) <= rate { + t.Fatalf("setup: steady readings left the rate at %.0f W", steady.hourRateW(now)) + } +} + +// A gap across the hour's end: the new hour starts at the ceiling and counts +// its unseen start at no less than the ceiling. +func TestImportCeilingGapAcrossHourCountsNewHourAtCeiling(t *testing.T) { + var now time.Time + st, store := newImportHourState(&now) + for at := clock(16, 23, 30); at.Before(clock(16, 23, 59)); at = at.Add(2 * time.Second) { + feedMeter(st, store, &now, at, 900) + } + feedMeter(st, store, &now, clock(16, 23, 59).Add(50*time.Second), 900) + feedMeter(st, store, &now, clock(17, 0, 0).Add(30*time.Second), 900) + if !st.importHour.start.Equal(clock(17, 0, 0)) { + t.Fatalf("hour %v, want midnight", st.importHour.start) + } + if got := st.importHour.importedWh; got < 7500*30/3600.0 { + t.Fatalf("the new hour's unseen 30 s counted %.1f Wh, under the ceiling", got) + } + if l, r, _ := st.importHourLimitsW(); l > 7500+1e-6 || r > 7500+1e-6 { + t.Fatalf("new hour's limits %.0f/%.0f W, above the ceiling", l, r) + } +} + +// Readings count toward the local clock hour they fall in. Before any +// reading of a new hour, its limits are the ceiling. +func TestImportCeilingCountsPerLocalClockHour(t *testing.T) { + var now time.Time + st, store := newImportHourState(&now) + for at := clock(16, 23, 59); !at.After(clock(17, 0, 0).Add(time.Second)); at = at.Add(time.Second) { + feedMeter(st, store, &now, at, 3600) + } + if got := st.importHour.importedWh; math.Abs(got-1) > 1e-9 { + t.Fatalf("the new hour's first second counted %.3f Wh, want 1 Wh", got) + } + st.importHour.limitW, st.importHour.riseW = 20000, 20000 + now = clock(17, 1, 2) // no reading in this hour yet + if l, r, _ := st.importHourLimitsW(); l != 7500 || r != 7500 { + t.Fatalf("an unread hour's limits %.0f/%.0f W, want the ceiling", l, r) + } + if got := importHourStart(time.Date(2026, 10, 17, 10, 47, 3, 0, time.FixedZone("IST", 19800)), time.UTC); !got.Equal(time.Date(2026, 10, 17, 5, 0, 0, 0, time.UTC)) { + t.Fatalf("UTC hour %v", got) + } + ist := time.FixedZone("IST", 19800) + if got := importHourStart(time.Date(2026, 10, 17, 10, 47, 3, 0, ist), ist); !got.Equal(time.Date(2026, 10, 17, 10, 0, 0, 0, ist)) { + t.Fatalf("local hour %v, want 10:00 IST", got) + } +} + +// #1674, Göran, 17 October 14:26–14:28 UTC (16:26 here): the 7.5 kW ceiling +// went on with the car at 10,350 W beside 3.3 kW of house load. The car came +// down to its 4,140 W minimum; one reading of 7,524 W, 24 W over the ceiling, +// then stopped it, and it never restarted. On the hour's mean, 3.3 + 4.14 kW +// = 7.44 kW holds the ceiling: the lone reading changes nothing. +func TestImportCeilingLoneReadingOverCeilingKeepsMinimum(t *testing.T) { + h := newHourSite(t, 0, 3300, clock(17, 16, 0)) + h.run(26*time.Minute + 8*time.Second) + h.ceilingW, h.state.PeakImportCeilingW = 7500, 7500 + onAt, onWh := h.now, h.hourWh[clock(17, 16, 0)] + h.run(time.Minute + 50*time.Second) // to 16:27:58 + h.setHouse(3384) + h.step() // 16:27:59 + if grid := h.houseW + h.evW; grid != 7524 { + t.Fatalf("setup: grid %.0f W at 16:27:59, want 7,524 W", grid) + } + h.setHouse(3300) + h.run(32*time.Minute + time.Second) // to 17:00 + h.logChanges() + got := h.offersSince(onAt) + for _, o := range got { + if o.w < 4140 { + t.Fatalf("offer %.0f W at %s under the ceiling", o.w, o.at.In(hourTestZone).Format("15:04:05")) + } + } + if len(got) == 0 || got[len(got)-1].w != 4140 { + t.Fatalf("offers %v, want the car held at 4,140 W", offerWatts(got)) + } + mean := (h.hourWh[clock(17, 16, 0)] - onWh) / h.now.Sub(onAt).Hours() + if mean > 7500 { + t.Fatalf("import averaged %.0f W since the ceiling went on", mean) + } +} + +// A stop under the ceiling restarts on the hour's budget: the dwell's mean +// house load leaves room for the 4,140 W minimum, without the step of margin +// the fuse keeps. With 3.3 kW of house load that room is 4.2 kW; a step of +// margin on the latest reading (4,830 W) never restarted the car. +func TestImportCeilingRestartsOnHoursBudget(t *testing.T) { + h := newHourSite(t, 7500, 3300, clock(17, 16, 0)) + h.run(20 * time.Minute) + if h.offer() != 4140 { + t.Fatalf("setup: offer %.0f W, want the 4,140 W minimum", h.offer()) + } + h.setHouse(3700) // 7.84 kW: a real overage, not one reading + h.run(2 * time.Minute) + if h.offer() != 0 { + t.Fatalf("offer %.0f W after two minutes over the hour's budget, want a stop", h.offer()) + } + backAt := h.now + h.setHouse(3300) + h.run(5 * time.Minute) + h.logChanges() + got := h.offersSince(backAt) + if len(got) == 0 || got[len(got)-1].w != 4140 { + t.Fatalf("offers after the house came back %v, want a restart at 4,140 W", offerWatts(got)) + } + restartedAt := got[len(got)-1].at + h.run(32*time.Minute + time.Second) // to 17:00 + h.logChanges() + for _, o := range h.offersSince(restartedAt) { + if o.w == 0 { + t.Fatalf("the car stopped again at %s", o.at.In(hourTestZone).Format("15:04:05")) + } + } + if mean := h.meanW(clock(17, 16, 0)); mean > 7500 { + t.Fatalf("hour mean import %.0f W over the 7,500 W ceiling", mean) + } +} + +// A ceiling set during the hour counts from then: the rest of the hour may +// average it, whatever the hour imported before. +func TestImportCeilingSetMidHourCountsFromThen(t *testing.T) { + var now time.Time + st, store := newImportHourState(&now) + st.PeakImportCeilingW = 0 + at := clock(17, 16, 0) + for ; !at.After(clock(17, 16, 26)); at = at.Add(2 * time.Second) { + feedMeter(st, store, &now, at, 13650) + } + st.PeakImportCeilingW = 7500 + feedMeter(st, store, &now, at, 13650) + if l, r, _ := st.importHourLimitsW(); math.Abs(l-7500) > 1e-6 || math.Abs(r-7500) > 1e-6 { + t.Fatalf("limits %.0f/%.0f W, want the ceiling from now on", l, r) + } + if left, _, _ := st.ImportHourStatus(17250); math.Abs(left-7500*now.Sub(clock(17, 16, 0)).Hours()*-1-7500) > 1 { + t.Fatalf("budget left %.0f Wh, want the ceiling for the time left", left) + } +} + +// A car that could run only on the hour's slack does not start: with 5 kW +// of house load under a 7.5 kW ceiling, its 4.14 kW minimum fits only late +// in each hour and would stop when the next one starts. +func TestImportCeilingNoStartOnSlackAlone(t *testing.T) { + h := newHourSite(t, 7500, 5000, clock(16, 21, 0)) + h.run(3 * time.Hour) + h.logChanges() + for _, o := range h.offers { + if o.w > 0 { + t.Fatalf("the car started at %s on slack alone", o.at.In(hourTestZone).Format("15:04:05")) + } + } +} + +// A reading after a clock jump of years counts only the hour it falls in. +func TestImportCeilingClockJumpCountsOnlyItsHour(t *testing.T) { + var now time.Time + st, store := newImportHourState(&now) + feedMeter(st, store, &now, time.Date(1970, 1, 1, 0, 0, 2, 0, hourTestZone), 900) + feedMeter(st, store, &now, clock(16, 23, 30), 900) + if !st.importHour.start.Equal(clock(16, 23, 0)) { + t.Fatalf("hour %v, want 23:00", st.importHour.start) + } + if got := st.importHour.importedWh; math.Abs(got-7500*0.5) > 1e-6 { + t.Fatalf("the unseen half-hour counted %.1f Wh, want 3,750 Wh at the ceiling", got) + } +} + +// The status shows the budget left and the limit, and nothing while off. +func TestImportHourStatus(t *testing.T) { + var now time.Time + st, store := newImportHourState(&now) + for at := clock(16, 23, 0); !at.After(clock(16, 23, 30)); at = at.Add(2 * time.Second) { + feedMeter(st, store, &now, at, 3000) + } + left, limit, ok := st.ImportHourStatus(17250) + if !ok || math.Abs(left-6000) > 2 || math.Abs(limit-12000) > 5 { + t.Fatalf("status %v: left %.1f Wh limit %.1f W, want 6,000 Wh and 12,000 W", ok, left, limit) + } + st.PeakImportCeilingW = 0 + if _, _, ok := st.ImportHourStatus(17250); ok { + t.Fatal("status reported with the ceiling off") + } +} diff --git a/go/internal/telemetry/store.go b/go/internal/telemetry/store.go index 216101e2f..243160c01 100644 --- a/go/internal/telemetry/store.go +++ b/go/internal/telemetry/store.go @@ -672,6 +672,22 @@ func (s *Store) SumOnlineV2XW() float64 { return sum } +// OnlineMeterW returns a site meter's latest power and when it arrived, +// while its driver is still reporting. A watchdog-offline meter returns +// false, so its last value never counts as a new reading. +func (s *Store) OnlineMeterW(driver string) (w float64, at time.Time, ok bool) { + s.mu.RLock() + defer s.mu.RUnlock() + r, found := s.readings[key(driver, DerMeter)] + if !found || math.IsNaN(r.RawW) || math.IsInf(r.RawW, 0) { + return 0, time.Time{}, false + } + if h, known := s.health[driver]; !known || !h.TelemetryLive() { + return 0, time.Time{}, false + } + return r.RawW, r.UpdatedAt, true +} + // ReadingsByDriver returns all readings from one driver. func (s *Store) ReadingsByDriver(driver string) []*DerReading { s.mu.RLock() diff --git a/go/internal/telemetry/telemetry_test.go b/go/internal/telemetry/telemetry_test.go index dedb65abb..4bc95b6b1 100644 --- a/go/internal/telemetry/telemetry_test.go +++ b/go/internal/telemetry/telemetry_test.go @@ -727,3 +727,22 @@ func TestWatchdogGivesANewDriverItsTimeoutBeforeTheFirstReading(t *testing.T) { t.Fatalf("a restarted driver went stale at once: %+v", tr) } } + +// The import ceiling's hour counts only readings from a meter that is still +// reporting. A watchdog-offline meter's last value is not a new reading. +func TestOnlineMeterWSkipsOfflineMeter(t *testing.T) { + s := NewStore() + if _, _, ok := s.OnlineMeterW("meter"); ok { + t.Fatal("a meter with no reading reported one") + } + s.Update("meter", DerMeter, 7110, nil, nil) + s.DriverHealthMut("meter").RecordSuccess() + w, at, ok := s.OnlineMeterW("meter") + if !ok || w != 7110 || at.IsZero() { + t.Fatalf("online meter: %v %v %v, want 7110 W", w, at, ok) + } + s.DriverHealthMut("meter").SetOffline() + if _, _, ok := s.OnlineMeterW("meter"); ok { + t.Fatal("an offline meter's last reading was returned") + } +} diff --git a/web/index.html b/web/index.html index d32213249..a0fe4d7ef 100644 --- a/web/index.html +++ b/web/index.html @@ -957,10 +957,10 @@

Price bars (top of the chart)

Grid
- +
Peak import limit
-
Hard cap on grid import (tariff peak). Off = no cap; only the physical fuse applies.
+
Caps the average grid import over each clock hour (power tariff). Off = no cap; only the physical fuse applies.