From 66470a40d3569672ed5c93317d5623457194ada4 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 14:55:17 +0200 Subject: [PATCH 1/3] fix(control): the EV hard limit follows the site meter (#1674) The EV hard cap credited a car only when its own readings bracketed a site reading. Easee Cloud reports late and on change only, so missing proof or one 0 W sample counted the car as house load and stopped the charge (05:49: 2,449.5 W of credit at 20.45 A on a 25 A fuse), and an import ceiling made the charger start and stop every half minute. EVFuseBudget now cuts by the overage the meter measures on the worst phase or in total import, taken from the highest offer the meter may still contain. It holds inside the limits, rises one 1 A step when the meter shows room and the cars have taken the last step, and restarts an idle pool only with room for its minimum plus a step. EV readings only gate a rise. In the loadpoint split, keeping an offer on the same phases is no longer an increase, and the phase gate no longer cuts. The bracket credit for the EV cap, the common battery reserve and the dead reactive phase clamp are removed. Overlaps srcfl/ftw#1675. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/ev-meter-hard-limit.md | 5 + go/cmd/ftw/main.go | 13 +- go/internal/control/dispatch.go | 2 + go/internal/control/ev_battery_headroom.go | 50 +- go/internal/control/ev_fuse_async_test.go | 110 ---- go/internal/control/ev_fuse_budget.go | 320 ++++++------ go/internal/control/ev_fuse_budget_test.go | 475 +++++++++++++++--- go/internal/control/ev_pending_battery.go | 30 +- .../control/ev_pending_battery_test.go | 15 +- go/internal/loadpoint/controller.go | 85 +--- .../loadpoint/controller_fuse_budget.go | 44 +- .../loadpoint/controller_fuse_phase_test.go | 24 + .../loadpoint/controller_fusephase_test.go | 63 +-- 13 files changed, 717 insertions(+), 519 deletions(-) create mode 100644 .changeset/ev-meter-hard-limit.md delete mode 100644 go/internal/control/ev_fuse_async_test.go diff --git a/.changeset/ev-meter-hard-limit.md b/.changeset/ev-meter-hard-limit.md new file mode 100644 index 000000000..cf1c8f29d --- /dev/null +++ b/.changeset/ev-meter-hard-limit.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The EV's hard limit now follows the site meter. A charge is cut by the overage the meter measures on the worst phase or in total import, holds while the site is inside its limits, and rises one step at a time once the car has taken the last one. A late or lone 0 W reading from the charger can no longer stop a charge, and an import ceiling no longer makes the charger start and stop every half minute. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index d971f274e..4cf352a1f 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2069,14 +2069,11 @@ func main() { go mpcSvc.ReplanWithReason(context.Background(), "loadpoint_battery_boost_stopped") slog.Info("battery boost stopped — replan requested", "lp", id, "reason", reason) }) - // Wire the live per-phase site-meter current reader. The control - // package's fuse guard is site-TOTAL only (sum across phases); - // a single phase can still trip from house-load imbalance (e.g. - // a vacuum or oven on L1) stacked on top of the EV's per-phase - // draw. This reader feeds the loadpoint's reactive per-phase EV - // clamp (applyPerPhaseFuseClamp), which lowers max_amps_per_phase - // the instant the worst phase approaches the breaker. Reads the - // same meter_lN_a metrics the fuse_over_limit notifier uses. + // Wire the live per-phase site-meter current reader. The EV hard + // limit (control.EVFuseBudget) cuts a worst-phase overage; this + // reader lets the loadpoint gate an increase on the phase a + // runtime 1p or auto charger may land on. Reads the same + // meter_lN_a metrics the fuse_over_limit notifier uses. lpController.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { cfgMu.RLock() siteMeter := cfg.SiteMeterDriver() diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 8d894f8f1..8e517837f 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -371,6 +371,8 @@ type State struct { // battery charging yields even when the EV cannot start yet. evFuseDemands []evFuseDemand evFuseDemandsSet bool + // evLimit is the EVs' meter-driven hard cap, kept between site readings. + evLimit evMeterLimit // PI controller (outer, site-level) PI *PIController diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 209873f17..7bcccd4d7 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -1,7 +1,6 @@ package control import ( - "encoding/json" "math" "sort" "time" @@ -282,6 +281,38 @@ func capBatteryOffersToHeadroom(targets []DispatchTarget, baselines, uppers map[ return out } +// A managed EV earns replacement credit only across a site observation that +// its raw power brackets. A later start or an earlier stop cannot invent +// spare capacity for an optional battery increase. +func bracketedEVPower(points []telemetry.ControlObservation, at time.Time, latest float64) (float64, bool) { + var before, after telemetry.ControlObservation + hasBefore, hasAfter := false, false + for _, p := range points { + if !finiteFuseValue(p.PowerW) || p.PowerW < 0 { + continue + } + if !p.At.After(at) && (!hasBefore || p.At.After(before.At)) { + before, hasBefore = p, true + } + if !p.Seen().Before(at) && (!hasAfter || p.At.Before(after.At)) { + after, hasAfter = p, true + } + } + if !hasBefore || !hasAfter { + return 0, false + } + baseline := math.Max(0, math.Min(latest, math.Min(before.PowerW, after.PowerW))) + // Another slow device may force an older common site observation. A + // later observed stop still removes its replacement credit, even if + // the charger has since started again. + for _, point := range points { + if !point.Seen().Before(at) && finiteFuseValue(point.PowerW) && point.PowerW >= 0 { + baseline = math.Min(baseline, point.PowerW) + } + } + return baseline, true +} + // A source sample before this site sample may hold its current level. A // later start needs bracketed replacement credit before it can fund change. func evPowerAtLatestSite(points []telemetry.ControlObservation, at time.Time, current telemetry.ControlObservation) float64 { @@ -395,24 +426,13 @@ func bracketedBatteryPower(points []telemetry.ControlObservation, at time.Time, } func evBatteryPhaseSpareW(meter *telemetry.DerReading, state *State, evIncreaseA float64) (float64, bool) { - var data struct { - L1 *float64 `json:"l1_a"` - L2 *float64 `json:"l2_a"` - L3 *float64 `json:"l3_a"` - } - if json.Unmarshal(meter.Data, &data) != nil || (data.L1 == nil && data.L2 == nil && data.L3 == nil) { + worst, present, valid := worstPhaseA(meter, state) + if !present { return 0, false } - if state.SiteFusePhases < 1 || state.SiteFusePhases > 3 || state.SiteFuseVoltage <= 0 || state.SiteFuseAmps <= 0 { + if !valid { return math.Inf(-1), true } - worst := 0.0 - for _, amps := range []*float64{data.L1, data.L2, data.L3}[:state.SiteFusePhases] { - if amps == nil || !finiteFuseValue(*amps) { - return math.Inf(-1), true - } - worst = math.Max(worst, math.Abs(*amps)) - } margin := math.Max(1, state.SiteFuseSafetyA) // Same working margin as the EV phase guard. return (state.SiteFuseAmps - margin - worst - evIncreaseA) * state.SiteFuseVoltage * float64(state.SiteFusePhases), true } diff --git a/go/internal/control/ev_fuse_async_test.go b/go/internal/control/ev_fuse_async_test.go deleted file mode 100644 index c01989ba4..000000000 --- a/go/internal/control/ev_fuse_async_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package control - -import ( - "encoding/json" - "sort" - "testing" - "time" - - "github.com/srcfl/ftw/go/internal/loadpoint" - "github.com/srcfl/ftw/go/internal/telemetry" -) - -type externalAuditRawEvent struct { - kind telemetry.DerType - secondsAgo int - watts float64 - confirmed bool -} - -func TestAsynchronousPowerCredit(t *testing.T) { - cases := []struct { - name string - events []externalAuditRawEvent - minCap, maxCap float64 - }{ - {"steady older EV and newer site", []externalAuditRawEvent{{telemetry.DerEV, 8, 10000, false}, {telemetry.DerMeter, 7, 13000, false}, {telemetry.DerEV, 4, 10000, false}, {telemetry.DerMeter, 3, 13000, false}}, 10000, 14250}, - {"steady newer EV and older site", []externalAuditRawEvent{{telemetry.DerEV, 8, 10000, false}, {telemetry.DerMeter, 7, 13000, false}, {telemetry.DerMeter, 5, 13000, false}, {telemetry.DerEV, 4, 10000, false}}, 10000, 14250}, - {"new EV rise cannot be subtracted from older site", []externalAuditRawEvent{{telemetry.DerEV, 8, 0, false}, {telemetry.DerMeter, 7, 1000, false}, {telemetry.DerEV, 4, 11000, false}}, 0, 16250}, - {"old high EV then observed stop", []externalAuditRawEvent{{telemetry.DerEV, 8, 11000, false}, {telemetry.DerMeter, 7, 12000, false}, {telemetry.DerEV, 4, 0, false}, {telemetry.DerMeter, 3, 1000, false}}, 0, 16250}, - {"qualified EV permits trimming a small total overload", []externalAuditRawEvent{{telemetry.DerEV, 8, 10000, false}, {telemetry.DerMeter, 7, 17750, false}, {telemetry.DerEV, 4, 10000, false}}, 1380, 9500}, - {"fresh actual overload beats an older stable bracket", []externalAuditRawEvent{{telemetry.DerEV, 8, 10000, false}, {telemetry.DerMeter, 7, 13000, false}, {telemetry.DerEV, 4, 10000, false}, {telemetry.DerMeter, 3, 30000, false}}, 0, 0}, - {"confirmed unchanged EV supports normal asynchronous site", []externalAuditRawEvent{{telemetry.DerEV, 8, 10000, false}, {telemetry.DerEV, 4, 10000, true}, {telemetry.DerMeter, 3, 13000, false}}, 10000, 14250}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - now := time.Now() - events := append([]externalAuditRawEvent(nil), tc.events...) - sort.SliceStable(events, func(i, j int) bool { return events[i].secondsAgo > events[j].secondsAgo }) - store := telemetry.NewStore() - for _, e := range events { - driver := "meter" - if e.kind == telemetry.DerEV { - driver = "car" - } - data, _ := json.Marshal(map[string]any{"control_power_observed_at": now.Add(-time.Duration(e.secondsAgo) * time.Second).Format(time.RFC3339Nano), "control_power_available": true, "control_power_confirmed": e.confirmed}) - store.Update(driver, e.kind, e.watts, nil, data) - store.DriverHealthMut(driver).RecordSuccess() - } - st := NewState(0, 0, "meter") - st.SiteFuseAmps = 25 - st.SiteFuseVoltage = 230 - st.SiteFusePhases = 3 - capW, known := EVFuseBudget(store, st, []loadpoint.State{{ID: "a", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "a", DriverName: "car", PhaseMode: "3p"}}, 17250) - if !known || capW < tc.minCap-0.001 || capW > tc.maxCap+0.001 { - t.Fatalf("cap=%v known=%v want[%v,%v]", capW, known, tc.minCap, tc.maxCap) - } - }) - } -} - -func TestExistingPhaseOverloadTrimsKnownThreePhaseEV(t *testing.T) { - now := time.Now() - store := telemetry.NewStore() - emit := func(driver string, kind telemetry.DerType, at time.Time, w float64, amps bool) { - data := map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano), "control_power_available": true} - if amps { - data["l1_a"] = 26.0 - data["l2_a"] = 5.0 - data["l3_a"] = 5.0 - } - b, _ := json.Marshal(data) - store.Update(driver, kind, w, nil, b) - store.DriverHealthMut(driver).RecordSuccess() - } - emit("car", telemetry.DerEV, now.Add(-8*time.Second), 10350, false) - emit("meter", telemetry.DerMeter, now.Add(-7*time.Second), 8280, true) - emit("car", telemetry.DerEV, now.Add(-4*time.Second), 10350, false) - st := NewState(0, 0, "meter") - st.SiteFuseAmps = 25 - st.SiteFuseVoltage = 230 - st.SiteFusePhases = 3 - capW, known := EVFuseBudget(store, st, []loadpoint.State{{ID: "a", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "a", DriverName: "car", PhaseMode: "3p"}}, 17250) - // Current EV is15A on each phase; its underlying L1 net load is11A. - // A24A operating ceiling permits at most13A (=8970W) of EV charge. - if !known || capW <= 0 || capW > 8970.001 { - t.Fatalf("known3p phase overload needs safe positive trim≤8970W, cap=%v known=%v", capW, known) - } -} - -func TestObservedZeroCannotDisappearFromPowerCredit(t *testing.T) { - store := telemetry.NewStore() - at := time.Now().Add(-4 * time.Second).Truncate(time.Second) - emit := func(driver string, kind telemetry.DerType, stamp time.Time, w float64) { - d, _ := json.Marshal(map[string]any{"control_power_observed_at": stamp.Format(time.RFC3339Nano), "control_power_available": true}) - store.Update(driver, kind, w, nil, d) - store.RecordDriverSuccess(driver) - } - emit("car", telemetry.DerEV, at.Add(-100*time.Millisecond), 11000) - emit("car", telemetry.DerEV, at.Add(100*time.Millisecond), 0) - emit("meter", telemetry.DerMeter, at.Add(500*time.Millisecond), 1000) - emit("car", telemetry.DerEV, at.Add(900*time.Millisecond), 11000) - st := NewState(0, 0, "meter") - st.SiteFuseAmps = 25 - st.SiteFuseVoltage = 230 - st.SiteFusePhases = 3 - capW, known := EVFuseBudget(store, st, []loadpoint.State{{ID: "a", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "a", DriverName: "car", PhaseMode: "3p"}}, 17250) - if !known || capW > 16250.001 { - t.Fatalf("observed zero erased around site1kW: cap=%v known=%v, must≤16250W", capW, known) - } -} diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 1008b0d84..8a09148ad 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -2,8 +2,8 @@ package control import ( "encoding/json" + "log/slog" "math" - "sort" "time" "github.com/srcfl/ftw/go/internal/loadpoint" @@ -21,15 +21,41 @@ func evHeadroomW(ceiling, gridW, poolW float64) float64 { func finiteFuseValue(w float64) bool { return !math.IsNaN(w) && !math.IsInf(w, 0) } -type evFuseBalance struct { - gridW, poolW, pendingW float64 - latestPoolW, latestPendingW float64 +// evSettle is how long a charger may take to show a new current at the site +// meter; Easee Cloud needs about 5–15 s (#1674). Until then the meter may +// still contain the higher offer, so no overage is cut twice and no rise +// follows an unseen step. +const evSettle = 20 * time.Second + +// evPhaseMarginA is the working margin below the breaker on each phase. One +// amp on the pool's phases is also the rise step and the restart margin. +const evPhaseMarginA = 1.0 + +// evMeterLimit is the managed EVs' common hard cap. The site meter measures +// the protected quantities, worst phase and total import, so it alone decides +// a cut. EV readings only gate a rise: a lone or late 0 W sample cannot stop +// a charge. +type evMeterLimit struct { + set bool + capW float64 + changedAt time.Time // last cut, rise or idle start level + peakW float64 // highest offer the meter may still contain + peakAt time.Time } -// EVFuseBudget refreshes the common EV cap before a charger command, also -// without batteries. The shared freshness gate must already allow dispatch. -// Other EVs remain measured household demand. Callers hold the control lock. -// The controller also applies its final tick's phase choice before sending. +type evPool struct { + offerW, drawW, wantW float64 + drawKnown bool + minW, phases float64 +} + +// EVFuseBudget returns the managed EVs' common hard cap, which the loadpoint +// controller divides between cars. Callers hold the control lock, and the +// shared freshness gate must already allow dispatch. An overage on the latest +// site reading cuts at once; inside the limits the cap holds, and it rises +// one step only when the meter shows room and the cars have taken the last +// step. An idle pool starts only with room for its minimum. Repeating a call +// on the same reading changes nothing. func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State, configs []loadpoint.Config, fuseMaxW float64) (float64, bool) { if store == nil || state == nil || !finiteFuseValue(fuseMaxW) || fuseMaxW <= 0 { return 0, true @@ -38,167 +64,179 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State if meter == nil || !finiteFuseValue(meter.RawW) { return 0, true } - now := state.now() - balance := coherentEVBalance(store, state, meter, points, now) - // The common balance includes later observed DER increases. Keep the - // newest site/phase read as an independent bound so the same increase is - // not counted once in its grid watts and again as a pending command. - cap := evHeadroomW(state.effectiveImportCeilingW(fuseMaxW), balance.gridW+balance.pendingW, balance.poolW) - latestCap := evHeadroomW(state.phaseImportCeilingW(fuseMaxW, meter), meter.RawW+balance.latestPendingW, balance.latestPoolW) - if phaseW, known := evPhaseIncreaseW(meter, state, configs, balance.latestPoolW); known { - latestCap = math.Min(latestCap, math.Max(0, balance.latestPoolW+phaseW-balance.latestPendingW)) - } - return math.Min(cap, latestCap), true + lim := &state.evLimit + pool, plugged := evPoolFor(store, state, points, configs, fuseMaxW) + if !plugged { + *lim = evMeterLimit{} + return 0, true + } + state.stepEVMeterLimit(store, meter, pool, fuseMaxW) + return lim.capW, true } -// A managed EV earns replacement credit only across a site observation that -// its raw power brackets. This keeps normal asynchronous polls useful while -// preventing a later start or an earlier stop from inventing spare capacity. -// The existing bounded telemetry ring owns the samples; no second cache or -// filtered load estimate participates in the hard limit. -func coherentEVBalance(store *telemetry.Store, state *State, meter *telemetry.DerReading, points []loadpoint.State, now time.Time) evFuseBalance { - balance := evFuseBalance{gridW: meter.RawW} - siteAt := meter.UpdatedAt - from := now.Add(-telemetry.ControlWindowDuration) - windows := store.ControlWindows(from, now) - batteries := pendingBatterySeriesForFuse(store, state, windows, now) - sites := windows[state.SiteMeterDriver+":"+telemetry.DerMeter.String()].Points - if current, ok := qualifiedFuseObservation(meter, now); ok { - current.At = current.Seen() - siteAt = current.At - sites = append(sites, current) +func (s *State) stepEVMeterLimit(store *telemetry.Store, meter *telemetry.DerReading, pool evPool, fuseMaxW float64) { + lim, now := &s.evLimit, s.now() + if !lim.set || pool.offerW >= lim.peakW || now.Sub(lim.peakAt) >= evSettle { + lim.peakW, lim.peakAt = pool.offerW, now + } + prev := math.Inf(1) + if lim.set { + prev = lim.capW + } + lim.set = true + roomW, measured, worstA := s.evMeterRoomW(meter, pool.phases, fuseMaxW) + if roomW < 0 { + // The meter may still contain up to peakW of EV power. Taking the + // overage from that peak cuts each measured watt once. + lim.capW = math.Min(prev, math.Max(0, lim.peakW+roomW)) + if lim.capW < prev { + lim.changedAt = now + slog.Info("EV hard limit: site meter over limit", "grid_w", meter.RawW, "worst_phase_a", worstA, + "overage_w", -roomW, "offer_w", lim.peakW, "cap_w", lim.capW) + } + return } - type evSeries struct { - latest float64 - current telemetry.ControlObservation - points []telemetry.ControlObservation + // An increase needs measured room left after outstanding battery orders. + room := 0.0 + if measured { + room = math.Max(0, roomW-s.pendingBatteryIncreaseW(store, meter)) } - var series []evSeries - seen := map[string]bool{} - for _, point := range points { - if !point.PluggedIn || seen[point.DriverName] { - continue - } - seen[point.DriverName] = true - r := store.Get(point.DriverName, telemetry.DerEV) - h := store.DriverHealth(point.DriverName) - current, ok := qualifiedFuseObservation(r, now) - if !ok || h == nil || !h.TelemetryLive() || current.PowerW <= 0 { - continue // Unknown EV power stays inside the measured site demand. + step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases + if pool.offerW <= 0 { + lim.capW, lim.changedAt = 0, now + if room >= pool.minW+step { + lim.capW = room } - ps := windows[point.DriverName+":"+telemetry.DerEV.String()].Points - ps = append(ps, current) // Includes explicit change-only confirmations. - series = append(series, evSeries{math.Min(current.PowerW, r.RawW), current, ps}) + return } - for _, ev := range series { - balance.latestPoolW += evPowerAtLatestSite(ev.points, siteAt, ev.current) + // Keeping the current offer is never a cut. Headroom above it stays + // within what this reading measured. + lim.capW = math.Min(prev, math.Max(pool.offerW, pool.drawW)+room) + if math.IsInf(prev, 1) { + lim.changedAt = now // The first reading sets the cap; a rise waits. + return } - balance.latestPendingW = pendingBatteryIncreaseAtLatestSite(batteries, siteAt) - sort.SliceStable(sites, func(i, j int) bool { return sites[i].At.After(sites[j].At) }) - for _, site := range sites { - if site.At.Before(from) || site.At.After(now) || !finiteFuseValue(site.PowerW) { - continue - } - credit, coherent := 0.0, true - for _, ev := range series { - w, ok := bracketedEVPower(ev.points, site.At, ev.latest) - if !ok { - coherent = false - break - } - credit += w - } - if coherent { - balance.pendingW, coherent = pendingBatteryIncreaseAt(batteries, site.At) - } - if coherent { - balance.gridW, balance.poolW = site.PowerW, credit - return balance - } + if lim.capW < pool.wantW && room >= step && now.Sub(lim.changedAt) >= evSettle && + (!pool.drawKnown || pool.drawW >= pool.offerW-step) { + lim.capW += step + lim.changedAt = now } - balance.pendingW, _ = pendingBatteryIncreaseAt(batteries, siteAt) - return balance } -func qualifiedFuseObservation(r *telemetry.DerReading, now time.Time) (telemetry.ControlObservation, bool) { - if r == nil || !finiteFuseValue(r.RawW) || r.UpdatedAt.After(now) || now.Sub(r.UpdatedAt) > telemetry.ControlPowerMaxAge(r.Data) { - return telemetry.ControlObservation{}, false - } - p, ok := telemetry.ControlPowerObservation(r.RawW, r.Data, r.UpdatedAt) - return p, ok && !p.Seen().After(now) && now.Sub(p.Seen()) <= telemetry.ControlWindowDuration +// 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, phases, fuseMaxW float64) (roomW float64, measured bool, worstA float64) { + roomW = s.effectiveImportCeilingW(fuseMaxW) - meter.RawW + 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 } -func bracketedEVPower(points []telemetry.ControlObservation, at time.Time, latest float64) (float64, bool) { - var before, after telemetry.ControlObservation - hasBefore, hasAfter := false, false - for _, p := range points { - if !finiteFuseValue(p.PowerW) || p.PowerW < 0 { - continue - } - if !p.At.After(at) && (!hasBefore || p.At.After(before.At)) { - before, hasBefore = p, true - } - if !p.Seen().Before(at) && (!hasAfter || p.At.Before(after.At)) { - after, hasAfter = p, true - } - } - if !hasBefore || !hasAfter { - return 0, false - } - baseline := math.Max(0, math.Min(latest, math.Min(before.PowerW, after.PowerW))) - // Another slow device may force an older common site observation. A - // later observed stop still removes its replacement credit, even if - // the charger has since started again. - for _, point := range points { - if !point.Seen().Before(at) && finiteFuseValue(point.PowerW) && point.PowerW >= 0 { - baseline = math.Min(baseline, point.PowerW) - } - } - return baseline, true -} - -// Phase-current magnitudes cannot tell us that an exporting phase makes -// room on an importing one. Bound the actual increase on the worst phase. -func evPhaseIncreaseW(meter *telemetry.DerReading, state *State, configs []loadpoint.Config, poolW float64) (float64, bool) { +// worstPhaseA reads the site's configured phases. present is false when the +// meter reports no phase currents; valid is false when they cannot be used. +func worstPhaseA(meter *telemetry.DerReading, state *State) (worstA float64, present, valid bool) { // DER data uses snake case; separate fields retain absent-vs-zero. var data struct { L1 *float64 `json:"l1_a"` L2 *float64 `json:"l2_a"` L3 *float64 `json:"l3_a"` } - if json.Unmarshal(meter.Data, &data) != nil { - return 0, false - } - amps := [...]*float64{data.L1, data.L2, data.L3} - known := data.L1 != nil || data.L2 != nil || data.L3 != nil - if !known { - return 0, false + if json.Unmarshal(meter.Data, &data) != nil || (data.L1 == nil && data.L2 == nil && data.L3 == nil) { + return 0, false, false } if !finiteFuseValue(state.SiteFuseAmps) || !finiteFuseValue(state.SiteFuseVoltage) || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases < 1 || state.SiteFusePhases > 3 { - return 0, true + return 0, true, false } - worst := 0.0 - for _, a := range amps[:state.SiteFusePhases] { - if a == nil || !finiteFuseValue(*a) { - return 0, true + for _, amps := range []*float64{data.L1, data.L2, data.L3}[:state.SiteFusePhases] { + if amps == nil || !finiteFuseValue(*amps) { + return 0, true, false } - worst = math.Max(worst, math.Abs(*a)) + worstA = math.Max(worstA, math.Abs(*amps)) + } + return worstA, true, true +} + +func (s *State) pendingBatteryIncreaseW(store *telemetry.Store, meter *telemetry.DerReading) float64 { + now := s.now() + siteAt := meter.UpdatedAt + if current, ok := qualifiedFuseObservation(meter, now); ok { + siteAt = current.Seen() } - phases := state.SiteFusePhases - unknownPhase := false + windows := store.ControlWindows(now.Add(-telemetry.ControlWindowDuration), now) + return pendingBatteryIncreaseAtLatestSite(pendingBatterySeriesForFuse(store, s, windows, now), siteAt) +} + +// evPoolFor sums the plugged managed chargers. Before the first command the +// cars' readings, or else the fuse, bound what the meter contains. +func evPoolFor(store *telemetry.Store, state *State, points []loadpoint.State, configs []loadpoint.Config, fuseMaxW float64) (evPool, bool) { + byID := make(map[string]loadpoint.Config, len(configs)) for _, cfg := range configs { - // Empty retains the config's declared three-phase default. The - // controller's final guard covers runtime auto/manual phase changes. + byID[cfg.ID] = cfg + } + pool := evPool{phases: 3, wantW: math.Inf(1), drawKnown: true} + offerKnown := true + seen := map[string]bool{} + now := state.now() + for _, point := range points { + if !point.PluggedIn { + continue + } + cfg := byID[point.ID] + // Empty is the declared three-phase default. The controller's own + // gate covers runtime auto/manual phase changes. if cfg.PhaseMode != "" && cfg.PhaseMode != "3p" { - phases = 1 // No promise about an auto charger's current phase dwell. - unknownPhase = true + pool.phases = 1 + } + if cfg.MinChargeW > 0 && (pool.minW == 0 || cfg.MinChargeW < pool.minW) { + pool.minW = cfg.MinChargeW } + offerKnown = offerKnown && point.CommandedKnown + if seen[point.DriverName] { + continue + } + seen[point.DriverName] = true + r := store.Get(point.DriverName, telemetry.DerEV) + h := store.DriverHealth(point.DriverName) + if current, ok := qualifiedFuseObservation(r, now); ok && h != nil && h.TelemetryLive() { + pool.drawW += math.Max(0, current.PowerW) + } else { + pool.drawKnown = false + } + } + if len(seen) == 0 { + return pool, false } - deltaA := state.SiteFuseAmps - 1 - worst - if deltaA < 0 && unknownPhase { - return -poolW, true // Cannot prove which single-phase car relieves it. + pool.offerW = loadpoint.CommandedEVPowerW(points) + if !offerKnown { + pool.offerW = fuseMaxW + if pool.drawKnown { + pool.offerW = pool.drawW + } + } + if state.evFuseDemandsSet { + pool.wantW = 0 + for _, demand := range state.evFuseDemands { + pool.wantW += demand.powerW + } } - return deltaA * state.SiteFuseVoltage * float64(phases), true + if state.SiteFusePhases >= 1 && float64(state.SiteFusePhases) < pool.phases { + pool.phases = float64(state.SiteFusePhases) + } + return pool, true +} + +func qualifiedFuseObservation(r *telemetry.DerReading, now time.Time) (telemetry.ControlObservation, bool) { + if r == nil || !finiteFuseValue(r.RawW) || r.UpdatedAt.After(now) || now.Sub(r.UpdatedAt) > telemetry.ControlPowerMaxAge(r.Data) { + return telemetry.ControlObservation{}, false + } + p, ok := telemetry.ControlPowerObservation(r.RawW, r.Data, r.UpdatedAt) + return p, ok && !p.Seen().After(now) && now.Sub(p.Seen()) <= telemetry.ControlWindowDuration } func publishBatterylessEVFuseBudget(store *telemetry.Store, state *State, fuseMaxW float64) { diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go index 718224281..25e7169cd 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -1,6 +1,7 @@ package control import ( + "context" "encoding/json" "math" "testing" @@ -10,84 +11,422 @@ import ( "github.com/srcfl/ftw/go/internal/telemetry" ) -func TestLiveFuseBudgetKeepsDeclaredPhaseDefault(t *testing.T) { +type evTimed struct { + at time.Time + w float64 +} + +// evMeterSite runs one Easee-like 3p charger with Charge now against a +// simulated 25 A site. A new current takes chargerDelay to reach the car. +// Like Easee Cloud, the driver polls every 5 s and receives the draw as it +// was telemetryDelay ago. The meter reads the physical import on every +// one-second tick, and the loadpoint ticks right after it, as in main.go. +type evMeterSite struct { + t *testing.T + now time.Time + store *telemetry.Store + state *State + mgr *loadpoint.Manager + ctrl *loadpoint.Controller + chargerDelay, telemetryDelay time.Duration + houseW float64 + housePhaseA [3]float64 + carMaxW float64 // the car takes at most this + carPaused, chargerIgnores bool + appliedW, evW float64 + reportedW float64 + reportedAt, nextPoll time.Time + commands, draws []evTimed + offers []evTimed // every ev_set_current + maxGridW float64 +} + +const evMeterSteps = "0,4140,4830,5520,6210,6900,7590,8280,8970,9660,10350" + +func newEVMeterSite(t *testing.T, ceilingW, houseW float64) *evMeterSite { + t.Helper() + s := &evMeterSite{t: t, now: time.Now().Truncate(time.Second), store: telemetry.NewStore(), + chargerDelay: 5 * time.Second, telemetryDelay: 10 * time.Second, carMaxW: math.Inf(1)} + s.setHouse(houseW) + s.state = NewState(0, 0, "meter") + s.state.SiteFuseAmps, s.state.SiteFuseVoltage, s.state.SiteFusePhases, s.state.SiteFuseSafetyA = 25, 230, 3, 0.5 + s.state.PeakImportCeilingW = ceilingW + s.state.clock = func() time.Time { return s.now } + cfg := loadpoint.Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 10350, PhaseMode: "3p", + AllowedStepsW: []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350}} + s.mgr = loadpoint.NewManager() + s.mgr.Load([]loadpoint.Config{cfg}) + 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, SessionWhUnavailable: true}, true + }, + func(_ context.Context, _ string, payload []byte) error { + var cmd struct { + Action string `json:"action"` + PowerW float64 `json:"power_w"` + } + if err := json.Unmarshal(payload, &cmd); err != nil { + return err + } + if cmd.Action == "ev_set_current" { + s.offers = append(s.offers, evTimed{s.now, cmd.PowerW}) + s.commands = append(s.commands, evTimed{s.now.Add(s.chargerDelay), cmd.PowerW}) + } + return nil + }) + s.ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + s.ctrl.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { + a := s.phaseA() + return a[0], a[1], a[2], true + }) + s.ctrl.SetFuseEVMax(func() (float64, bool) { + 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}) + s.draws = []evTimed{{s.now.Add(-time.Hour), 0}} + s.report(0) + return s +} + +func (s *evMeterSite) setHouse(w float64) { + s.houseW = w + a := w / 690 + s.housePhaseA = [3]float64{a, a, a} +} + +func (s *evMeterSite) phaseA() [3]float64 { + a := s.housePhaseA + for i := range a { + a[i] += s.evW / 690 + } + return a +} + +func (s *evMeterSite) emit(driver string, kind telemetry.DerType, w float64, extra map[string]any, at time.Time) { + d := map[string]any{"control_power_available": true, "control_power_observed_at": at.Format(time.RFC3339Nano)} + for k, v := range extra { + d[k] = v + } + data, _ := json.Marshal(d) + s.store.Update(driver, kind, w, nil, data) + s.store.Get(driver, kind).UpdatedAt = s.now // Receipt on the simulated clock. + s.store.RecordDriverSuccess(driver) +} + +// report delivers one poll. The receipt time is all the driver knows. +func (s *evMeterSite) report(w float64) { + s.reportedW, s.reportedAt = w, s.now + s.emit("easee", telemetry.DerEV, w, nil, s.now) +} + +func (s *evMeterSite) cloudW() float64 { + w, seen := 0.0, s.now.Add(-s.telemetryDelay) + for _, d := range s.draws { + if !d.at.After(seen) { + w = d.w + } + } + return w +} + +func (s *evMeterSite) step() { + s.now = s.now.Add(time.Second) + for len(s.commands) > 0 && !s.commands[0].at.After(s.now) { + if !s.chargerIgnores { + s.appliedW = s.commands[0].w + } + s.commands = s.commands[1:] + } + draw := math.Min(s.appliedW, s.carMaxW) + if s.carPaused { + draw = 0 + } + if draw != s.evW { + s.evW = draw + s.draws = append(s.draws, evTimed{s.now, draw}) + } + if !s.now.Before(s.nextPoll) { + s.report(s.cloudW()) + s.nextPoll = s.now.Add(5 * time.Second) + } + a := s.phaseA() + grid := s.houseW + s.evW + s.maxGridW = math.Max(s.maxGridW, grid) + s.emit("meter", telemetry.DerMeter, grid, map[string]any{"l1_a": a[0], "l2_a": a[1], "l3_a": a[2]}, s.now) + s.ctrl.TickWithDispatch(context.Background(), s.now, true) + s.state.SetEVFuseDemands(s.ctrl.FuseDemands()) +} + +func (s *evMeterSite) run(d time.Duration) { + for end := s.now.Add(d); s.now.Before(end); { + s.step() + } +} + +func (s *evMeterSite) offer() float64 { + if len(s.offers) == 0 { + return 0 + } + return s.offers[len(s.offers)-1].w +} + +func (s *evMeterSite) offersSince(at time.Time) []float64 { + var out []float64 + for _, o := range s.offers { + if !o.at.Before(at) { + out = append(out, o.w) + } + } + return out +} + +// steady brings the car to a constant draw with the telemetry caught up. +func (s *evMeterSite) steady(wantOffer float64) { + s.t.Helper() + s.run(3 * time.Minute) + if s.offer() != wantOffer || s.evW != wantOffer || s.reportedW != wantOffer { + s.t.Fatalf("setup: offer %.0f draw %.0f reported %.0f, want %.0f W", s.offer(), s.evW, s.reportedW, wantOffer) + } +} + +// #1674, 05:49:15: the import ceiling was off and the car drew 10,242 W at +// 15 A. One 0 W Easee sample arrived while Pixii read 20.45/17.05/15.87 A +// and 12,323 W on the 25 A fuse. Proof-based credit gave +// (25-1-20.45)x230x3 = 2,449.5 W, below the 4,140 W step, and stopped the +// charge. The meter shows room on every phase, so the offer must hold. +func TestEVHardLimitHoldsOfferThroughLoneZeroSample(t *testing.T) { + s := newEVMeterSite(t, 0, 2081) + s.carMaxW = 10242 + s.housePhaseA = [3]float64{20.45 - 10242.0/690, 17.05 - 10242.0/690, 15.87 - 10242.0/690} + s.run(3 * time.Minute) + if s.offer() != 10350 || s.evW != 10242 { + t.Fatalf("setup: offer %.0f draw %.0f", s.offer(), s.evW) + } + if a := s.phaseA(); math.Abs(a[0]-20.45) > .01 || math.Abs(s.houseW+s.evW-12323) > .01 { + t.Fatalf("setup: L1 %.2f A grid %.0f W", a[0], s.houseW+s.evW) + } + zeroAt := s.now + s.report(0) // 05:49:13, the lone zero + s.step() + capW, _ := EVFuseBudget(s.store, s.state, s.mgr.States(), s.mgr.Configs(), 17250) + s.report(10242) // 05:49:18 + s.run(time.Minute) + if capW < 10350 { + t.Fatalf("lone 0 W sample cut the hard cap to %.1f W", capW) + } + for _, w := range s.offersSince(zeroAt) { + if w != 10350 { + t.Fatalf("offer left 10,350 W after a lone 0 W sample: %v", s.offersSince(zeroAt)) + } + } +} + +// #1674, 23:45: a 7.5 kW import ceiling, the car at 9 A beside 0.9 kW of +// house load, and Easee telemetry 5-15 s late. The charge started and +// stopped every 30 s. It must start once and stay inside the ceiling. +func TestEVHardLimitStartsOnceUnderImportCeilingWithLateTelemetry(t *testing.T) { + for _, delay := range []time.Duration{5 * time.Second, 10 * time.Second, 15 * time.Second} { + t.Run(delay.String(), func(t *testing.T) { + s := newEVMeterSite(t, 7500, 900) + s.telemetryDelay = delay + s.run(5 * time.Minute) + started := false + for _, o := range s.offers { + if o.w > 0 { + started = true + } else if started { + t.Fatalf("start/stop cycle: offers %v", s.offersSince(time.Time{})) + } + } + if !started || s.offer() != 6210 { + t.Fatalf("offer %.0f W, want a steady 6,210 W", s.offer()) + } + if s.maxGridW > 7500 { + t.Fatalf("grid reached %.0f W above the 7,500 W ceiling", s.maxGridW) + } + }) + } +} + +// A real house step pushes the grid to 8.15 kW under the 7.5 kW ceiling. +// The offer steps down by the measured overage, once, and never stops. +func TestEVHardLimitHouseStepStepsDownOnce(t *testing.T) { + s := newEVMeterSite(t, 7500, 900) + s.steady(6210) + stepAt := s.now + s.setHouse(1940) // 1,940 + 6,210 = 8,150 W + s.run(evSettle + 10*time.Second) + got := s.offersSince(stepAt) + for _, w := range got { + if w != 6210 && w != 5520 { + t.Fatalf("house step gave offers %v, want one step to 5,520 W", got) + } + } + if s.offer() != 5520 || s.houseW+s.evW > 7500 { + t.Fatalf("offer %.0f W grid %.0f W after the step", s.offer(), s.houseW+s.evW) + } +} + +// The car pauses by itself, the house rises into the room it left, and the +// car resumes. Nothing stopped it while paused; the next meter reading cuts. +func TestEVHardLimitCutsOnNextReadingWhenCarResumesIntoHouseLoad(t *testing.T) { + s := newEVMeterSite(t, 7500, 900) + s.steady(6210) + s.carPaused = true + s.run(30 * time.Second) + s.setHouse(5000) + s.run(30 * time.Second) + offered := s.offer() + if offered < 6210 { + t.Fatalf("paused car lost its offer: %.0f W", offered) + } + s.carPaused = false + s.step() // The resumed draw is in this reading. + if over := s.houseW + s.evW - 7500; over <= 0 || s.offer() > math.Max(0, offered-over) { + t.Fatalf("overage %.0f W left offer %.0f W after the first reading", over, s.offer()) + } +} + +// After a cut the cap rises one step per settle time, and only while the +// car takes what it was offered. A paused car does not wind it up. +func TestEVHardLimitRisesOneSeenStepAtATime(t *testing.T) { + s := newEVMeterSite(t, 7500, 900) + s.steady(6210) + s.carPaused = true + s.run(s.telemetryDelay + 5*time.Second) // The cloud now reports 0 W. + pausedOffer := s.offer() + s.setHouse(2600) // Room for the paused car only: no cut. + s.run(2 * time.Minute) + if s.offer() != pausedOffer { + t.Fatalf("cap moved %.0f -> %.0f W while the car reported 0 W", pausedOffer, s.offer()) + } + s.carPaused = false + s.run(time.Minute) // 2,600 + 6,210 = 8,810 W: cut to 4,830 W + if s.offer() != 4830 { + t.Fatalf("offer %.0f W after a 1,310 W overage", s.offer()) + } + s.setHouse(900) + recoveredAt := s.now + s.run(3 * time.Minute) + got := s.offersSince(recoveredAt) + for i := 1; i < len(got); i++ { + if got[i]-got[i-1] > 690 { + t.Fatalf("cap rose more than one step at once: %v", got) + } + } + if s.offer() != 6210 || s.houseW+s.evW > 7500 { + t.Fatalf("recovered to %.0f W at %.0f W grid", s.offer(), s.houseW+s.evW) + } +} + +// A charger that ignores a cut still gets the next one once its old +// current should have reached the meter. +func TestEVHardLimitCutsAgainAfterSettleWhenChargerIgnores(t *testing.T) { + s := newEVMeterSite(t, 7500, 900) + s.steady(6210) + s.chargerIgnores = true + stepAt := s.now + s.setHouse(1940) + s.run(evSettle - 2*time.Second) + if s.offer() != 5520 { + t.Fatalf("offers %v, want one cut within the settle time", s.offersSince(stepAt)) + } + s.run(4 * time.Second) + if s.offer() >= 5520 { + t.Fatalf("ignored cut was not repeated: %v", s.offersSince(stepAt)) + } +} + +func evMeterBudgetFixture(t *testing.T, gridW float64, phaseA []float64, offerW float64, phase string) (*telemetry.Store, *State, []loadpoint.State, []loadpoint.Config) { + t.Helper() + store := telemetry.NewStore() + data := map[string]any{} + if phaseA != nil { + data["l1_a"], data["l2_a"], data["l3_a"] = phaseA[0], phaseA[1], phaseA[2] + } + raw, _ := json.Marshal(data) + store.Update("meter", telemetry.DerMeter, gridW, nil, raw) + store.RecordDriverSuccess("meter") + state := NewState(0, 0, "meter") + state.SiteFuseAmps, state.SiteFuseVoltage, state.SiteFusePhases = 25, 230, 3 + points := []loadpoint.State{{ID: "car", DriverName: "charger", PluggedIn: true, CommandedKnown: true, CommandedW: offerW}} + configs := []loadpoint.Config{{ID: "car", DriverName: "charger", PhaseMode: phase, MinChargeW: 1380, MaxChargeW: 13110}} + return store, state, points, configs +} + +// A known 3p car at 15 A beside 11 A of house load on L1: 26 A on a 24 A +// working limit. Two amps on three phases come off the offer at once. +func TestEVHardLimitTrimsPhaseOverageFromOffer(t *testing.T) { + store, state, points, configs := evMeterBudgetFixture(t, 8280, []float64{26, 20, 20}, 10350, "3p") + got, known := EVFuseBudget(store, state, points, configs, 17250) + if !known || math.Abs(got-8970) > .01 { + t.Fatalf("cap %.1f W, want 10,350 - 2 A x 690 = 8,970 W", got) + } +} + +// An overage larger than the offer less one step stops the car; a smaller +// one never does. +func TestEVHardLimitStopsOnlyWhenOverageExceedsOfferLessMinimum(t *testing.T) { + store, state, points, configs := evMeterBudgetFixture(t, 30000, nil, 10350, "3p") + if got, _ := EVFuseBudget(store, state, points, configs, 17250); got != 0 { + t.Fatalf("13 kW overage left %.1f W", got) + } + store, state, points, configs = evMeterBudgetFixture(t, 17250+500, nil, 10350, "3p") + if got, _ := EVFuseBudget(store, state, points, configs, 17250); math.Abs(got-9850) > .01 { + t.Fatalf("500 W overage gave %.1f W, want 9,850 W", got) + } +} + +// An idle car starts only with room for its minimum and a margin step, and +// a single-phase or auto car may land its whole offer on the worst phase. +func TestEVHardLimitIdleStartNeedsRoomOnDeclaredPhases(t *testing.T) { for _, tc := range []struct { name, phase string - wantW float64 + worstA float64 + want float64 }{ - {"legacy_default", "", 13110}, - {"explicit_3p", "3p", 13110}, - {"explicit_1p", "1p", 4370}, - {"explicit_auto", "auto", 4370}, - {"unknown", "unknown", 4370}, + {"3p_room", "3p", 5, (24 - 5) * 690}, + {"legacy_default", "", 5, (24 - 5) * 690}, + {"1p_room", "1p", 5, (24 - 5) * 230}, + {"auto_room", "auto", 5, (24 - 5) * 230}, + {"3p_below_minimum", "3p", 21.5, 0}, // 1,725 W < 1,380 + 690 W } { t.Run(tc.name, func(t *testing.T) { - store := telemetry.NewStore() - store.Update("meter", telemetry.DerMeter, 3450, nil, []byte(`{"l1_a":5,"l2_a":5,"l3_a":5}`)) - state := NewState(0, 0, "meter") - state.SiteFuseAmps, state.SiteFuseVoltage, state.SiteFusePhases = 25, 230, 3 - cfg := loadpoint.Config{ID: "car", DriverName: "charger", PhaseMode: tc.phase} - got, known := EVFuseBudget(store, state, nil, []loadpoint.Config{cfg}, 17250) - if !known || math.Abs(got-tc.wantW) > .01 { - t.Fatalf("declared phase%q cap=%.0fW known=%t, want%.0fW", tc.phase, got, known, tc.wantW) + store, state, points, configs := evMeterBudgetFixture(t, tc.worstA*690, []float64{tc.worstA, tc.worstA, tc.worstA}, 0, tc.phase) + got, known := EVFuseBudget(store, state, points, configs, 17250) + if !known || math.Abs(got-tc.want) > .01 { + t.Fatalf("cap %.1f W, want %.1f W", got, tc.want) } }) } } -func TestLiveFuseBudgetPhysicalBalance(t *testing.T) { - cases := []struct { - name string - previousGrid, grid, oldEV, ev float64 - managed, duplicate, stale bool - phase []float64 - maxCap float64 - }{ - {name: "fresh actual overload after low value", previousGrid: 1000, grid: 20000, maxCap: 0}, - {name: "existing managed EV stays in replacement pool", previousGrid: 10000, grid: 10000, oldEV: 8000, ev: 8000, managed: true, maxCap: 15250}, - {name: "EV stopped after high filtered power", previousGrid: 2000, grid: 2000, oldEV: 8000, ev: 0, managed: true, maxCap: 15250}, - {name: "stale managed EV cannot create headroom", previousGrid: 2000, grid: 2000, oldEV: 8000, ev: 8000, managed: true, stale: true, maxCap: 15250}, - {name: "new EV rise after older site sample", previousGrid: 1000, grid: 1000, oldEV: 0, ev: 11000, managed: true, maxCap: 16250}, - {name: "unmanaged EV remains base demand", previousGrid: 8000, grid: 8000, oldEV: 6000, ev: 6000, maxCap: 9250}, - {name: "duplicate identity adds only once", previousGrid: 10000, grid: 10000, oldEV: 8000, ev: 8000, managed: true, duplicate: true, maxCap: 15250}, - {name: "PV export can supply measured net headroom", previousGrid: -5000, grid: -5000, maxCap: 22250}, - {name: "measured battery relief counted once", previousGrid: -3000, grid: -3000, maxCap: 20250}, - {name: "mixed phase directions under net export", previousGrid: -2300, grid: -2300, phase: []float64{10, 10, 10}, maxCap: 10350}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - store := telemetry.NewStore() - var data json.RawMessage - if tc.phase != nil { - data, _ = json.Marshal(map[string]float64{"l1_a": tc.phase[0], "l2_a": tc.phase[1], "l3_a": tc.phase[2]}) - } - store.Update("meter", telemetry.DerMeter, tc.previousGrid, nil, data) - store.Update("meter", telemetry.DerMeter, tc.grid, nil, data) - store.DriverHealthMut("meter").RecordSuccess() - store.Update("car", telemetry.DerEV, tc.oldEV, nil, nil) - store.Update("car", telemetry.DerEV, tc.ev, nil, nil) - store.DriverHealthMut("car").RecordSuccess() - if tc.stale { - store.Get("car", telemetry.DerEV).UpdatedAt = time.Now().Add(-time.Hour) - } - st := NewState(0, 0, "meter") - st.SiteFuseAmps = 25 - st.SiteFuseVoltage = 230 - st.SiteFusePhases = 3 - var points []loadpoint.State - if tc.managed { - points = append(points, loadpoint.State{ID: "a", DriverName: "car", PluggedIn: true}) - } - if tc.duplicate { - points = append(points, loadpoint.State{ID: "b", DriverName: "car", PluggedIn: true}) - } - got, known := EVFuseBudget(store, st, points, []loadpoint.Config{{ID: "a", DriverName: "car", PhaseMode: "3p"}}, 17250) - if !known || got < 0 || got > tc.maxCap+0.001 { - t.Fatalf("got cap=%v known=%v max=%v; rawgrid=%v smoothgrid=%v rawev=%v smoothev=%v", got, known, tc.maxCap, store.Get("meter", telemetry.DerMeter).RawW, store.Get("meter", telemetry.DerMeter).SmoothedW, store.Get("car", telemetry.DerEV).RawW, store.Get("car", telemetry.DerEV).SmoothedW) - } - if tc.maxCap > 0 && !tc.stale && tc.name != "mixed phase directions under net export" && got == 0 { - t.Fatalf("usable physical budget abandoned: max=%v", tc.maxCap) +// Missing or unreadable EV telemetry is not a reason to cut, and before the +// first command a fresh reading bounds what the meter contains. +func TestEVHardLimitIgnoresEVTelemetryForCuts(t *testing.T) { + for name, samples := range map[string][]string{ + "lone_zero": {`{"control_power_available":true}`, `{"control_power_available":true}`}, + "unavailable": {`{"control_power_available":false}`}, + } { + store, state, points, configs := evMeterBudgetFixture(t, 12323, []float64{20.45, 17.05, 15.87}, 10350, "3p") + for i, data := range samples { + w := 0.0 + if i < len(samples)-1 { + w = 10242 } - }) + store.Update("charger", telemetry.DerEV, w, nil, []byte(data)) + store.RecordDriverSuccess("charger") + } + if got, _ := EVFuseBudget(store, state, points, configs, 17250); got < 10350 { + t.Fatalf("%s EV power cut the cap to %.1f W", name, got) + } + } + store, state, points, configs := evMeterBudgetFixture(t, 12323, []float64{20.45, 17.05, 15.87}, 0, "3p") + points[0].CommandedKnown = false + store.Update("charger", telemetry.DerEV, 10242, nil, nil) + store.RecordDriverSuccess("charger") + if got, _ := EVFuseBudget(store, state, points, configs, 17250); got < 10242 { + t.Fatalf("restart with the car charging cut it to %.1f W", got) } } diff --git a/go/internal/control/ev_pending_battery.go b/go/internal/control/ev_pending_battery.go index 0000ec582..8887f65a7 100644 --- a/go/internal/control/ev_pending_battery.go +++ b/go/internal/control/ev_pending_battery.go @@ -65,32 +65,18 @@ func pendingBatterySeriesForFuse(store *telemetry.Store, state *State, windows m return series } -// Reserve orders against the same site observation as the EV replacement -// credit. Signed subtraction also reserves a loss of existing discharge. -func pendingBatteryIncreaseAt(series []pendingBatterySeries, siteAt time.Time) (reserve float64, coherent bool) { - coherent = true - for _, battery := range series { - baseline, known := battery.fallbackW, false - if battery.fresh { - if value, ok := bracketedBatteryPower(battery.points, siteAt, battery.current.PowerW); ok { - baseline, known = value, true - } - } - coherent = coherent && known - if !finiteFuseValue(battery.upperW) || !finiteFuseValue(baseline) { - return math.Inf(1), false - } - reserve += math.Max(0, battery.upperW-baseline) - } - return reserve, coherent -} - +// A first battery sample after the site read cannot show how much earlier +// discharge the meter contains; it keeps the full fallback. func pendingBatteryIncreaseAtLatestSite(series []pendingBatterySeries, siteAt time.Time) float64 { var reserve float64 for _, battery := range series { baseline := battery.fallbackW - if battery.fresh { - baseline = batteryPowerAtLatestSite(battery.points, siteAt, battery.current, battery.fallbackW) + if battery.fresh && !battery.current.Seen().After(siteAt) { + baseline = battery.current.PowerW + } else if battery.fresh { + if value, ok := bracketedBatteryPower(battery.points, siteAt, battery.current.PowerW); ok { + baseline = value + } } if !finiteFuseValue(battery.upperW) || !finiteFuseValue(baseline) { return math.Inf(1) diff --git a/go/internal/control/ev_pending_battery_test.go b/go/internal/control/ev_pending_battery_test.go index 46d7acd6a..91f44581d 100644 --- a/go/internal/control/ev_pending_battery_test.go +++ b/go/internal/control/ev_pending_battery_test.go @@ -111,7 +111,8 @@ func TestEVPendingBatteryMissingPowerCannotInventRoom(t *testing.T) { pendingEVOrder(store, 8000, now.Add(-4*time.Second), "accepted") data := []byte(`{"control_power_available":false}`) store.Update("battery", telemetry.DerBattery, 0, nil, data) - if got := pendingEVBudget(store, state); got != 0 { + // The meter alone cuts; the car keeps its 3 kW but cannot rise. + if got := pendingEVBudget(store, state); got > 3000.01 { t.Fatalf("missing battery power earned %.1fW EV room", got) } } @@ -139,17 +140,19 @@ func TestEVPendingBatteryFirstSampleWaitsForSiteCorrespondence(t *testing.T) { store.RecordDriverSuccess(driver) } emit("battery", telemetry.DerBattery, now.Add(-3*time.Second), 0) - if got := pendingEVBudget(store, state); got > 1560.01 { + if got := pendingEVBudget(store, state); got > 3000.01 { t.Fatalf("unknown earlier discharge financed %.1fW EV room", got) } - // The next bracket proves the new balance and releases that startup bound. + // The next bracket proves the new balance; the cap then rises one step + // once the last change has had time to reach the meter. amps := 13000.0 / 690 data, _ := json.Marshal(map[string]any{"control_power_observed_at": now.Add(-2 * time.Second).Format(time.RFC3339Nano), "l1_a": amps, "l2_a": amps, "l3_a": amps}) store.Update("meter", telemetry.DerMeter, 13000, nil, data) emit("battery", telemetry.DerBattery, now.Add(-time.Second), 0) emit("car", telemetry.DerEV, now.Add(-time.Second), 3000) - if got := pendingEVBudget(store, state); math.Abs(got-6560) > .01 { + state.evLimit.changedAt = now.Add(-evSettle) + if got := pendingEVBudget(store, state); math.Abs(got-3690) > .01 { t.Fatalf("matching fresh readings did not restore room: %.1fW", got) } } @@ -165,8 +168,10 @@ func TestEVPendingBatteryOrderArrivalKeepsPhysicalImportBounded(t *testing.T) { } store, state, now := pendingEVFixture(t, house, actual, 3000, actual) pendingEVOrder(store, target, now.Add(-4*time.Second), "accepted") + // The car already draws 3 kW. Only the meter may cut it; a pending + // order must not fund a rise. ev := pendingEVBudget(store, state) - if grid := house + math.Max(actual, target) + ev; grid > 16560.01 { + if grid := house + math.Max(actual, target) + ev; ev > 3000.01 && grid > 16560.01 { t.Fatalf("case %d: late signed order gives %.1fW import; house %.1f actual %.1f target %.1f EV %.1f", i, grid, house, actual, target, ev) } } diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index fa41d1308..2f3e9fb93 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -48,17 +48,16 @@ type Controller struct { fuseEVMax func() (float64, bool) // perPhaseMeterAmps returns the live site-meter per-phase currents (L1, - // L2, L3). Wired from telemetry; nil disables the per-phase fuse clamp. + // L2, L3). Wired from telemetry; nil disables the phase increase gate. perPhaseMeterAmps func() (l1, l2, l3 float64, ok bool) - // fusePhaseCapA holds the per-loadpoint per-phase EV current cap carried - // between ticks by the reactive per-phase fuse clamp (tickOne goroutine). - fusePhaseCapA map[string]float64 // tickFuseCaps belongs to the serial tick, after one shared budget read. tickFuseCaps map[string]float64 tickFusePhases int tickDirectiveActive bool tickDirectiveOK bool tickDirective Directive + // offerPhase is each loadpoint's last offered phase mode (serial tick). + offerPhase map[string]string // fuseResumeTicks is owned by the serial tick. A paused charger needs // two consecutive dispatch ticks with room for a legal step. fuseResumeTicks map[string]int @@ -579,75 +578,6 @@ func (c *Controller) SetPeakRemainingSurplusW(f func() (float64, bool)) { // fusePhaseMarginA is the per-phase amp headroom held below the breaker. const fusePhaseMarginA = 1.0 -// fusePhaseStepUpA is how fast the per-phase EV cap recovers per tick once a -// phase has headroom — gentle enough not to re-trip, quick enough to ramp back. -const fusePhaseStepUpA = 1.0 - -// nextFusePhaseCapA reactively caps the EV's per-phase offer so the worst -// measured site-meter phase stays at or below (fuseA - marginA). Reductions are -// immediate (the full overage) to protect the breaker; ramp-up is gradual -// (stepA/tick). prevCapA <= 0 means "uninitialised" -> start from the full fuse. -// Pure for testability. -func nextFusePhaseCapA(prevCapA, worstMeterA, fuseA, marginA, stepA float64) float64 { - if fuseA <= 0 { - return prevCapA - } - limit := fuseA - marginA - capA := prevCapA - if capA <= 0 { - capA = fuseA - } - switch { - case worstMeterA > limit: - capA -= worstMeterA - limit - case worstMeterA+stepA <= limit: - capA += stepA - } - if capA < 0 { - capA = 0 - } - if capA > fuseA { - capA = fuseA - } - return capA -} - -// applyPerPhaseFuseClamp caps one phase. The site-total fuse guard -// misses a phase that is already near the breaker (L1 at 18 A on a 16 A fuse). -func (c *Controller) applyPerPhaseFuseClamp(lpCfg Config, cmd map[string]any) { - if c == nil || c.perPhaseMeterAmps == nil { - return - } - if c.tickFuseCaps != nil { - // The shared budget already accounts for the worst measured phase. - // Re-integrating its overage here would trim the same reading again - // on each tick, eventually turning a safe positive offer into 0 W. - return - } - fuseA := c.siteFuse().MaxAmps - if fuseA <= 0 { - return - } - l1, l2, l3, ok := c.perPhaseMeterAmps() - if !ok { - return - } - worst := math.Max(l1, math.Max(l2, l3)) - if c.fusePhaseCapA == nil { - c.fusePhaseCapA = map[string]float64{} - } - capA := nextFusePhaseCapA(c.fusePhaseCapA[lpCfg.ID], worst, fuseA, fusePhaseMarginA, fusePhaseStepUpA) - c.fusePhaseCapA[lpCfg.ID] = capA - if capA >= fuseA { - return - } - if existing, has := cmd["max_amps_per_phase"].(float64); !has || capA < existing { - cmd["max_amps_per_phase"] = capA - slog.Info("loadpoint per-phase fuse clamp", - "lp", lpCfg.ID, "worst_phase_a", worst, "ev_cap_a", capA, "fuse_a", fuseA) - } -} - // applyFuseClampAndCooldown applies the frozen hard cap. A short recovery // wait is part of that cap; an unknown startup budget never starts a timer. func (c *Controller) applyFuseClampAndCooldown(now time.Time, lpCfg Config, wantW float64) (float64, string) { @@ -1620,7 +1550,6 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s cmd["power_w"], cmdReason = float64(0), "fuse_limit" } } - c.applyPerPhaseFuseClamp(lpCfg, cmd) if applyCurrentCeiling(cmd) { cmdReason = "fuse_limit" } @@ -1633,6 +1562,14 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s if w, ok := cmd["power_w"].(float64); ok { offerW, haveOffer = w, true c.manager.setCommandedForManual(lpCfg.ID, w, cmdReason, manualCommandUpdatedAt) + phase, _ := cmd["phase_mode"].(string) + if phase == "" { + phase = "3p" // The driver's locked default, as in phaseModeForFuse. + } + if c.offerPhase == nil { + c.offerPhase = map[string]string{} + } + c.offerPhase[lpCfg.ID] = phase } payload, err := json.Marshal(cmd) if err != nil { diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index 5682ad37a..4681a7ac6 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -10,7 +10,7 @@ type fuseRequest struct { cfg Config deadline time.Time wantW float64 - liveW float64 + baseW float64 goal bool } @@ -34,16 +34,21 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample } d, _ := c.currentDirective(now) var requests []fuseRequest - var liveTotal float64 + var baseTotal float64 for _, cfg := range c.manager.Configs() { sample := samples[cfg.DriverName] st, _ := c.manager.State(cfg.ID) st.PluggedIn = sample.Connected && !sample.ConnectionUnknown request := fuseRequest{cfg: c.fuseConfig(now, cfg), deadline: st.TargetTime} if sample.Connected && finite(sample.PowerW) { - request.liveW = math.Max(0, sample.PowerW) - liveTotal += request.liveW + request.baseW = math.Max(0, sample.PowerW) } + // Keeping the current offer on the same phases is not an increase, + // even while a late or lone reading says the car draws less. + if st.PluggedIn && st.CommandedKnown && finite(st.CommandedW) && c.offerPhase[cfg.ID] == c.phaseModeForFuse(now, request.cfg) { + request.baseW = math.Max(request.baseW, st.CommandedW) + } + baseTotal += request.baseW if st.PluggedIn && c.driverCanDispatch(cfg.DriverName) { if hold, held := c.GetManualHold(cfg.ID, now); held { request.wantW = clampManualPower(cfg, hold, c.siteFuse()) @@ -79,23 +84,19 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample return a.cfg.ID < b.cfg.ID }) c.tickFuseCaps = make(map[string]float64, len(requests)) - remaining, increase := total, math.Max(0, total-liveTotal) - if phaseIncrease, known := c.sharedPhaseIncreaseW(now, requests, liveTotal); known { - if phaseIncrease < 0 { - total = math.Min(total, math.Max(0, liveTotal+phaseIncrease)) - remaining = total - } + remaining, increase := total, math.Max(0, total-baseTotal) + if phaseIncrease, known := c.sharedPhaseIncreaseW(now, requests); known { increase = math.Max(0, math.Min(increase, phaseIncrease)) } for _, request := range requests { target := floorChargeW(math.Min(request.wantW, remaining), request.cfg.MinChargeW, request.cfg.MaxChargeW, request.cfg.AllowedStepsW) remaining -= target // Do not spend another car's curtailment before its meter confirms it. - want := math.Min(target, request.liveW+increase) + want := math.Min(target, request.baseW+increase) cap := floorChargeW(want, request.cfg.MinChargeW, request.cfg.MaxChargeW, request.cfg.AllowedStepsW) cap = c.recoverFuseCap(request.cfg.ID, request.wantW, cap) c.tickFuseCaps[request.cfg.ID] = cap - increase -= math.Max(0, cap-request.liveW) + increase -= math.Max(0, cap-request.baseW) } } @@ -129,9 +130,9 @@ func (c *Controller) recoverFuseCap(id string, wantW, capW float64) float64 { return capW } -// A new single-phase offer can land on the busiest phase. Do not spend a -// different car's still-measured draw to move power between phases. -func (c *Controller) sharedPhaseIncreaseW(now time.Time, requests []fuseRequest, liveTotal float64) (float64, bool) { +// A new single-phase offer can land on the busiest phase, so an increase +// needs measured room there. Cutting an overage is the shared budget's job. +func (c *Controller) sharedPhaseIncreaseW(now time.Time, requests []fuseRequest) (float64, bool) { if c.perPhaseMeterAmps == nil { return 0, false } @@ -141,24 +142,17 @@ func (c *Controller) sharedPhaseIncreaseW(now time.Time, requests []fuseRequest, return 0, false } if !finite(l1) || !finite(l2) || !finite(l3) { - return -liveTotal, true + return 0, true } phaseCount := site.Phases() - unknownPhase := false for _, request := range requests { - mode := c.phaseModeForFuse(now, request.cfg) - if mode != "3p" { + if c.phaseModeForFuse(now, request.cfg) != "3p" { phaseCount = 1 - unknownPhase = true } } c.tickFusePhases = phaseCount worst := math.Max(math.Abs(l1), math.Max(math.Abs(l2), math.Abs(l3))) - deltaA := site.MaxAmps - fusePhaseMarginA - worst - if deltaA < 0 && unknownPhase { - return -liveTotal, true - } - return deltaA * site.Voltage * float64(phaseCount), true + return (site.MaxAmps - fusePhaseMarginA - worst) * site.Voltage * float64(phaseCount), true } // Empty is the driver's locked-3p default unless the command branch selects diff --git a/go/internal/loadpoint/controller_fuse_phase_test.go b/go/internal/loadpoint/controller_fuse_phase_test.go index b023b4799..b1950a50b 100644 --- a/go/internal/loadpoint/controller_fuse_phase_test.go +++ b/go/internal/loadpoint/controller_fuse_phase_test.go @@ -147,3 +147,27 @@ func TestManualPhaseChangeAfterSharedBudgetWaitsForNextTick(t *testing.T) { }) } } + +// #1674, 05:49: the car drew 10,242 W at 15 A when one 0 W reading +// arrived. Keeping the 10,350 W offer is not an increase; the phase gate +// must not treat it as one and stop the charge at 2,449.5 W of room. +func TestLoneZeroReadingKeepsOfferThroughPhaseGate(t *testing.T) { + now := time.Date(2026, 10, 17, 5, 49, 0, 0, time.UTC) + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 10350, PhaseMode: "3p", + AllowedStepsW: []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350}} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true, PowerW: 10242}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 20.45, 17.05, 15.87, true }) + c.SetFuseEVMax(func() (float64, bool) { return 16905, true }) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) + c.TickWithDispatch(context.Background(), now, true) + for i, w := range []float64{0, 10242} { + samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true, PowerW: w} + c.TickWithDispatch(context.Background(), now.Add(time.Duration(5*(i+1))*time.Second), true) + if command, known := lastSetCurrent(sender.calls); !known || command.power != 10350 { + t.Fatalf("reading %.0f W changed the offer: %+v", w, command) + } + } +} diff --git a/go/internal/loadpoint/controller_fusephase_test.go b/go/internal/loadpoint/controller_fusephase_test.go index 43d8f4d3b..b20e34e7d 100644 --- a/go/internal/loadpoint/controller_fusephase_test.go +++ b/go/internal/loadpoint/controller_fusephase_test.go @@ -1,60 +1,21 @@ package loadpoint -import "testing" +import ( + "testing" + "time" +) -func TestNextFusePhaseCapA(t *testing.T) { - const fuse, margin, step = 16.0, 1.0, 1.0 // limit = 15 - cases := []struct { - name string - prev, worst, want float64 - }{ - {"over-limit drops by overage", 16, 18, 13}, // 16-(18-15) - {"deadband holds", 13, 15, 13}, - {"headroom ramps up", 13, 13, 14}, // 13+1=14<=15 - {"never exceeds fuse", 16, 10, 16}, - {"severe house overage floors at 0", 2, 20, 0}, - {"uninit starts at fuse", 0, 10, 16}, // 16, 10+1<=15 -> +1 -> 17 -> clamp 16 - } - for _, tc := range cases { - if got := nextFusePhaseCapA(tc.prev, tc.worst, fuse, margin, step); got != tc.want { - t.Errorf("%s: nextFusePhaseCapA(prev=%v,worst=%v)=%v want %v", tc.name, tc.prev, tc.worst, got, tc.want) - } - } -} - -// TestSiteFuseHotReloadAppliesToPerPhaseClamp guards the fix where the -// loadpoint's per-phase EV clamp ignored hot-reloaded fuse params (it read -// the startup-only c.site). After SetSiteFuse the clamp must use the new -// fuse: a worst phase that's safe at fuse=16 becomes a clamp at fuse=11. -func TestSiteFuseHotReloadAppliesToPerPhaseClamp(t *testing.T) { +// The phase increase gate must read hot-reloaded fuse params, not the +// startup-only c.site: a worst phase with room at fuse=16 has none at 11. +func TestSiteFuseHotReloadAppliesToPhaseIncreaseGate(t *testing.T) { c := &Controller{} - c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) - if got := c.siteFuse().MaxAmps; got != 16 { - t.Fatalf("siteFuse MaxAmps = %v, want 16", got) - } c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 13, 5, 5, true }) - - // fuse 16 (limit 15): worst 13 A is safe → no clamp. - cmd := map[string]any{"max_amps_per_phase": 16.0} - c.applyPerPhaseFuseClamp(Config{ID: "lp"}, cmd) - if cmd["max_amps_per_phase"].(float64) != 16.0 { - t.Fatalf("fuse 16, worst 13 A: expected no clamp, got %v", cmd["max_amps_per_phase"]) + c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) + if got, known := c.sharedPhaseIncreaseW(time.Now(), nil); !known || got != 1380 { + t.Fatalf("fuse 16, worst 13 A: increase=%v known=%v, want 1380 W", got, known) } - - // Hot-reload the fuse down to 11 (limit 10): worst 13 A is now over. c.SetSiteFuse(SiteFuse{MaxAmps: 11, Voltage: 230, PhaseCnt: 3}) - if got := c.siteFuse().MaxAmps; got != 11 { - t.Fatalf("after hot-reload siteFuse MaxAmps = %v, want 11", got) - } - // The servo ramps the cap down across ticks; within a couple it must - // clamp the EV below the new fuse. - var capped float64 - for i := 0; i < 3; i++ { - cmd = map[string]any{"max_amps_per_phase": 11.0} - c.applyPerPhaseFuseClamp(Config{ID: "lp"}, cmd) - capped = cmd["max_amps_per_phase"].(float64) - } - if capped >= 11.0 { - t.Fatalf("hot-reloaded fuse 11, worst 13 A: expected EV cap < 11, got %v", capped) + if got, known := c.sharedPhaseIncreaseW(time.Now(), nil); !known || got >= 0 { + t.Fatalf("hot-reloaded fuse 11, worst 13 A: increase=%v known=%v, want none", got, known) } } From 3393699692db6382887d53c6cf6a004f55e61131 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 15:18:17 +0200 Subject: [PATCH 2/3] fix(control): the EV guard trusts only the meter and our own commands Fredrik's rule (17 Oct): never trust slow current values from an Easee or any other device. The EV hard limit, the split between cars, the EV/battery split and the #1536 allocator now read only the site meter and FTW's own recent commands, kept for the 20 s a command takes to reach the meter. A rise needs a fresh reading with room for two steps and waits until the meter shows the last change (import rose with it, or 20 s passed). Every increase leaves a step free. An idle pool restarts only after 35 s of measured room; a meter gap starts the count again, and a silent meter's repeated reading never acts twice. Battery commands, not readings, set the battery's baseline and pending range. Removes the bracketed EV and battery credit and ev_pending_battery.go. Overlaps srcfl/ftw#1675. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/ev-meter-hard-limit.md | 2 +- go/internal/control/dispatch.go | 31 +- go/internal/control/ev_battery_headroom.go | 316 ++-------- .../control/ev_battery_headroom_test.go | 561 +++++------------- .../control/ev_battery_sequence_test.go | 87 ++- go/internal/control/ev_fuse_budget.go | 218 ++++--- go/internal/control/ev_fuse_budget_test.go | 314 +++++++--- go/internal/control/ev_pending_battery.go | 87 --- .../control/ev_pending_battery_test.go | 310 ---------- .../testdata/golden/seeded_ev_boost.json | 8 +- go/internal/loadpoint/controller.go | 18 +- .../loadpoint/controller_fuse_budget.go | 35 +- .../loadpoint/controller_fuse_phase_test.go | 6 +- .../controller_fuse_recovery_test.go | 11 +- 14 files changed, 675 insertions(+), 1329 deletions(-) delete mode 100644 go/internal/control/ev_pending_battery.go delete mode 100644 go/internal/control/ev_pending_battery_test.go diff --git a/.changeset/ev-meter-hard-limit.md b/.changeset/ev-meter-hard-limit.md index cf1c8f29d..8bebd4835 100644 --- a/.changeset/ev-meter-hard-limit.md +++ b/.changeset/ev-meter-hard-limit.md @@ -2,4 +2,4 @@ "ftw": patch --- -The EV's hard limit now follows the site meter. A charge is cut by the overage the meter measures on the worst phase or in total import, holds while the site is inside its limits, and rises one step at a time once the car has taken the last one. A late or lone 0 W reading from the charger can no longer stop a charge, and an import ceiling no longer makes the charger start and stop every half minute. +The EV's hard limit now reads only the site meter and FTW's own commands, never the charger's power readings. A charge is cut by the overage the meter measures on the worst phase or in total import, holds while the site is inside its limits, and rises one step at a time once the meter shows the last one. After a stop it restarts only when the meter has shown room for 35 seconds. A late, missing or lone 0 W reading from the charger can no longer stop a charge, and an import ceiling no longer makes the charger start and stop every half minute. diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 8e517837f..a367b55d2 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -373,6 +373,10 @@ type State struct { evFuseDemandsSet bool // evLimit is the EVs' meter-driven hard cap, kept between site readings. evLimit evMeterLimit + // evOffers and batteryCommands are our own recent commands, the only + // device input the EV hard limit and the EV/battery split use. + evOffers commandLog + batteryCommands map[string]commandLog // PI controller (outer, site-level) PI *PIController @@ -2030,7 +2034,8 @@ func ComputeDispatch( state.FuseEVMaxW = 0 state.FuseSaturated = false plannedEVW := scheduledEVPowerW(state) - if fuseMaxW > 0 && math.Max(state.EVChargingW, plannedEVW) > 0 { + 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 @@ -2041,13 +2046,13 @@ func ComputeDispatch( targetTotal := currentTotal + totalCorrection B := math.Max(0, targetTotal) Bn := math.Min(0, targetTotal) - E := math.Max(state.EVChargingW, plannedEVW) - H := rawGridW - currentTotal - state.EVChargingW + E := math.Max(evInMeterW, plannedEVW) + H := rawGridW - currentTotal - evInMeterW projectedGrid := H + targetTotal + E if projectedGrid > ceilingW && (B+E) > 0 { if plannedEVW > 0 { // A timed EV goal takes its share before optional battery charge. - available := evHeadroomW(ceilingW, rawGridW-currentTotal+Bn, state.EVChargingW) + available := evHeadroomW(ceilingW, rawGridW-currentTotal+Bn, evInMeterW) state.FuseEVMaxW = math.Min(E, available) newBattery := Bn + math.Min(B, math.Max(0, available-state.FuseEVMaxW)) totalCorrection = newBattery - currentTotal @@ -2437,6 +2442,13 @@ func recordDispatchTargets(targets []DispatchTarget, state *State, updatePrevTar if recordDispatch { now := state.now() state.LastDispatch = &now + if state.batteryCommands == nil { + state.batteryCommands = map[string]commandLog{} + } + for _, t := range targets { + state.batteryCommands[t.Driver] = state.batteryCommands[t.Driver].add(now, t.TargetW) + } + state.evOffers = state.evOffers.add(now, math.Max(0, state.EVCommandedW)) } if updatePrevTargets { for _, t := range targets { @@ -4584,3 +4596,14 @@ func forceFuseDischargeWithSnapshot( } return out } + +// evInMeterW is EV load the meter is sure to contain: the operator's declared +// load plus the lowest managed offer of the last evSettle. This tick's new +// offer is not in it yet. Charger readings take no part in the fuse share. +func (s *State) evInMeterW() float64 { + low, _, _, ok := s.evOffers.span(s.now()) + if !ok { + low = 0 + } + return s.ManualEVChargingW + math.Max(0, low) +} diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 7bcccd4d7..a087001ed 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -2,161 +2,75 @@ package control import ( "math" - "sort" - "time" "github.com/srcfl/ftw/go/internal/telemetry" ) -type evBatteryPowerSeries struct { - reading *telemetry.DerReading - current telemetry.ControlObservation - latest float64 - points []telemetry.ControlObservation -} - -// clampEVBatteryHeadroom reserves EV offers that the site sample does not -// yet contain. A newer charger reading must not be subtracted from an older -// grid sample to fund optional battery charge. Commands that reduce another -// battery's charge or increase its discharge do not create measured room. +// clampEVBatteryHeadroom keeps optional battery charge out of room an EV +// still needs. It reads only the site meter and our own commands: within +// evSettle the meter may show any command sent, so a device's lowest recent +// command is its baseline and the rest of its range is still pending. No +// charger or battery reading takes part. func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, state *State, fuseMaxW float64) []DispatchTarget { if store == nil || state == nil || len(targets) == 0 || fuseMaxW <= 0 { return targets } now := state.now() - windows := store.ControlWindows(now.Add(-telemetry.ControlWindowDuration), now) - need := make(map[string]float64, len(state.evFuseDemands)) - phaseMode := make(map[string]string, len(state.evFuseDemands)) - for _, demand := range state.evFuseDemands { - if demand.powerW > 0 && finiteFuseValue(demand.powerW) { - need[demand.driverName] = math.Max(need[demand.driverName], demand.powerW) - phaseMode[demand.driverName] = demand.phaseMode - } + // This tick's new offer is not in the meter yet; the lowest logged one is. + offeredW, _, _, ok := state.evOffers.span(now) + if !ok { + offeredW = 0 } - series := make(map[string]evBatteryPowerSeries) - // Unmanaged live chargers stay separate. Their measured power cannot - // finance a different car's new offer, but an unobserved start also - // needs room before an optional battery increase. - for _, kind := range []telemetry.DerType{telemetry.DerEV, telemetry.DerV2X} { - for _, reading := range store.ReadingsByType(kind) { - health := store.DriverHealth(reading.Driver) - observation, ok := qualifiedFuseObservation(reading, now) - if !ok || health == nil || !health.TelemetryLive() || observation.PowerW <= 0 { - continue - } - need[reading.Driver] = math.Max(need[reading.Driver], observation.PowerW) - points := windows[reading.Driver+":"+kind.String()].Points - series[reading.Driver] = evBatteryPowerSeries{reading: reading, current: observation, latest: observation.PowerW, points: append(points, observation)} + var demandW float64 + phases := 3.0 // No phase placement promise for 1p or auto chargers. + for _, demand := range state.evFuseDemands { + demandW += demand.powerW + if demand.phaseMode != "3p" { + phases = 1 } } - if len(need) == 0 { + if len(state.evFuseDemands) == 0 { return targets } + needW := math.Max(0, demandW-offeredW) meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - site, known := qualifiedFuseObservation(meter, now) - if !known || !finiteFuseValue(fuseMaxW) { + if meter == nil || !finiteFuseValue(meter.RawW) || !finiteFuseValue(fuseMaxW) { return floorPositiveEVBatteryTargets(targets) } - baselines := make(map[string]float64, len(targets)) - latestBaselines := make(map[string]float64, len(targets)) - batteryReadings := make(map[string]*telemetry.DerReading, len(targets)) - byDriver := make(map[string]DispatchTarget, len(targets)) drivers := make(map[string]bool, len(targets)) for driver := range state.DriverLimits { drivers[driver] = true } - for _, reading := range store.ReadingsByType(telemetry.DerBattery) { - drivers[reading.Driver] = true + for driver := range state.batteryCommands { + drivers[driver] = true } + targeted := make(map[string]bool, len(targets)) for _, target := range targets { - if _, duplicate := byDriver[target.Driver]; duplicate { + if targeted[target.Driver] { return floorPositiveEVBatteryTargets(targets) } - byDriver[target.Driver] = target - drivers[target.Driver] = true + targeted[target.Driver], drivers[target.Driver] = true, true } + baselines := make(map[string]float64, len(drivers)) uppers := make(map[string]float64, len(drivers)) - coherentBatteries := make(map[string]bool, len(targets)) - for driver := range drivers { - reading := store.Get(driver, telemetry.DerBattery) - if reading != nil { - batteryReadings[driver] = reading - } - if current, fresh := qualifiedFuseObservation(reading, now); fresh { - uppers[driver] = current.PowerW - coherentBatteries[driver] = true - } - if _, targeted := byDriver[driver]; targeted { - coherentBatteries[driver] = true - } - if upper, known := batteryOrderUpperW(store, state, driver); known { - if !finiteFuseValue(upper) { - return floorPositiveEVBatteryTargets(targets) - } - if observed, known := uppers[driver]; known { - upper = math.Max(upper, observed) - } - uppers[driver] = upper - coherentBatteries[driver] = true - } - } - at, gridW := coherentEVBatterySite(store, windows, state, site, meter.RawW, series, coherentBatteries, now) - var evIncreaseW, latestEVIncreaseW, latestEVIncreaseA float64 - for driver, watts := range need { - var credit, latestCredit float64 - if power, ok := series[driver]; ok { - if baseline, ok := bracketedEVPower(power.points, at, power.latest); ok { - credit = baseline - } - latestCredit = evPowerAtLatestSite(power.points, site.Seen(), power.current) - } - evIncreaseW += math.Max(0, watts-credit) - latestIncrease := math.Max(0, watts-latestCredit) - latestEVIncreaseW += latestIncrease - phases := 1 // No phase placement promise for 1p or auto chargers. - if phaseMode[driver] == "3p" { - phases = 3 - } - if state.SiteFuseVoltage > 0 { - latestEVIncreaseA += latestIncrease / (state.SiteFuseVoltage * float64(phases)) - } - } for driver := range drivers { - baseline, reading, _ := evBatteryBaseline(store, windows, state, driver, at, now) - baselines[driver] = baseline - if reading != nil { - batteryReadings[driver] = reading - } - latestBaselines[driver] = -state.DriverLimits[driver].dischargeCap() - if current, fresh := qualifiedFuseObservation(reading, now); fresh { - if upper, known := uppers[driver]; !known || current.PowerW > upper { - uppers[driver] = current.PowerW - } - points := append(windows[driver+":"+telemetry.DerBattery.String()].Points, current) - latestBaselines[driver] = batteryPowerAtLatestSite(points, site.Seen(), current, latestBaselines[driver]) + if low, high, _, sent := state.batteryCommands[driver].span(now); sent { + baselines[driver], uppers[driver] = low, high + } else { + // Without a recent command it may be discharging and stop. + baselines[driver] = -state.DriverLimits[driver].dischargeCap() } } - - // Keep the common measured balance separate from the newest meter. - // Combining a newer total with an older low battery baseline would - // reserve an already observed increase twice and cause oscillation. - spare := state.effectiveImportCeilingW(fuseMaxW) - gridW - evIncreaseW - out := capBatteryOffersToHeadroom(targets, baselines, uppers, spare) - latestSpare := state.effectiveImportCeilingW(fuseMaxW) - meter.RawW - latestEVIncreaseW + spare := state.effectiveImportCeilingW(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. - if meter.RawW >= -state.effectiveExportCeilingW(fuseMaxW) { - if phaseSpare, known := evBatteryPhaseSpareW(meter, state, latestEVIncreaseA); known { - latestSpare = math.Min(latestSpare, phaseSpare) - } - } - latestOut := capBatteryOffersToHeadroom(targets, latestBaselines, uppers, latestSpare) - for i := range out { - if latestOut[i].TargetW < out[i].TargetW { - out[i].TargetW, out[i].Clamped = latestOut[i].TargetW, true + if meter.RawW >= -state.effectiveExportCeilingW(fuseMaxW) && state.SiteFuseVoltage > 0 { + if phaseSpare, known := evBatteryPhaseSpareW(meter, state, needW/(state.SiteFuseVoltage*phases)); known { + spare = math.Min(spare, phaseSpare) } } + out := capBatteryOffersToHeadroom(targets, baselines, uppers, spare) // Charge needed to bring measured export inside its hard ceiling is // not optional. Preserve that part of the already validated offer; // this stage never invents or increases an original battery command. @@ -174,25 +88,11 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st } } } - // A racing poll invalidates the captured balance; retry next tick. - if current := store.Get(state.SiteMeterDriver, telemetry.DerMeter); current == nil || !current.UpdatedAt.Equal(meter.UpdatedAt) { - return floorPositiveEVBatteryTargets(targets) - } - for driver, reading := range batteryReadings { - if current := store.Get(driver, telemetry.DerBattery); current == nil || !current.UpdatedAt.Equal(reading.UpdatedAt) { - return floorPositiveEVBatteryTargets(targets) - } - } - for _, power := range series { - if current := store.Get(power.reading.Driver, power.reading.DerType); current == nil || !current.UpdatedAt.Equal(power.reading.UpdatedAt) { - return floorPositiveEVBatteryTargets(targets) - } - } return out } -// Both source-time bounds share this allocation. A reduction at one battery -// never funds an increase at another, and a lost discharge also needs room. +// A reduction at one battery never funds an increase at another, and a lost +// discharge also needs room. func capBatteryOffersToHeadroom(targets []DispatchTarget, baselines, uppers map[string]float64, spare float64) []DispatchTarget { if !finiteFuseValue(spare) { return floorPositiveEVBatteryTargets(targets) @@ -281,150 +181,6 @@ func capBatteryOffersToHeadroom(targets []DispatchTarget, baselines, uppers map[ return out } -// A managed EV earns replacement credit only across a site observation that -// its raw power brackets. A later start or an earlier stop cannot invent -// spare capacity for an optional battery increase. -func bracketedEVPower(points []telemetry.ControlObservation, at time.Time, latest float64) (float64, bool) { - var before, after telemetry.ControlObservation - hasBefore, hasAfter := false, false - for _, p := range points { - if !finiteFuseValue(p.PowerW) || p.PowerW < 0 { - continue - } - if !p.At.After(at) && (!hasBefore || p.At.After(before.At)) { - before, hasBefore = p, true - } - if !p.Seen().Before(at) && (!hasAfter || p.At.Before(after.At)) { - after, hasAfter = p, true - } - } - if !hasBefore || !hasAfter { - return 0, false - } - baseline := math.Max(0, math.Min(latest, math.Min(before.PowerW, after.PowerW))) - // Another slow device may force an older common site observation. A - // later observed stop still removes its replacement credit, even if - // the charger has since started again. - for _, point := range points { - if !point.Seen().Before(at) && finiteFuseValue(point.PowerW) && point.PowerW >= 0 { - baseline = math.Min(baseline, point.PowerW) - } - } - return baseline, true -} - -// A source sample before this site sample may hold its current level. A -// later start needs bracketed replacement credit before it can fund change. -func evPowerAtLatestSite(points []telemetry.ControlObservation, at time.Time, current telemetry.ControlObservation) float64 { - if !current.Seen().After(at) && finiteFuseValue(current.PowerW) { - return math.Max(0, current.PowerW) - } - baseline, _ := bracketedEVPower(points, at, current.PowerW) - return baseline -} - -func batteryPowerAtLatestSite(points []telemetry.ControlObservation, at time.Time, current telemetry.ControlObservation, fallback float64) float64 { - if !finiteFuseValue(current.PowerW) { - return fallback - } - if !current.Seen().After(at) { - return current.PowerW - } - if baseline, known := bracketedBatteryPower(points, at, current.PowerW); known { - return baseline - } - baseline := math.Min(0, current.PowerW) - for _, point := range points { - if finiteFuseValue(point.PowerW) { - baseline = math.Min(baseline, point.PowerW) - } - } - return baseline -} - -// Use one common source instant when fixed poll order leaves the latest -// site sample ahead of a battery. The separate latest-site bound handles -// later grid and phase increases without counting DER increases twice. -func coherentEVBatterySite(store *telemetry.Store, windows map[string]telemetry.ControlBaseline, state *State, current telemetry.ControlObservation, gridW float64, evs map[string]evBatteryPowerSeries, batteries map[string]bool, now time.Time) (time.Time, float64) { - current.At = current.Seen() - sites := append(windows[state.SiteMeterDriver+":"+telemetry.DerMeter.String()].Points, current) - sort.SliceStable(sites, func(i, j int) bool { return sites[i].At.After(sites[j].At) }) - for _, site := range sites { - if site.At.Before(now.Add(-telemetry.ControlWindowDuration)) || site.At.After(now) || !finiteFuseValue(site.PowerW) { - continue - } - coherent := true - for _, ev := range evs { - if _, known := bracketedEVPower(ev.points, site.At, ev.latest); !known { - coherent = false - break - } - } - if coherent { - for driver := range batteries { - if _, _, known := evBatteryBaseline(store, windows, state, driver, site.At, now); !known { - coherent = false - break - } - } - } - if coherent { - return site.At, site.PowerW - } - } - return current.At, gridW -} - -func evBatteryBaseline(store *telemetry.Store, windows map[string]telemetry.ControlBaseline, state *State, driver string, at, now time.Time) (float64, *telemetry.DerReading, bool) { - reading := store.Get(driver, telemetry.DerBattery) - observation, known := qualifiedFuseObservation(reading, now) - if !known { - return -state.DriverLimits[driver].dischargeCap(), reading, false - } - points := windows[driver+":"+telemetry.DerBattery.String()].Points - latest := observation.PowerW // Qualified site-side AC, which can differ from raw DC. - if baseline, known := bracketedBatteryPower(append(points, observation), at, latest); known { - return baseline, reading, true - } - // No positive replacement credit without correspondence. Retain any - // observed discharge as a possible import rise, even in a sparse ring. - baseline := math.Min(0, latest) - for _, point := range points { - if finiteFuseValue(point.PowerW) { - baseline = math.Min(baseline, point.PowerW) - } - } - return baseline, reading, false -} - -// Unlike an EV, a battery's baseline may be negative. Retaining the lower -// signed value also reserves a loss of discharge before a positive offer. -func bracketedBatteryPower(points []telemetry.ControlObservation, at time.Time, latest float64) (float64, bool) { - var before, after telemetry.ControlObservation - hasBefore, hasAfter := false, false - for _, point := range points { - if !finiteFuseValue(point.PowerW) { - continue - } - if !point.At.After(at) && (!hasBefore || point.At.After(before.At)) { - before, hasBefore = point, true - } - if !point.Seen().Before(at) && (!hasAfter || point.At.Before(after.At)) { - after, hasAfter = point, true - } - } - if !hasBefore || !hasAfter || !finiteFuseValue(latest) { - return 0, false - } - baseline := math.Min(latest, math.Min(before.PowerW, after.PowerW)) - for _, point := range points { - if !point.Seen().Before(at) && finiteFuseValue(point.PowerW) { - baseline = math.Min(baseline, point.PowerW) - } - } - return baseline, true -} - func evBatteryPhaseSpareW(meter *telemetry.DerReading, state *State, evIncreaseA float64) (float64, bool) { worst, present, valid := worstPhaseA(meter, state) if !present { diff --git a/go/internal/control/ev_battery_headroom_test.go b/go/internal/control/ev_battery_headroom_test.go index 35cf02f44..97ed34c6f 100644 --- a/go/internal/control/ev_battery_headroom_test.go +++ b/go/internal/control/ev_battery_headroom_test.go @@ -2,7 +2,6 @@ package control import ( "encoding/json" - "fmt" "math" "testing" "time" @@ -10,446 +9,182 @@ import ( "github.com/srcfl/ftw/go/internal/telemetry" ) -type evBatteryFixturePower struct { - driver string - kind telemetry.DerType - before, after, requested float64 - phaseMode string - missingBefore bool -} - -func evBatteryFixture(t *testing.T, gridW float64, phaseA []float64, powers ...evBatteryFixturePower) (*telemetry.Store, *State) { +// evSplitFixture is a 25 A site with a 1 A margin (16,560 W). It holds the +// meter reading, our recent commands and the EV demand; device readings are +// left out on purpose, since the split must not read them. +func evSplitFixture(t *testing.T, gridW float64, phaseA []float64, demandW float64, demandPhase string) (*telemetry.Store, *State) { t.Helper() - wall := time.Now() store := telemetry.NewStore() - state := NewState(0, 0, "meter") - state.Mode, state.UseEnergyDispatch = ModePlannerArbitrage, true - state.SiteFuseAmps, state.SiteFuseVoltage, state.SiteFusePhases, state.SiteFuseSafetyA = 25, 230, 3, 1 - state.SlewRateW, state.MinDispatchIntervalS = 100000, 0 - emit := func(driver string, kind telemetry.DerType, at time.Time, watts float64, phases []float64) { - data := map[string]any{"control_power_available": true, "control_power_observed_at": at.Format(time.RFC3339Nano)} - if phases != nil { - for i, key := range []string{"l1_a", "l2_a", "l3_a"} { - if i < len(phases) { - data[key] = phases[i] - } - } - } - raw, _ := json.Marshal(data) - var soc *float64 - if kind == telemetry.DerBattery { - level := .5 - soc = &level - } - store.Update(driver, kind, watts, soc, raw) - store.RecordDriverSuccess(driver) - } - for _, power := range powers { - if !power.missingBefore { - emit(power.driver, power.kind, wall.Add(-3*time.Second), power.before, nil) - } - if power.requested > 0 { - state.evFuseDemands = append(state.evFuseDemands, evFuseDemand{power.driver, power.requested, power.phaseMode}) + data := map[string]any{} + for i, key := range []string{"l1_a", "l2_a", "l3_a"} { + if i < len(phaseA) { + data[key] = phaseA[i] } } - emit("meter", telemetry.DerMeter, wall.Add(-2*time.Second), gridW, phaseA) - for _, power := range powers { - emit(power.driver, power.kind, wall.Add(-time.Second), power.after, nil) + raw, _ := json.Marshal(data) + store.Update("meter", telemetry.DerMeter, gridW, nil, raw) + store.RecordDriverSuccess("meter") + state := NewState(0, 0, "meter") + state.SiteFuseAmps, state.SiteFuseVoltage, state.SiteFusePhases, state.SiteFuseSafetyA = 25, 230, 3, 1 + state.DriverLimits = map[string]PowerLimits{"first": {MaxChargeW: 10000, MaxDischargeW: 5000}, "second": {MaxChargeW: 10000, MaxDischargeW: 5000}} + state.batteryCommands = map[string]commandLog{} + if demandW > 0 { + state.evFuseDemands = []evFuseDemand{{"car", demandW, demandPhase}} } return store, state } -func TestEVBatteryNewEVFeedbackCannotFundBatteryCharge(t *testing.T) { - store, state := evBatteryFixture(t, 6900, []float64{10, 10, 10}, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 0, after: 9660, requested: 10350, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 3900, after: 3900}) - now := time.Now() - dir := SlotDirective{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), BatteryEnergyWh: 2500, - LoadpointEnergyWh: map[string]float64{"car": 2587.5}, LoadpointMaxPowerW: map[string]float64{"car": 10350}, Strategy: "arbitrage"} - state.SlotDirective = func(time.Time) (SlotDirective, bool) { return dir, true } - state.DriverLimits = map[string]PowerLimits{"battery": {MaxChargeW: 10000, MaxDischargeW: 10000}} - state.EVCommandedW = 9660 - out := ComputeDispatch(store, state, map[string]float64{"battery": 10000}, 17250) - if len(out) != 1 || math.Abs(out[0].TargetW-3210) > .1 { - t.Fatalf("battery took EV room from newer feedback: %v", out) - } - if grid := 3000 + 9660 + out[0].TargetW; grid > 16560.01 { - t.Fatalf("physical grid %.0fW exceeds working ceiling", grid) - } -} - -func TestEVBatterySteadyChargingKeepsMeasuredRoom(t *testing.T) { - for _, ev := range []float64{0, 4000, 9660} { - t.Run(fmt.Sprintf("%.0fW", ev), func(t *testing.T) { - grid := 3000 + 3900 + ev - amps := grid / 690 - store, state := evBatteryFixture(t, grid, []float64{amps, amps, amps}, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: ev, after: ev, requested: 9660, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 3900, after: 3900}) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 3900}}, store, state, 17250) - if math.Abs(out[0].TargetW-3900) > .01 { - t.Fatalf("steady/starting EV %.0fW unnecessarily lost battery room: %v", ev, out) - } - }) +// sent logs our commands for a device, oldest first, ending now. +func sent(state *State, driver string, watts ...float64) { + now := state.now() + var log commandLog + for i, w := range watts { + log = log.add(now.Add(time.Duration(i-len(watts)+1)*2*time.Second), w) } -} - -func TestEVBatteryLaterBatteryPowerCannotBeReplacementCredit(t *testing.T) { - store, state := evBatteryFixture(t, 7000, []float64{7000.0 / 690, 7000.0 / 690, 7000.0 / 690}, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 4000, after: 4000, requested: 4000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 0, after: 10000}) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 10000}}, store, state, 17250) - if math.Abs(out[0].TargetW-9560) > .01 { - t.Fatalf("later battery feedback created extra charge room: %v", out) + if driver == "ev" { + state.evOffers, state.EVCommandedW = log, watts[len(watts)-1] + return } + state.batteryCommands[driver] = log } -func TestEVBatteryMissingBracketUsesRemainingRoom(t *testing.T) { - store, state := evBatteryFixture(t, 3000, nil, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 0, after: 0, requested: 4000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, after: 2000, missingBefore: true}) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 5000}}, store, state, 17250) - if out[0].TargetW != 5000 { - t.Fatalf("missing positive replacement credit blocked spare room: %v", out) - } -} - -func TestEVBatteryOneBatteryReductionDoesNotFundSiblingIncrease(t *testing.T) { - store, state := evBatteryFixture(t, 14000, nil, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 4000, after: 4000, requested: 4000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "first", kind: telemetry.DerBattery, before: 9000, after: 9000}, - evBatteryFixturePower{driver: "second", kind: telemetry.DerBattery, before: 0, after: 0}) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 0}, {Driver: "second", TargetW: 10000}}, store, state, 17250) - if out[0].TargetW != 0 || math.Abs(out[1].TargetW-2560) > .01 { - t.Fatalf("unmeasured sibling reduction financed increase: %v", out) - } - // Before the first battery has reduced, the second can already apply. - if 1000+4000+9000+out[1].TargetW > 16560.01 { - t.Fatal("command ordering can cross the working ceiling") - } -} - -func TestEVBatteryFutureDischargeDoesNotFundCharge(t *testing.T) { - store, state := evBatteryFixture(t, 7000, nil, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 4000, after: 4000, requested: 4000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "first", kind: telemetry.DerBattery, before: 0, after: 0}, - evBatteryFixturePower{driver: "second", kind: telemetry.DerBattery, before: 0, after: 0}) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: -9000}, {Driver: "second", TargetW: 10000}}, store, state, 17250) - if out[0].TargetW != -9000 || math.Abs(out[1].TargetW-9560) > .01 { - t.Fatalf("future discharge financed positive offer: %v", out) - } -} - -func TestEVBatteryStoppingDischargeReservesItsLostSupply(t *testing.T) { - store, state := evBatteryFixture(t, 2000, nil, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 4000, after: 4000, requested: 4000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: -5000, after: -5000}) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 10000}}, store, state, 17250) - if math.Abs(out[0].TargetW-9560) > .01 { - t.Fatalf("stopped discharge still funded charge: %v", out) +func targetW(out []DispatchTarget, driver string) float64 { + for _, target := range out { + if target.Driver == driver { + return target.TargetW + } } -} - -func TestEVBatteryUnmanagedEVDoesNotFundAnotherCar(t *testing.T) { - store, state := evBatteryFixture(t, 8000, nil, - evBatteryFixturePower{driver: "other-car", kind: telemetry.DerEV, before: 5000, after: 5000}, - evBatteryFixturePower{driver: "new-car", kind: telemetry.DerEV, before: 0, after: 0, requested: 5000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 2000, after: 2000}) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 10000}}, store, state, 17250) - if math.Abs(out[0].TargetW-5560) > .01 { - t.Fatalf("unmanaged EV was used as another car's replacement credit: %v", out) + return math.NaN() +} + +// House 3 kW, our 4,140 W EV offer, a 10,350 W demand: 6,210 W of the +// remaining room is the car's before the battery may charge. +func TestEVBatteryChargeYieldsToEVNeed(t *testing.T) { + store, state := evSplitFixture(t, 7140, nil, 10350, "3p") + sent(state, "ev", 4140, 4140) + sent(state, "first", 0, 0) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state, 17250) + if got, want := targetW(out, "first"), 16560.0-7140-6210; math.Abs(got-want) > .01 { + t.Fatalf("battery %.1f W, want %.1f W", got, want) + } +} + +// An EV offer raised within the settle time may not be in the meter yet; +// the lower offer is what the meter is sure to contain. +func TestEVBatteryRecentEVRiseIsStillNeed(t *testing.T) { + store, state := evSplitFixture(t, 7140, nil, 10350, "3p") + sent(state, "ev", 4140, 10350) + sent(state, "first", 0) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state, 17250) + if got, want := targetW(out, "first"), 16560.0-7140-6210; math.Abs(got-want) > .01 { + t.Fatalf("battery %.1f W spent the unseen EV rise, want %.1f W", got, want) + } +} + +// Our 4 kW cut on one battery may not be in the meter yet. It cannot fund +// the sibling's charge. +func TestEVBatteryReductionDoesNotFundSibling(t *testing.T) { + store, state := evSplitFixture(t, 12000, nil, 6000, "3p") + sent(state, "ev", 5000) + sent(state, "first", 4000, 0) + sent(state, "second", 0) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 0}, {Driver: "second", TargetW: 8000}}, store, state, 17250) + if got, want := targetW(out, "second"), 16560.0-12000-1000; got > want+.01 { + t.Fatalf("sibling %.1f W used an unseen reduction, want <= %.1f W", got, want) + } +} + +// Stopping our 5 kW discharge raises import by 5 kW; that supply is gone +// before any battery may charge. +func TestEVBatteryStoppingDischargeReservesItsSupply(t *testing.T) { + store, state := evSplitFixture(t, 6000, nil, 2000, "3p") + sent(state, "ev", 0) + sent(state, "first", -5000) + sent(state, "second", 0) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 0}, {Driver: "second", TargetW: 8000}}, store, state, 17250) + if got, want := targetW(out, "second"), 16560.0-6000-2000-5000; got > want+.01 { + t.Fatalf("sibling %.1f W, want <= %.1f W", got, want) + } +} + +// Our own 4 kW rise that the meter may not show is reserved once, not +// twice, when the same order is repeated: 9,000 + 4,000 + 2,000 W fits. +func TestEVBatteryOwnPendingRiseCountsOnce(t *testing.T) { + for _, tc := range []struct{ target, want float64 }{{6000, 6000}, {9000, 2000 + 16560 - 9000 - 2000}} { + store, state := evSplitFixture(t, 9000, nil, 2000, "3p") + sent(state, "ev", 0) + sent(state, "first", 2000, 6000) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: tc.target}}, store, state, 17250) + if got := targetW(out, "first"); math.Abs(got-tc.want) > .01 { + t.Fatalf("order %.0f W became %.1f W, want %.1f W", tc.target, got, tc.want) + } } } -func TestEVBatteryPendingSinglePhaseOfferUsesWorstPhase(t *testing.T) { - for _, mode := range []string{"1p", "auto", ""} { - t.Run(mode, func(t *testing.T) { - store, state := evBatteryFixture(t, 6900, []float64{10, 10, 10}, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 0, after: 0, requested: 2300, phaseMode: mode}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 3900, after: 3900}) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 10000}}, store, state, 17250) - if math.Abs(out[0].TargetW-6660) > .01 { - t.Fatalf("phase %q offer allowed too much balanced battery charge: %v", mode, out) - } - }) +// A single-phase car may land its whole need on the worst phase. +func TestEVBatterySinglePhaseNeedUsesWorstPhase(t *testing.T) { + store, state := evSplitFixture(t, 4600, []float64{10, 5, 5}, 3680, "1p") + sent(state, "ev", 0) + sent(state, "first", 0) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 3000}}, store, state, 17250) + if got := targetW(out, "first"); got != 0 { + t.Fatalf("battery %.1f W; L1 10 A + 16 A car leaves no room under 24 A", got) } } -func TestEVBatteryMixedPhaseSignsDoNotDoubleCreditPV(t *testing.T) { - store, state := evBatteryFixture(t, -2300, []float64{10, 10, 10}, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 0, after: 0, requested: 2300, phaseMode: "1p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 0, after: 0}) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 10000}}, store, state, 17250) - if math.Abs(out[0].TargetW-2760) > .01 { - t.Fatalf("net export hid an importing phase: %v", out) +// Without a recent command a battery may be discharging and stop; its +// configured discharge stays reserved. +func TestEVBatteryWithoutCommandsReservesDischarge(t *testing.T) { + store, state := evSplitFixture(t, 5000, nil, 4140, "3p") + sent(state, "ev", 0) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state, 17250) + if got, want := targetW(out, "first"), 16560.0-5000-4140-5000; got > want+.01 { + t.Fatalf("battery %.1f W, want <= %.1f W", got, want) } } func TestEVBatteryUnknownMeterClampsOnlyPositiveOffers(t *testing.T) { - store, state := evBatteryFixture(t, 0, nil, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 0, after: 0, requested: 11000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 0, after: 0}) + store, state := evSplitFixture(t, 0, nil, 4140, "3p") store.Remove("meter") - in := []DispatchTarget{{Driver: "battery", TargetW: 5000}, {Driver: "other", TargetW: -1000}} - out := clampEVBatteryHeadroom(in, store, state, 17250) - if out[0].TargetW != 0 || out[1].TargetW != -1000 || in[0].TargetW != 5000 { - t.Fatalf("unknown site invented headroom, removed discharge, or mutated input: in=%v out=%v", in, out) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 3000}, {Driver: "second", TargetW: -2000}}, store, state, 17250) + if targetW(out, "first") != 0 || targetW(out, "second") != -2000 { + t.Fatalf("unknown meter: %+v", out) } } +// Charge that brings measured export back inside its ceiling stays. func TestEVBatteryKeepsChargeNeededForMeasuredExportRelief(t *testing.T) { - store, state := evBatteryFixture(t, -20000, []float64{20000.0 / 690, 20000.0 / 690, 20000.0 / 690}, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 0, after: 0, requested: 10000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 0, after: 0}) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 5000}}, store, state, 17250) - if out[0].TargetW != 5000 { - t.Fatalf("EV reserve removed export relief needed before the charger can start: %v", out) - } - if -20000+out[0].TargetW < -16560.01 { - t.Fatal("EV reserve left the measured site above the export ceiling") - } -} - -func TestEVBatteryPendingSiblingOrderCannotFundNewCharge(t *testing.T) { - for _, includeSibling := range []bool{true, false} { - t.Run(fmt.Sprint(includeSibling), func(t *testing.T) { - store, state := evBatteryFixture(t, 1000, nil, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 0, after: 0, requested: 4000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "first", kind: telemetry.DerBattery, before: 0, after: 0}, - evBatteryFixturePower{driver: "second", kind: telemetry.DerBattery, before: 0, after: 0}) - // A higher order may still arrive after this tick's lower one. - now := time.Now() - old := store.BeginCommand("first", []byte(`{"action":"battery","power_w":8000}`), now.Add(-time.Second)) - store.CompleteCommand(old, "accepted") - lower := store.BeginCommand("first", []byte(`{"action":"battery","power_w":0}`), now) - store.CompleteCommand(lower, "accepted") - in := []DispatchTarget{{Driver: "second", TargetW: 10000}} - if includeSibling { - in = append([]DispatchTarget{{Driver: "first", TargetW: 0}}, in...) - } - out := clampEVBatteryHeadroom(in, store, state, 17250) - var got float64 - for _, target := range out { - if target.Driver == "second" { - got = target.TargetW - } - } - if math.Abs(got-3560) > .01 { - t.Fatalf("old sibling order financed a new battery: %+v", out) - } - if 1000+4000+8000+got > 16560.01 { - t.Fatal("EV plus old and new battery orders exceed one physical budget") - } - }) - } -} - -func TestEVBatteryOwnPendingOrderIsNotCountedTwice(t *testing.T) { - store, state := evBatteryFixture(t, 1000, nil, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 0, after: 0, requested: 4000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 0, after: 0}) - order := store.BeginCommand("battery", []byte(`{"action":"battery","power_w":8000}`), time.Now()) - store.CompleteCommand(order, "accepted") - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 8000}}, store, state, 17250) - if out[0].TargetW != 8000 { - t.Fatalf("same physical offer was reserved twice: %v", out) - } -} - -func TestEVBatteryPollBeforeSiteKeepsSteadyCharge(t *testing.T) { - for _, tc := range []struct { - name string - offlineSibling, removed, cold bool - }{ - {name: "steady"}, - {name: "offline_sibling", offlineSibling: true}, - {name: "removed_driver", removed: true}, - {name: "cold_start", cold: true}, - } { - t.Run(tc.name, func(t *testing.T) { - wall := time.Now() - start := wall.Add(-6 * time.Second) - store := telemetry.NewStore() - state := NewState(0, 0, "meter") - state.SiteFuseAmps, state.SiteFuseVoltage, state.SiteFusePhases, state.SiteFuseSafetyA = 25, 230, 3, 1 - state.evFuseDemands = []evFuseDemand{{"car", 10350, "3p"}} - state.clock = func() time.Time { return wall.Add(time.Second) } - if tc.offlineSibling { - state.DriverLimits = map[string]PowerLimits{"offline": {MaxChargeW: 10000, MaxDischargeW: 10000}} - } - if tc.removed { - state.PrevTargets = map[string]float64{"removed": 10000} - } - emit := func(driver string, kind telemetry.DerType, at time.Time, watts float64) { - data := map[string]any{"control_power_available": true, "control_power_observed_at": at.Format(time.RFC3339Nano)} - if kind == telemetry.DerMeter { - for _, key := range []string{"l1_a", "l2_a", "l3_a"} { - data[key] = watts / 690 - } - } - raw, _ := json.Marshal(data) - store.Update(driver, kind, watts, nil, raw) - store.RecordDriverSuccess(driver) - } - emit("battery", telemetry.DerBattery, start.Add(-1900*time.Millisecond), 1210) - emit("car", telemetry.DerEV, start.Add(-1900*time.Millisecond), 10350) - if !tc.cold { - emit("meter", telemetry.DerMeter, start.Add(-1500*time.Millisecond), 16560) - } - emit("car", telemetry.DerEV, start.Add(-1400*time.Millisecond), 10350) - batteryW := 1210.0 - var commands []float64 - for tick := range 6 { - at := start.Add(time.Duration(tick) * time.Second) - emit("car", telemetry.DerEV, at.Add(-300*time.Millisecond), 10350) - emit("battery", telemetry.DerBattery, at.Add(-200*time.Millisecond), batteryW) - emit("meter", telemetry.DerMeter, at, 5000+10350+batteryW) - emit("car", telemetry.DerEV, at.Add(100*time.Millisecond), 10350) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 1210}}, store, state, 17250) - batteryW = out[0].TargetW - commands = append(commands, batteryW) - } - for tick, watts := range commands { - if tc.cold && tick < 2 { - continue // No common source observation yet. - } - if math.Abs(watts-1210) > .01 { - t.Fatalf("steady source poll order needlessly changed battery at tick%d: %v", tick, commands) - } - } - t.Logf("battery commands: %v", commands) - }) - } -} - -func TestEVBatteryUsesQualifiedACInsteadOfRawDC(t *testing.T) { - store, state := evBatteryFixture(t, 13000, nil, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 4000, after: 4000, requested: 4000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 8000, after: 8000}) - at := time.Now().Add(-500 * time.Millisecond) - data, _ := json.Marshal(map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano), "control_power_w": 8000}) - store.Update("battery", telemetry.DerBattery, 2000, nil, data) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 8000}}, store, state, 17250) - if out[0].TargetW != 8000 { - t.Fatalf("raw DC replaced qualified AC in site balance: %v", out) - } -} - -func TestEVBatteryObservedSiblingRiseNeedsRoomWithoutOrder(t *testing.T) { - for _, tc := range []struct { - name string - beforeW, afterW, gridW, wantW float64 - }{ - {"charging", 0, 8000, 7000, 1560}, - {"stopped_discharge", -8000, 0, -1000, 9560}, + store, state := evSplitFixture(t, -19000, nil, 4140, "3p") + state.MaxExportW = 10000 + sent(state, "ev", 0) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 12000}}, store, state, 17250) + if got := targetW(out, "first"); got < 9000-.01 { + t.Fatalf("export relief cut to %.1f W, want >= 9,000 W", got) + } +} + +// The split reads our commands and the meter only. A lone, missing, late +// or too high reading from the charger or battery changes nothing. +func TestEVBatterySplitIgnoresDeviceReadings(t *testing.T) { + run := func(readings func(*telemetry.Store)) float64 { + store, state := evSplitFixture(t, 7140, nil, 10350, "3p") + sent(state, "ev", 4140, 4140) + sent(state, "first", 0, 0) + readings(store) + return targetW(clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state, 17250), "first") + } + want := run(func(*telemetry.Store) {}) + for name, readings := range map[string]func(*telemetry.Store){ + "ev_lone_zero": func(s *telemetry.Store) { s.Update("car", telemetry.DerEV, 0, nil, nil) }, + "ev_above_offer": func(s *telemetry.Store) { s.Update("car", telemetry.DerEV, 11000, nil, nil) }, + "ev_unavailable": func(s *telemetry.Store) { + s.Update("car", telemetry.DerEV, 0, nil, []byte(`{"control_power_available":false}`)) + }, + "battery_discharging": func(s *telemetry.Store) { s.Update("first", telemetry.DerBattery, -5000, nil, nil) }, + "battery_charging": func(s *telemetry.Store) { s.Update("first", telemetry.DerBattery, 6000, nil, nil) }, } { - t.Run(tc.name, func(t *testing.T) { - store, state := evBatteryFixture(t, tc.gridW, nil, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 4000, after: 4000, requested: 4000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "sibling", kind: telemetry.DerBattery, before: tc.beforeW, after: tc.afterW}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 0, after: 0}) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 10000}}, store, state, 17250) - if math.Abs(out[0].TargetW-tc.wantW) > .01 || 7000+tc.afterW+out[0].TargetW > 16560.01 { - t.Fatalf("newer measured sibling rise was absent from the physical budget: %v, want%.0fW", out, tc.wantW) - } - }) - } -} - -func TestEVBatteryPendingSiblingBacksOffExistingChargeForEV(t *testing.T) { - for _, tc := range []struct { - name string - hasTarget bool - targetW float64 - }{ - {name: "absent"}, - {name: "zero", hasTarget: true}, - {name: "partial", hasTarget: true, targetW: 2000}, - } { - t.Run(tc.name, func(t *testing.T) { - store, state := evBatteryFixture(t, 5000, nil, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 0, after: 0, requested: 5000, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 4000, after: 4000}, - evBatteryFixturePower{driver: "sibling", kind: telemetry.DerBattery, before: 0, after: 0}) - old := store.BeginCommand("sibling", []byte(`{"action":"battery","power_w":8000}`), time.Now()) - store.CompleteCommand(old, "accepted") - targets := []DispatchTarget{{Driver: "battery", TargetW: 4000}} - if tc.hasTarget { - targets = append(targets, DispatchTarget{Driver: "sibling", TargetW: tc.targetW}) - } - out := clampEVBatteryHeadroom(targets, store, state, 17250) - if math.Abs(out[0].TargetW-2560) > .01 || 1000+5000+8000+out[0].TargetW > 16560.01 { - t.Fatalf("pending sibling kept optional battery ahead of EV: %v", out) - } - }) - } -} - -func TestEVBatteryPositiveLimitsUseOneSnapshot(t *testing.T) { - for _, reverse := range []bool{false, true} { - t.Run(fmt.Sprint(reverse), func(t *testing.T) { - targets := []DispatchTarget{{Driver: "first", TargetW: 8000}, {Driver: "second", TargetW: 8000}} - if reverse { - targets[0], targets[1] = targets[1], targets[0] - } - out := capBatteryOffersToHeadroom(targets, map[string]float64{"first": 0, "second": 0}, map[string]float64{"first": 0, "second": 0}, 10000) - if out[0].TargetW != 5000 || out[1].TargetW != 5000 { - t.Fatalf("one target's clamp changed the other's shared allocation: %v", out) - } - }) - } -} - -func TestEVBatteryOlderBalanceKeepsLatestGridAndPhaseLimits(t *testing.T) { - for _, tc := range []struct { - name string - gridW, worstA float64 - wantW float64 - }{ - {"grid_jump", 17560, 17560.0 / 690, 210}, - {"phase_jump", 16560, 25, 520}, - } { - t.Run(tc.name, func(t *testing.T) { - store, state := evBatteryFixture(t, 16560, []float64{24, 24, 24}, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 10350, after: 10350, requested: 10350, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: 1210, after: 1210}) - at := time.Now().Add(-500 * time.Millisecond) - data, _ := json.Marshal(map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano), - "l1_a": tc.worstA, "l2_a": 24, "l3_a": 24}) - store.Update("meter", telemetry.DerMeter, tc.gridW, nil, data) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 1210}}, store, state, 17250) - if math.Abs(out[0].TargetW-tc.wantW) > .01 { - t.Fatalf("older balance hid latest %s: %v, want%.0fW", tc.name, out, tc.wantW) - } - }) - } -} - -func TestEVBatteryOlderBalanceRetainsLaterStop(t *testing.T) { - for _, kind := range []telemetry.DerType{telemetry.DerEV, telemetry.DerBattery} { - t.Run(kind.String(), func(t *testing.T) { - carW, batteryW, oldGridW, newGridW, wantW := 4000.0, 1000.0, 6000.0, 4000.0, 7560.0 - driver, restartedW := "car", carW - if kind == telemetry.DerBattery { - carW, batteryW, oldGridW, newGridW, wantW = 3000, 8000, 12000, 9000, 4560 - driver, restartedW = "battery", batteryW - } - store, state := evBatteryFixture(t, oldGridW, nil, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: carW, after: carW, requested: carW, phaseMode: "3p"}, - evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: batteryW, after: batteryW}) - wall := time.Now() - emit := func(driver string, kind telemetry.DerType, ago time.Duration, watts float64) { - at := wall.Add(-ago) - data, _ := json.Marshal(map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano)}) - store.Update(driver, kind, watts, nil, data) - } - emit(driver, kind, 800*time.Millisecond, 0) - emit("meter", telemetry.DerMeter, 700*time.Millisecond, newGridW) - emit(driver, kind, 600*time.Millisecond, restartedW) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 12000}}, store, state, 17250) - if math.Abs(out[0].TargetW-wantW) > .01 { - t.Fatalf("older balance lost the observed %s stop: %v, want%.0fW", kind, out, wantW) - } - }) + if got := run(readings); got != want { + t.Fatalf("%s moved the battery target %.1f -> %.1f W", name, want, got) + } } } diff --git a/go/internal/control/ev_battery_sequence_test.go b/go/internal/control/ev_battery_sequence_test.go index a01dc1bf9..b941e956c 100644 --- a/go/internal/control/ev_battery_sequence_test.go +++ b/go/internal/control/ev_battery_sequence_test.go @@ -13,7 +13,7 @@ import ( ) func TestJoinedEVWaitsForMeasuredBatteryBackoffThenResumes(t *testing.T) { - wall := time.Now() + now := time.Now().Truncate(time.Second) const houseW, desiredEVW = 5000.0, 10350.0 actualBattery, actualEV := 8000.0, 0.0 config := loadpoint.Config{ID: "garage", DriverName: "charger", MinChargeW: 4140, MaxChargeW: desiredEVW, @@ -21,8 +21,9 @@ func TestJoinedEVWaitsForMeasuredBatteryBackoffThenResumes(t *testing.T) { mgr := loadpoint.NewManager() mgr.Load([]loadpoint.Config{config}) store := telemetry.NewStore() - dir := SlotDirective{SlotStart: wall, SlotEnd: wall.Add(15 * time.Minute), BatteryEnergyWh: 2500, Strategy: "arbitrage"} + dir := SlotDirective{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), BatteryEnergyWh: 2500, Strategy: "arbitrage"} st := newStateWithEnergyDispatch(dir, "meter") + st.clock = func() time.Time { return now } st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases, st.SiteFuseSafetyA = 25, 230, 3, 1 st.SlewEnabled = false st.DriverLimits = map[string]PowerLimits{"pixii": {MaxChargeW: 10000, MaxDischargeW: 10000}} @@ -30,7 +31,7 @@ func TestJoinedEVWaitsForMeasuredBatteryBackoffThenResumes(t *testing.T) { controller := loadpoint.NewController(mgr, func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false }, func(string) (loadpoint.EVSample, bool) { - return loadpoint.EVSample{Connected: true, RequestActive: true, PowerW: actualEV, PowerAt: time.Now(), SessionWhUnavailable: true}, true + return loadpoint.EVSample{Connected: true, RequestActive: true, PowerW: actualEV, PowerAt: now, SessionWhUnavailable: true}, true }, func(_ context.Context, _ string, payload []byte) error { var cmd struct { @@ -54,49 +55,54 @@ func TestJoinedEVWaitsForMeasuredBatteryBackoffThenResumes(t *testing.T) { controller.SetManualHold(config.ID, loadpoint.ManualHold{PowerW: desiredEVW, PhaseMode: "3p", Persistent: true}) controller.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return sampledPhaseA, sampledPhaseA, sampledPhaseA, true }) controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(store, st, mgr.States(), mgr.Configs(), 17250) }) - emit := func(driver string, kind telemetry.DerType, at time.Time, watts float64) { - d := map[string]any{"control_power_available": true, "control_power_observed_at": at.Format(time.RFC3339Nano)} + emit := func(driver string, kind telemetry.DerType, watts float64) { + d := map[string]any{"control_power_available": true, "control_power_observed_at": now.Format(time.RFC3339Nano)} if kind == telemetry.DerMeter { d["l1_a"], d["l2_a"], d["l3_a"] = sampledPhaseA, sampledPhaseA, sampledPhaseA } data, _ := json.Marshal(d) soc := .5 store.Update(driver, kind, watts, &soc, data) + store.Get(driver, kind).UpdatedAt = now store.RecordDriverSuccess(driver) } - for tick := 0; tick < 6; tick++ { + var obeyedAt, resumedAt time.Time + for tick := 0; tick < 30; tick++ { + now = now.Add(5 * time.Second) if tick == 3 { - actualBattery = 0 - } // Only now does the battery obey. - at := wall.Add(-9*time.Second + time.Duration(tick)*1500*time.Millisecond) + actualBattery, obeyedAt = 0, now // Only now does the battery obey. + } sampledPhaseA = (houseW + actualBattery + actualEV) / 690 - emit("pixii", telemetry.DerBattery, at, actualBattery) - emit(config.DriverName, telemetry.DerEV, at, actualEV) - emit("meter", telemetry.DerMeter, at.Add(500*time.Millisecond), houseW+actualBattery+actualEV) - emit("pixii", telemetry.DerBattery, at.Add(time.Second), actualBattery) - emit(config.DriverName, telemetry.DerEV, at.Add(time.Second), actualEV) - controller.TickWithDispatch(context.Background(), wall.Add(time.Duration(tick)*5*time.Second), true) + emit("pixii", telemetry.DerBattery, actualBattery) + emit(config.DriverName, telemetry.DerEV, actualEV) + emit("meter", telemetry.DerMeter, houseW+actualBattery+actualEV) + controller.TickWithDispatch(context.Background(), now, true) st.EVCommandedW = loadpoint.CommandedEVPowerW(mgr.States()) st.SetEVFuseDemands(controller.FuseDemands()) targets := ComputeDispatch(store, st, caps(map[string]float64{"pixii": 15000}), 17250) if len(targets) != 1 || targets[0].TargetW > 1210.001 { t.Fatalf("tick %d: battery did not yield to the EV: %+v", tick, targets) } - if tick < 4 && actualEV != 0 { - t.Fatalf("tick %d: EV resumed before measured room was stable: %v W", tick, actualEV) + if actualEV > 0 && resumedAt.IsZero() { + resumedAt = now } - if tick >= 4 { - if math.Abs(actualEV-desiredEVW) > .001 { - t.Fatalf("tick %d: EV stuck at %v W after recovery", tick, actualEV) - } - actualBattery = targets[0].TargetW - if (houseW+actualBattery+actualEV)/690 > 24.001 { - t.Fatal("battery took the EV's new headroom") - } + if !obeyedAt.IsZero() { + actualBattery = math.Max(0, targets[0].TargetW) + } + if (houseW+actualBattery+actualEV)/690 > 24.001 { + t.Fatalf("tick %d: battery took the EV's headroom: EV %.0f battery %.0f W", tick, actualEV, actualBattery) } } + if resumedAt.IsZero() || resumedAt.Sub(obeyedAt) < evRestartDwell { + t.Fatalf("EV resumed at %v, battery obeyed at %v: want a %v dwell on measured room", resumedAt, obeyedAt, evRestartDwell) + } + if actualEV < 9660 { + t.Fatalf("EV stuck at %.0f W after recovery", actualEV) + } } +// Whatever the EV guard may still allow, our battery orders keep the total +// inside 24 A, in either order of arrival. func TestEVBatteryCommandOrdersStayWithinMeasuredHeadroom(t *testing.T) { rng := rand.New(rand.NewSource(1667)) for sample := 0; sample < 300; sample++ { @@ -105,14 +111,12 @@ func TestEVBatteryCommandOrdersStayWithinMeasuredHeadroom(t *testing.T) { ev := rng.Float64() * 3000 grid := house + first + second + ev requestedEV := rng.Float64() * 11040 - // The EV's own guard has allowed only a measured increase. Its new - // draw may appear before the next site sample or any battery reply. actualEV := math.Max(ev, math.Min(requestedEV, ev+16560-grid)) amps := grid / 690 - store, state := evBatteryFixture(t, grid, []float64{amps, amps, amps}, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: ev, after: actualEV, requested: requestedEV, phaseMode: "3p"}, - evBatteryFixturePower{driver: "first", kind: telemetry.DerBattery, before: first, after: first}, - evBatteryFixturePower{driver: "second", kind: telemetry.DerBattery, before: second, after: second}) + store, state := evSplitFixture(t, grid, []float64{amps, amps, amps}, requestedEV, "3p") + sent(state, "ev", ev) + sent(state, "first", first) + sent(state, "second", second) out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: rng.Float64() * 10000}, {Driver: "second", TargetW: rng.Float64() * 10000}}, store, state, 17250) for _, order := range [][2]int{{0, 1}, {1, 0}} { batteries := [2]float64{first, second} @@ -127,6 +131,8 @@ func TestEVBatteryCommandOrdersStayWithinMeasuredHeadroom(t *testing.T) { } } +// Battery orders the meter may not show yet, the EV cap and new battery +// orders share one physical budget. func TestEVBatteryPendingAndNewOrdersShareOnePhysicalBudget(t *testing.T) { rng := rand.New(rand.NewSource(1668)) for sample := 0; sample < 300; sample++ { @@ -136,21 +142,14 @@ func TestEVBatteryPendingAndNewOrdersShareOnePhysicalBudget(t *testing.T) { pendingSecond := second + rng.Float64()*(16560-grid-(pendingFirst-first)) requestedEV := rng.Float64() * 11040 amps := grid / 690 - store, state := evBatteryFixture(t, grid, []float64{amps, amps, amps}, - evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: ev, after: ev, requested: requestedEV, phaseMode: "3p"}, - evBatteryFixturePower{driver: "first", kind: telemetry.DerBattery, before: first, after: first}, - evBatteryFixturePower{driver: "second", kind: telemetry.DerBattery, before: second, after: second}) - state.DriverLimits = map[string]PowerLimits{"first": {MaxChargeW: 17000, MaxDischargeW: 10000}, "second": {MaxChargeW: 17000, MaxDischargeW: 10000}} - for driver, watts := range map[string]float64{"first": pendingFirst, "second": pendingSecond} { - payload, _ := json.Marshal(map[string]any{"action": "battery", "power_w": watts}) - command := store.BeginCommand(driver, payload, time.Now()) - store.CompleteCommand(command, "accepted") - } - capW, _ := EVFuseBudget(store, state, []loadpoint.State{{ID: "car", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "car", DriverName: "car", PhaseMode: "3p"}}, 17250) + store, state := evSplitFixture(t, grid, []float64{amps, amps, amps}, requestedEV, "3p") + sent(state, "ev", ev) + sent(state, "first", first, pendingFirst) + sent(state, "second", second, pendingSecond) + points := []loadpoint.State{{ID: "car", DriverName: "car", PluggedIn: true, CommandedKnown: true, CommandedW: ev}} + capW, _ := EVFuseBudget(store, state, points, []loadpoint.Config{{ID: "car", DriverName: "car", PhaseMode: "3p"}}, 17250) actualEV := math.Max(ev, math.Min(requestedEV, capW)) out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: rng.Float64() * 10000}, {Driver: "second", TargetW: rng.Float64() * 10000}}, store, state, 17250) - // Either old order can arrive after either new one. A newer command - // on the same device replaces its offer, never its pending evidence. worst := house + actualEV + math.Max(pendingFirst, out[0].TargetW) + math.Max(pendingSecond, out[1].TargetW) if worst > 16560.001 { t.Fatalf("sample %d: old/new battery orders and EV reach %.1f W; pending %.1f/%.1f EV %.1f targets %+v", sample, worst, pendingFirst, pendingSecond, actualEV, out) diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 8a09148ad..4cd9652cb 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -21,41 +21,83 @@ func evHeadroomW(ceiling, gridW, poolW float64) float64 { func finiteFuseValue(w float64) bool { return !math.IsNaN(w) && !math.IsInf(w, 0) } -// evSettle is how long a charger may take to show a new current at the site -// meter; Easee Cloud needs about 5–15 s (#1674). Until then the meter may -// still contain the higher offer, so no overage is cut twice and no rise -// follows an unseen step. -const evSettle = 20 * time.Second +// evSettle is how long one of our commands may take to reach the site meter. +const evSettle = loadpoint.CommandSettle + +// evRestartDwell is how long the meter must keep showing room before an idle +// pool starts again. It stays above Easee's ~30 s contactor minimum. +const evRestartDwell = 35 * time.Second // evPhaseMarginA is the working margin below the breaker on each phase. One -// amp on the pool's phases is also the rise step and the restart margin. +// amp on the pool's phases is also the rise step and the gap between the cut +// and rise thresholds. const evPhaseMarginA = 1.0 -// evMeterLimit is the managed EVs' common hard cap. The site meter measures -// the protected quantities, worst phase and total import, so it alone decides -// a cut. EV readings only gate a rise: a lone or late 0 W sample cannot stop -// a charge. +// sentCommand is one of our own commands, never a device reading. +type sentCommand struct { + at time.Time + w float64 +} + +// commandLog keeps a device's commands for evSettle, plus the one in force +// when that window opened. The meter may show any of them. +type commandLog []sentCommand + +func (l commandLog) add(now time.Time, w float64) commandLog { + l = append(l, sentCommand{now, w}) + i := 0 + for i+1 < len(l) && now.Sub(l[i+1].at) >= evSettle { + i++ + } + if i > 0 { + return append(commandLog(nil), l[i:]...) + } + return l +} + +// span returns the lowest, highest and latest command the meter may show. +func (l commandLog) span(now time.Time) (low, high, latest float64, ok bool) { + for j, c := range l { + if j+1 < len(l) && now.Sub(l[j+1].at) >= evSettle { + continue // Replaced before the window opened. + } + if !ok || c.w < low { + low = c.w + } + if !ok || c.w > high { + high = c.w + } + latest, ok = c.w, true + } + return low, high, latest, ok +} + +// evMeterLimit is the managed EVs' common hard cap. It reads only the site +// meter, which measures the protected quantities, and our own commands. No +// charger, car or battery reading takes part in a cut, a rise or a start. type evMeterLimit struct { - set bool - capW float64 - changedAt time.Time // last cut, rise or idle start level - peakW float64 // highest offer the meter may still contain - peakAt time.Time + set bool + capW float64 + changedAt time.Time // last cut, rise or idle level + 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 } type evPool struct { - offerW, drawW, wantW float64 - drawKnown bool - minW, phases float64 + offerW, wantW, minW, phases float64 + offerKnown bool } // EVFuseBudget returns the managed EVs' common hard cap, which the loadpoint // controller divides between cars. Callers hold the control lock, and the // shared freshness gate must already allow dispatch. An overage on the latest -// site reading cuts at once; inside the limits the cap holds, and it rises -// one step only when the meter shows room and the cars have taken the last -// step. An idle pool starts only with room for its minimum. Repeating a call -// on the same reading changes nothing. +// site reading cuts at once. Inside the limits the cap holds; it rises one +// step only on a fresh reading with room for two, once the meter has shown +// the last change. An idle pool starts only after the meter has kept showing +// room for its minimum plus a step. Each reading acts once, so a silent +// meter can never raise or restart a charge. func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State, configs []loadpoint.Config, fuseMaxW float64) (float64, bool) { if store == nil || state == nil || !finiteFuseValue(fuseMaxW) || fuseMaxW <= 0 { return 0, true @@ -65,61 +107,84 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State return 0, true } lim := &state.evLimit - pool, plugged := evPoolFor(store, state, points, configs, fuseMaxW) + pool, plugged := evPoolFor(state, points, configs) if !plugged { *lim = evMeterLimit{} return 0, true } - state.stepEVMeterLimit(store, meter, pool, fuseMaxW) + if pool.offerKnown { + state.evOffers = state.evOffers.add(state.now(), pool.offerW) + } + if !lim.set || meter.UpdatedAt.After(lim.readingAt) { + state.stepEVMeterLimit(meter, pool, fuseMaxW) + } return lim.capW, true } -func (s *State) stepEVMeterLimit(store *telemetry.Store, meter *telemetry.DerReading, pool evPool, fuseMaxW float64) { - lim, now := &s.evLimit, s.now() - if !lim.set || pool.offerW >= lim.peakW || now.Sub(lim.peakAt) >= evSettle { - lim.peakW, lim.peakAt = pool.offerW, now - } +func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, pool evPool, fuseMaxW float64) { + lim, now, at := &s.evLimit, s.now(), meter.UpdatedAt + first := !lim.set + gap := first || at.Sub(lim.readingAt) > evSettle + lim.readingAt = at prev := math.Inf(1) - if lim.set { + if !first { prev = lim.capW } lim.set = true + peakW := pool.offerW + if _, high, _, ok := s.evOffers.span(now); ok { + peakW = math.Max(peakW, high) + } roomW, measured, worstA := s.evMeterRoomW(meter, pool.phases, fuseMaxW) if roomW < 0 { - // The meter may still contain up to peakW of EV power. Taking the - // overage from that peak cuts each measured watt once. - lim.capW = math.Min(prev, math.Max(0, lim.peakW+roomW)) + // 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{} if lim.capW < prev { - lim.changedAt = now + lim.changedAt, lim.raised = now, false slog.Info("EV hard limit: site meter over limit", "grid_w", meter.RawW, "worst_phase_a", worstA, - "overage_w", -roomW, "offer_w", lim.peakW, "cap_w", lim.capW) + "overage_w", -roomW, "offer_w", peakW, "cap_w", lim.capW) } return } - // An increase needs measured room left after outstanding battery orders. + // An increase needs a fresh reading and room left after our own battery + // commands that the meter may not show yet. room := 0.0 - if measured { - room = math.Max(0, roomW-s.pendingBatteryIncreaseW(store, meter)) + if _, fresh := qualifiedFuseObservation(meter, now); fresh && measured { + room = math.Max(0, roomW-s.pendingBatteryCommandsW(now)) } step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases if pool.offerW <= 0 { - lim.capW, lim.changedAt = 0, now - if room >= pool.minW+step { - lim.capW = room + 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 } return } - // Keeping the current offer is never a cut. Headroom above it stays + // Keeping the current offers is never a cut. Headroom above them stays // within what this reading measured. - lim.capW = math.Min(prev, math.Max(pool.offerW, pool.drawW)+room) - if math.IsInf(prev, 1) { - lim.changedAt = now // The first reading sets the cap; a rise waits. + lim.capW, lim.roomSince = math.Min(prev, peakW+room), time.Time{} + if first { + lim.changedAt = now return } - if lim.capW < pool.wantW && room >= step && now.Sub(lim.changedAt) >= evSettle && - (!pool.drawKnown || pool.drawW >= pool.offerW-step) { + // The meter shows the last change once import rose with the step, or + // in any reading taken evSettle after it. + seen := at.Sub(lim.changedAt) >= evSettle || + (lim.raised && at.After(lim.changedAt) && meter.RawW >= lim.raisedGridW+step/2) + if lim.capW < pool.wantW && room >= 2*step && seen { lim.capW += step - lim.changedAt = now + lim.changedAt, lim.raised, lim.raisedGridW = now, true, meter.RawW } } @@ -162,31 +227,32 @@ func worstPhaseA(meter *telemetry.DerReading, state *State) (worstA float64, pre return worstA, true, true } -func (s *State) pendingBatteryIncreaseW(store *telemetry.Store, meter *telemetry.DerReading) float64 { - now := s.now() - siteAt := meter.UpdatedAt - if current, ok := qualifiedFuseObservation(meter, now); ok { - siteAt = current.Seen() +// pendingBatteryCommandsW is battery increase we ordered that the meter may +// not show yet. Signed, so a stopped discharge counts too. +func (s *State) pendingBatteryCommandsW(now time.Time) float64 { + var pending float64 + for _, log := range s.batteryCommands { + if low, _, latest, ok := log.span(now); ok { + pending += math.Max(0, latest-low) + } } - windows := store.ControlWindows(now.Add(-telemetry.ControlWindowDuration), now) - return pendingBatteryIncreaseAtLatestSite(pendingBatterySeriesForFuse(store, s, windows, now), siteAt) + return pending } -// evPoolFor sums the plugged managed chargers. Before the first command the -// cars' readings, or else the fuse, bound what the meter contains. -func evPoolFor(store *telemetry.Store, state *State, points []loadpoint.State, configs []loadpoint.Config, fuseMaxW float64) (evPool, bool) { +// evPoolFor sums the plugged managed chargers. Before the first command after +// a restart their offer is unknown, and the pool counts as idle. +func evPoolFor(state *State, points []loadpoint.State, configs []loadpoint.Config) (evPool, bool) { byID := make(map[string]loadpoint.Config, len(configs)) for _, cfg := range configs { byID[cfg.ID] = cfg } - pool := evPool{phases: 3, wantW: math.Inf(1), drawKnown: true} - offerKnown := true - seen := map[string]bool{} - now := state.now() + pool := evPool{phases: 3, wantW: math.Inf(1), offerKnown: true} + plugged := false for _, point := range points { if !point.PluggedIn { continue } + plugged = true cfg := byID[point.ID] // Empty is the declared three-phase default. The controller's own // gate covers runtime auto/manual phase changes. @@ -196,28 +262,13 @@ func evPoolFor(store *telemetry.Store, state *State, points []loadpoint.State, c if cfg.MinChargeW > 0 && (pool.minW == 0 || cfg.MinChargeW < pool.minW) { pool.minW = cfg.MinChargeW } - offerKnown = offerKnown && point.CommandedKnown - if seen[point.DriverName] { - continue - } - seen[point.DriverName] = true - r := store.Get(point.DriverName, telemetry.DerEV) - h := store.DriverHealth(point.DriverName) - if current, ok := qualifiedFuseObservation(r, now); ok && h != nil && h.TelemetryLive() { - pool.drawW += math.Max(0, current.PowerW) - } else { - pool.drawKnown = false - } + pool.offerKnown = pool.offerKnown && point.CommandedKnown } - if len(seen) == 0 { + if !plugged { return pool, false } - pool.offerW = loadpoint.CommandedEVPowerW(points) - if !offerKnown { - pool.offerW = fuseMaxW - if pool.drawKnown { - pool.offerW = pool.drawW - } + if pool.offerKnown { + pool.offerW = loadpoint.CommandedEVPowerW(points) } if state.evFuseDemandsSet { pool.wantW = 0 @@ -242,11 +293,12 @@ func qualifiedFuseObservation(r *telemetry.DerReading, now time.Time) (telemetry func publishBatterylessEVFuseBudget(store *telemetry.Store, state *State, fuseMaxW float64) { meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) state.FuseEVMaxW, state.FuseSaturated = 0, false - demand := math.Max(state.EVChargingW, scheduledEVPowerW(state)) + evInMeterW := state.evInMeterW() + demand := math.Max(evInMeterW, scheduledEVPowerW(state)) if meter == nil || demand <= 0 { return } - cap := evHeadroomW(state.phaseImportCeilingW(fuseMaxW, meter), math.Max(meter.RawW, meter.SmoothedW), state.EVChargingW) + cap := evHeadroomW(state.phaseImportCeilingW(fuseMaxW, meter), math.Max(meter.RawW, meter.SmoothedW), evInMeterW) state.FuseEVMaxW = math.Min(demand, cap) state.FuseSaturated = demand > cap } diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go index 25e7169cd..9a19812e1 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -16,11 +16,21 @@ type evTimed struct { w float64 } +// evReadings is how the charger's own power readings behave. The hard +// limit must not care. +type evReadings int + +const ( + evReadingsNormal evReadings = iota // a poll every 5 s, telemetryDelay old + evReadingsMissing // no reading at all + evReadingsHigh // 2 kW above the true draw +) + // evMeterSite runs one Easee-like 3p charger with Charge now against a // simulated 25 A site. A new current takes chargerDelay to reach the car. // Like Easee Cloud, the driver polls every 5 s and receives the draw as it -// was telemetryDelay ago. The meter reads the physical import on every -// one-second tick, and the loadpoint ticks right after it, as in main.go. +// was telemetryDelay ago. The meter reads the physical import every second +// unless silenced; dispatch stops when it is a minute old, as in main.go. type evMeterSite struct { t *testing.T now time.Time @@ -29,20 +39,22 @@ type evMeterSite struct { mgr *loadpoint.Manager ctrl *loadpoint.Controller chargerDelay, telemetryDelay time.Duration + readings evReadings houseW float64 housePhaseA [3]float64 carMaxW float64 // the car takes at most this carPaused, chargerIgnores bool + meterSilent bool + loneZeroAt time.Time // one 0 W reading at this time appliedW, evW float64 reportedW float64 reportedAt, nextPoll time.Time + meterAt time.Time commands, draws []evTimed offers []evTimed // every ev_set_current maxGridW float64 } -const evMeterSteps = "0,4140,4830,5520,6210,6900,7590,8280,8970,9660,10350" - func newEVMeterSite(t *testing.T, ceilingW, houseW float64) *evMeterSite { t.Helper() s := &evMeterSite{t: t, now: time.Now().Truncate(time.Second), store: telemetry.NewStore(), @@ -103,8 +115,8 @@ func (s *evMeterSite) phaseA() [3]float64 { return a } -func (s *evMeterSite) emit(driver string, kind telemetry.DerType, w float64, extra map[string]any, at time.Time) { - d := map[string]any{"control_power_available": true, "control_power_observed_at": at.Format(time.RFC3339Nano)} +func (s *evMeterSite) emit(driver string, kind telemetry.DerType, w float64, extra map[string]any) { + d := map[string]any{"control_power_available": true, "control_power_observed_at": s.now.Format(time.RFC3339Nano)} for k, v := range extra { d[k] = v } @@ -117,7 +129,7 @@ func (s *evMeterSite) emit(driver string, kind telemetry.DerType, w float64, ext // report delivers one poll. The receipt time is all the driver knows. func (s *evMeterSite) report(w float64) { s.reportedW, s.reportedAt = w, s.now - s.emit("easee", telemetry.DerEV, w, nil, s.now) + s.emit("easee", telemetry.DerEV, w, nil) } func (s *evMeterSite) cloudW() float64 { @@ -146,15 +158,25 @@ func (s *evMeterSite) step() { s.evW = draw s.draws = append(s.draws, evTimed{s.now, draw}) } - if !s.now.Before(s.nextPoll) { - s.report(s.cloudW()) + if !s.now.Before(s.nextPoll) && s.readings != evReadingsMissing { + w := s.cloudW() + if s.readings == evReadingsHigh { + w += 2000 + } + s.report(w) s.nextPoll = s.now.Add(5 * time.Second) } - a := s.phaseA() + if s.now.Equal(s.loneZeroAt) { + s.report(0) + } grid := s.houseW + s.evW s.maxGridW = math.Max(s.maxGridW, grid) - s.emit("meter", telemetry.DerMeter, grid, map[string]any{"l1_a": a[0], "l2_a": a[1], "l3_a": a[2]}, s.now) - s.ctrl.TickWithDispatch(context.Background(), s.now, true) + if !s.meterSilent { + a := s.phaseA() + 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 + } + s.ctrl.TickWithDispatch(context.Background(), s.now, s.now.Sub(s.meterAt) <= time.Minute) s.state.SetEVFuseDemands(s.ctrl.FuseDemands()) } @@ -171,22 +193,30 @@ func (s *evMeterSite) offer() float64 { return s.offers[len(s.offers)-1].w } -func (s *evMeterSite) offersSince(at time.Time) []float64 { - var out []float64 +func (s *evMeterSite) offersSince(at time.Time) []evTimed { + var out []evTimed for _, o := range s.offers { if !o.at.Before(at) { - out = append(out, o.w) + out = append(out, o) } } return out } -// steady brings the car to a constant draw with the telemetry caught up. +func offerWatts(offers []evTimed) []float64 { + out := make([]float64, len(offers)) + for i, o := range offers { + out[i] = o.w + } + return out +} + +// steady brings the car to a constant draw. func (s *evMeterSite) steady(wantOffer float64) { s.t.Helper() s.run(3 * time.Minute) - if s.offer() != wantOffer || s.evW != wantOffer || s.reportedW != wantOffer { - s.t.Fatalf("setup: offer %.0f draw %.0f reported %.0f, want %.0f W", s.offer(), s.evW, s.reportedW, wantOffer) + if s.offer() != wantOffer || s.evW != wantOffer { + s.t.Fatalf("setup: offer %.0f draw %.0f, want %.0f W", s.offer(), s.evW, wantOffer) } } @@ -215,16 +245,17 @@ func TestEVHardLimitHoldsOfferThroughLoneZeroSample(t *testing.T) { if capW < 10350 { t.Fatalf("lone 0 W sample cut the hard cap to %.1f W", capW) } - for _, w := range s.offersSince(zeroAt) { + for _, w := range offerWatts(s.offersSince(zeroAt)) { if w != 10350 { - t.Fatalf("offer left 10,350 W after a lone 0 W sample: %v", s.offersSince(zeroAt)) + t.Fatalf("offer left 10,350 W after a lone 0 W sample: %v", offerWatts(s.offersSince(zeroAt))) } } } // #1674, 23:45: a 7.5 kW import ceiling, the car at 9 A beside 0.9 kW of // house load, and Easee telemetry 5-15 s late. The charge started and -// stopped every 30 s. It must start once and stay inside the ceiling. +// stopped every 30 s. It must start once, keep a step of margin and stay +// inside the ceiling. func TestEVHardLimitStartsOnceUnderImportCeilingWithLateTelemetry(t *testing.T) { for _, delay := range []time.Duration{5 * time.Second, 10 * time.Second, 15 * time.Second} { t.Run(delay.String(), func(t *testing.T) { @@ -236,11 +267,12 @@ func TestEVHardLimitStartsOnceUnderImportCeilingWithLateTelemetry(t *testing.T) if o.w > 0 { started = true } else if started { - t.Fatalf("start/stop cycle: offers %v", s.offersSince(time.Time{})) + t.Fatalf("start/stop cycle: offers %v", offerWatts(s.offers)) } } - if !started || s.offer() != 6210 { - t.Fatalf("offer %.0f W, want a steady 6,210 W", s.offer()) + // 7,500 - 900 W leaves 6,600 W; one 690 W step stays free. + if !started || s.offer() != 5520 { + t.Fatalf("offer %.0f W, want a steady 5,520 W", s.offer()) } if s.maxGridW > 7500 { t.Fatalf("grid reached %.0f W above the 7,500 W ceiling", s.maxGridW) @@ -253,17 +285,17 @@ func TestEVHardLimitStartsOnceUnderImportCeilingWithLateTelemetry(t *testing.T) // The offer steps down by the measured overage, once, and never stops. func TestEVHardLimitHouseStepStepsDownOnce(t *testing.T) { s := newEVMeterSite(t, 7500, 900) - s.steady(6210) + s.steady(5520) stepAt := s.now - s.setHouse(1940) // 1,940 + 6,210 = 8,150 W + s.setHouse(2630) // 2,630 + 5,520 = 8,150 W s.run(evSettle + 10*time.Second) - got := s.offersSince(stepAt) + got := offerWatts(s.offersSince(stepAt)) for _, w := range got { - if w != 6210 && w != 5520 { - t.Fatalf("house step gave offers %v, want one step to 5,520 W", got) + if w != 5520 && w != 4830 { + t.Fatalf("house step gave offers %v, want one step to 4,830 W", got) } } - if s.offer() != 5520 || s.houseW+s.evW > 7500 { + if s.offer() != 4830 || s.houseW+s.evW > 7500 { t.Fatalf("offer %.0f W grid %.0f W after the step", s.offer(), s.houseW+s.evW) } } @@ -272,13 +304,13 @@ func TestEVHardLimitHouseStepStepsDownOnce(t *testing.T) { // car resumes. Nothing stopped it while paused; the next meter reading cuts. func TestEVHardLimitCutsOnNextReadingWhenCarResumesIntoHouseLoad(t *testing.T) { s := newEVMeterSite(t, 7500, 900) - s.steady(6210) + s.steady(5520) s.carPaused = true s.run(30 * time.Second) s.setHouse(5000) s.run(30 * time.Second) offered := s.offer() - if offered < 6210 { + if offered < 5520 { t.Fatalf("paused car lost its offer: %.0f W", offered) } s.carPaused = false @@ -288,53 +320,174 @@ func TestEVHardLimitCutsOnNextReadingWhenCarResumesIntoHouseLoad(t *testing.T) { } } -// After a cut the cap rises one step per settle time, and only while the -// car takes what it was offered. A paused car does not wind it up. -func TestEVHardLimitRisesOneSeenStepAtATime(t *testing.T) { - s := newEVMeterSite(t, 7500, 900) - s.steady(6210) - s.carPaused = true - s.run(s.telemetryDelay + 5*time.Second) // The cloud now reports 0 W. - pausedOffer := s.offer() - s.setHouse(2600) // Room for the paused car only: no cut. - s.run(2 * time.Minute) - if s.offer() != pausedOffer { - t.Fatalf("cap moved %.0f -> %.0f W while the car reported 0 W", pausedOffer, s.offer()) - } - s.carPaused = false - s.run(time.Minute) // 2,600 + 6,210 = 8,810 W: cut to 4,830 W - if s.offer() != 4830 { - t.Fatalf("offer %.0f W after a 1,310 W overage", s.offer()) - } - s.setHouse(900) - recoveredAt := s.now - s.run(3 * time.Minute) - got := s.offersSince(recoveredAt) - for i := 1; i < len(got); i++ { - if got[i]-got[i-1] > 690 { - t.Fatalf("cap rose more than one step at once: %v", got) +// After a cut the cap rises one step at a time. The next rise follows as +// soon as the meter shows import rising with the last one; a car that does +// not follow gets one step per settle time at most. +func TestEVHardLimitRiseWaitsForMeterOrDwell(t *testing.T) { + for _, paused := range []bool{false, true} { + s := newEVMeterSite(t, 7500, 900) + s.steady(5520) + s.setHouse(2900) // 920 W over: cut to 4,140 W + s.run(10 * time.Second) + if s.offer() != 4140 { + t.Fatalf("offer %.0f W after a 920 W overage", s.offer()) + } + s.setHouse(900) + s.carPaused = paused + recoveredAt := s.now + s.run(2 * time.Minute) + var rises []evTimed + got := s.offersSince(recoveredAt) + for i := 1; i < len(got); i++ { + if got[i].w-got[i-1].w > 690 { + t.Fatalf("paused=%v: rose more than one step at once: %v", paused, offerWatts(got)) + } + if got[i].w > got[i-1].w { + rises = append(rises, got[i]) + } + } + if len(rises) < 2 { + t.Fatalf("paused=%v: rises %v, want at least two", paused, offerWatts(got)) + } + gap := rises[1].at.Sub(rises[0].at) + if !paused && gap >= evSettle { + t.Fatalf("a rise the meter confirmed still waited %v", gap) + } + if paused && gap < evSettle { + t.Fatalf("an unconfirmed rise followed after %v", gap) + } + if !paused && (s.offer() != 5520 || s.houseW+s.evW > 7500-690) { + t.Fatalf("recovered to %.0f W at %.0f W grid, want 5,520 W with a step of margin", s.offer(), s.houseW+s.evW) } - } - if s.offer() != 6210 || s.houseW+s.evW > 7500 { - t.Fatalf("recovered to %.0f W at %.0f W grid", s.offer(), s.houseW+s.evW) } } -// A charger that ignores a cut still gets the next one once its old -// current should have reached the meter. +// A charger that ignores a cut gets the next one once its old current +// should have reached the meter. func TestEVHardLimitCutsAgainAfterSettleWhenChargerIgnores(t *testing.T) { s := newEVMeterSite(t, 7500, 900) - s.steady(6210) + s.steady(5520) s.chargerIgnores = true stepAt := s.now - s.setHouse(1940) + s.setHouse(2630) s.run(evSettle - 2*time.Second) - if s.offer() != 5520 { - t.Fatalf("offers %v, want one cut within the settle time", s.offersSince(stepAt)) + if s.offer() != 4830 { + t.Fatalf("offers %v, want one cut within the settle time", offerWatts(s.offersSince(stepAt))) } s.run(4 * time.Second) - if s.offer() >= 5520 { - t.Fatalf("ignored cut was not repeated: %v", s.offersSince(stepAt)) + if s.offer() >= 4830 { + t.Fatalf("ignored cut was not repeated: %v", offerWatts(s.offersSince(stepAt))) + } +} + +// The charger's readings never move the offer: missing for the whole run, +// late by 15 s during a start, above what we offered, or a lone 0 W. +func TestEVHardLimitOffersIgnoreChargerReadings(t *testing.T) { + scenario := func(configure func(*evMeterSite)) []float64 { + s := newEVMeterSite(t, 7500, 900) + configure(s) + s.run(90 * time.Second) // start + s.setHouse(2630) // cut + s.run(time.Minute) + s.setHouse(900) // recover + s.run(2 * time.Minute) + return offerWatts(s.offers) + } + want := scenario(func(*evMeterSite) {}) + for name, configure := range map[string]func(*evMeterSite){ + "missing": func(s *evMeterSite) { s.readings = evReadingsMissing }, + "late_15s": func(s *evMeterSite) { s.telemetryDelay = 15 * time.Second }, + "above_offer": func(s *evMeterSite) { s.readings = evReadingsHigh }, + "lone_zero": func(s *evMeterSite) { s.loneZeroAt = s.now.Add(70 * time.Second) }, + } { + got := scenario(configure) + if len(got) != len(want) { + t.Fatalf("%s: offers %v, want %v", name, got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("%s: offers %v, want %v", name, got, want) + } + } + } +} + +// Easee power missing for 60 s mid-charge leaves the offer alone. +func TestEVHardLimitHoldsThroughMissingChargerPower(t *testing.T) { + s := newEVMeterSite(t, 7500, 900) + s.steady(5520) + from := s.now + s.readings = evReadingsMissing + s.run(time.Minute) + s.readings = evReadingsNormal + s.run(30 * time.Second) + for _, w := range offerWatts(s.offersSince(from)) { + if w != 5520 { + t.Fatalf("missing charger power moved the offer: %v", offerWatts(s.offersSince(from))) + } + } +} + +// A silent meter repeats its last reading. Nothing may rise on it, even +// when that reading showed room and the settle time has long passed. +func TestEVHardLimitNeverRisesOnSilentMeter(t *testing.T) { + s := newEVMeterSite(t, 7500, 900) + s.steady(5520) + s.meterSilent = true + s.setHouse(0) // 1,980 W of room the meter never shows + from := s.now + s.run(50 * time.Second) // under the one-minute dispatch gate + for _, w := range offerWatts(s.offersSince(from)) { + if w != 5520 { + t.Fatalf("offer moved on a silent meter: %v", offerWatts(s.offersSince(from))) + } + } +} + +// A meter gap past the dispatch gate stops the charge. When the meter +// returns, the car restarts only after the meter has kept showing room for +// the restart dwell. +func TestEVHardLimitRestartsAfterMeterGapOnlyOnSustainedRoom(t *testing.T) { + s := newEVMeterSite(t, 7500, 900) + s.steady(5520) + s.meterSilent = true + s.run(70 * time.Second) + if s.offer() != 0 { + t.Fatalf("stale meter left offer %.0f W", s.offer()) + } + s.meterSilent = false + backAt := s.now + s.run(2 * time.Minute) + var restartAt time.Time + for _, o := range s.offersSince(backAt) { + if o.w > 0 { + restartAt = o.at + break + } + } + if restartAt.IsZero() || restartAt.Sub(backAt) < evRestartDwell { + t.Fatalf("restart %v after the meter returned, want at least %v", restartAt.Sub(backAt), evRestartDwell) + } + if s.maxGridW > 7500 { + t.Fatalf("grid reached %.0f W", s.maxGridW) + } +} + +// Our own battery order the meter may not show yet blocks a rise, but +// never cuts the car. +func TestEVHardLimitBatteryOrderBlocksRiseNeverCuts(t *testing.T) { + s := newEVMeterSite(t, 7500, 900) + s.steady(5520) + s.setHouse(0) // room for a rise + from := s.now + for range 60 { + s.state.batteryCommands = map[string]commandLog{"pixii": commandLog{}.add(s.now.Add(-time.Second), 0).add(s.now, 3000)} + s.step() + } + for _, w := range offerWatts(s.offersSince(from)) { + if w != 5520 { + t.Fatalf("pending battery order moved the offer: %v", offerWatts(s.offersSince(from))) + } } } @@ -355,7 +508,7 @@ func evMeterBudgetFixture(t *testing.T, gridW float64, phaseA []float64, offerW return store, state, points, configs } -// A known 3p car at 15 A beside 11 A of house load on L1: 26 A on a 24 A +// A 3p car offered 15 A beside 11 A of house load on L1: 26 A on a 24 A // working limit. Two amps on three phases come off the offer at once. func TestEVHardLimitTrimsPhaseOverageFromOffer(t *testing.T) { store, state, points, configs := evMeterBudgetFixture(t, 8280, []float64{26, 20, 20}, 10350, "3p") @@ -378,18 +531,18 @@ func TestEVHardLimitStopsOnlyWhenOverageExceedsOfferLessMinimum(t *testing.T) { } } -// An idle car starts only with room for its minimum and a margin step, and -// a single-phase or auto car may land its whole offer on the worst phase. +// An idle car starts with a step of margin under the measured room, and a +// single-phase or auto car may land its whole offer on the worst phase. func TestEVHardLimitIdleStartNeedsRoomOnDeclaredPhases(t *testing.T) { for _, tc := range []struct { name, phase string worstA float64 want float64 }{ - {"3p_room", "3p", 5, (24 - 5) * 690}, - {"legacy_default", "", 5, (24 - 5) * 690}, - {"1p_room", "1p", 5, (24 - 5) * 230}, - {"auto_room", "auto", 5, (24 - 5) * 230}, + {"3p_room", "3p", 5, (24 - 5 - 1) * 690}, + {"legacy_default", "", 5, (24 - 5 - 1) * 690}, + {"1p_room", "1p", 5, (24 - 5 - 1) * 230}, + {"auto_room", "auto", 5, (24 - 5 - 1) * 230}, {"3p_below_minimum", "3p", 21.5, 0}, // 1,725 W < 1,380 + 690 W } { t.Run(tc.name, func(t *testing.T) { @@ -402,9 +555,10 @@ func TestEVHardLimitIdleStartNeedsRoomOnDeclaredPhases(t *testing.T) { } } -// Missing or unreadable EV telemetry is not a reason to cut, and before the -// first command a fresh reading bounds what the meter contains. -func TestEVHardLimitIgnoresEVTelemetryForCuts(t *testing.T) { +// A lone 0 W or unavailable charger reading is not a reason to cut, and +// before our first command a charging car's reading earns no credit: the +// meter alone decides. +func TestEVHardLimitIgnoresChargerReadings(t *testing.T) { for name, samples := range map[string][]string{ "lone_zero": {`{"control_power_available":true}`, `{"control_power_available":true}`}, "unavailable": {`{"control_power_available":false}`}, @@ -426,7 +580,7 @@ func TestEVHardLimitIgnoresEVTelemetryForCuts(t *testing.T) { points[0].CommandedKnown = false store.Update("charger", telemetry.DerEV, 10242, nil, nil) store.RecordDriverSuccess("charger") - if got, _ := EVFuseBudget(store, state, points, configs, 17250); got < 10242 { - t.Fatalf("restart with the car charging cut it to %.1f W", got) + if got, _ := EVFuseBudget(store, state, points, configs, 17250); got > (24-20.45-1)*690+.01 { + t.Fatalf("a car reading earned %.1f W before our first command", got) } } diff --git a/go/internal/control/ev_pending_battery.go b/go/internal/control/ev_pending_battery.go deleted file mode 100644 index 8887f65a7..000000000 --- a/go/internal/control/ev_pending_battery.go +++ /dev/null @@ -1,87 +0,0 @@ -package control - -import ( - "math" - "time" - - "github.com/srcfl/ftw/go/internal/telemetry" -) - -type pendingBatterySeries struct { - upperW, fallbackW float64 - current telemetry.ControlObservation - fresh bool - points []telemetry.ControlObservation -} - -// batteryOrderUpperW keeps the same order bound before EV and battery sends. -// PrevTargets covers a computed offer before the driver's call begins; once -// command evidence exists it owns the outstanding order, including releases. -func batteryOrderUpperW(store *telemetry.Store, state *State, driver string) (float64, bool) { - offer, offered := state.PrevTargets[driver] - if !offered { - return store.BatteryCommandUpperW(driver) - } - var at time.Time - if state.LastDispatch != nil { - at = *state.LastDispatch - } - if upper, sent := store.BatteryCommandUpperW(driver, telemetry.CommandPoint{PowerW: offer, At: at}); sent { - return upper, true - } - return offer, offered -} - -func pendingBatterySeriesForFuse(store *telemetry.Store, state *State, windows map[string]telemetry.ControlBaseline, now time.Time) []pendingBatterySeries { - drivers := make(map[string]bool) - for driver := range state.DriverLimits { - drivers[driver] = true - } - for _, reading := range store.ReadingsByType(telemetry.DerBattery) { - drivers[reading.Driver] = true - } - var series []pendingBatterySeries - for driver := range drivers { - upper, known := batteryOrderUpperW(store, state, driver) - reading := store.Get(driver, telemetry.DerBattery) - current, fresh := qualifiedFuseObservation(reading, now) - if fresh { - if known { - upper = math.Max(upper, current.PowerW) - } else { - upper = current.PowerW - } - } else if !known { - continue - } - // A measured increase can precede the site poll even when FTW - // sent no increase. Keep it in the same AC domain as the baseline. - // Missing battery data cannot promise a positive baseline. The - // configured discharge bound also covers an unobserved discharge - // that a pending zero/charge order could stop. - series = append(series, pendingBatterySeries{upperW: upper, fallbackW: -state.DriverLimits[driver].dischargeCap(), - current: current, fresh: fresh, points: append(windows[driver+":"+telemetry.DerBattery.String()].Points, current)}) - } - return series -} - -// A first battery sample after the site read cannot show how much earlier -// discharge the meter contains; it keeps the full fallback. -func pendingBatteryIncreaseAtLatestSite(series []pendingBatterySeries, siteAt time.Time) float64 { - var reserve float64 - for _, battery := range series { - baseline := battery.fallbackW - if battery.fresh && !battery.current.Seen().After(siteAt) { - baseline = battery.current.PowerW - } else if battery.fresh { - if value, ok := bracketedBatteryPower(battery.points, siteAt, battery.current.PowerW); ok { - baseline = value - } - } - if !finiteFuseValue(battery.upperW) || !finiteFuseValue(baseline) { - return math.Inf(1) - } - reserve += math.Max(0, battery.upperW-baseline) - } - return reserve -} diff --git a/go/internal/control/ev_pending_battery_test.go b/go/internal/control/ev_pending_battery_test.go deleted file mode 100644 index 91f44581d..000000000 --- a/go/internal/control/ev_pending_battery_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package control - -import ( - "encoding/json" - "fmt" - "math" - "math/rand" - "testing" - "time" - - "github.com/srcfl/ftw/go/internal/loadpoint" - "github.com/srcfl/ftw/go/internal/telemetry" -) - -func pendingEVFixture(t *testing.T, house, battery, ev, rawBattery float64) (*telemetry.Store, *State, time.Time) { - t.Helper() - now, store := time.Now(), telemetry.NewStore() - emit := func(driver string, kind telemetry.DerType, at time.Time, raw, ac float64) { - d := map[string]any{"control_power_w": ac, "control_power_observed_at": at.Format(time.RFC3339Nano)} - if kind == telemetry.DerMeter { - amps := math.Abs(ac) / 690 - d["l1_a"], d["l2_a"], d["l3_a"] = amps, amps, amps - } - data, _ := json.Marshal(d) - soc := .5 - store.Update(driver, kind, raw, &soc, data) - store.RecordDriverSuccess(driver) - } - emit("battery", telemetry.DerBattery, now.Add(-8*time.Second), rawBattery, battery) - emit("car", telemetry.DerEV, now.Add(-8*time.Second), ev, ev) - emit("meter", telemetry.DerMeter, now.Add(-7*time.Second), house+battery+ev, house+battery+ev) - emit("battery", telemetry.DerBattery, now.Add(-6*time.Second), rawBattery, battery) - emit("car", telemetry.DerEV, now.Add(-6*time.Second), ev, ev) - state := NewState(0, 0, "meter") - state.SiteFuseAmps, state.SiteFuseVoltage, state.SiteFusePhases, state.SiteFuseSafetyA = 25, 230, 3, 1 - state.DriverLimits = map[string]PowerLimits{"battery": {MaxChargeW: 10000, MaxDischargeW: 10000}} - return store, state, now -} - -func pendingEVOrder(store *telemetry.Store, watts float64, at time.Time, result string) { - c := store.BeginCommand("battery", []byte(fmt.Sprintf(`{"action":"battery","power_w":%g}`, watts)), at) - store.CompleteCommand(c, result) -} - -func pendingEVBudget(store *telemetry.Store, state *State) float64 { - cap, _ := EVFuseBudget(store, state, []loadpoint.State{{ID: "car", DriverName: "car", PluggedIn: true}}, - []loadpoint.Config{{ID: "car", DriverName: "car", PhaseMode: "3p"}}, 17250) - return cap -} - -func TestEVPendingBatteryCannotSpendAnUnmeasuredOrder(t *testing.T) { - for _, lower := range []bool{false, true} { - for _, result := range []string{"accepted", "failed", "unknown"} { - t.Run(fmt.Sprintf("lower=%v/%s", lower, result), func(t *testing.T) { - store, state, now := pendingEVFixture(t, 5000, 0, 3000, 0) - pendingEVOrder(store, 8000, now.Add(-4*time.Second), result) - state.PrevTargets["battery"] = 8000 - if lower { - pendingEVOrder(store, 0, now.Add(-3*time.Second), "accepted") - state.PrevTargets["battery"] = 0 - } - got := pendingEVBudget(store, state) - const want = 16560 - 5000 - 8000 - if math.Abs(got-want) > .01 { - t.Fatalf("EV cap %.1fW; need %.1fW before 8kW battery order can arrive", got, float64(want)) - } - if grid := 5000 + 8000 + got; grid > 16560.01 { - t.Fatalf("pending battery produces %.1fW physical import", grid) - } - }) - } - } -} - -func TestEVPendingBatteryReservesDischargeLossAndUsesAC(t *testing.T) { - // Site includes -5kW AC discharge. Its DC reading is deliberately different. - store, state, now := pendingEVFixture(t, 10000, -5000, 3000, -300) - pendingEVOrder(store, 0, now.Add(-4*time.Second), "accepted") - got := pendingEVBudget(store, state) - if want := 16560.0 - 10000; math.Abs(got-want) > .01 { - t.Fatalf("loss of AC discharge not reserved: cap %.1fW want %.1fW", got, want) - } -} - -func TestEVPendingBatteryCannotUseLaterPowerAsReplacementCredit(t *testing.T) { - store, state, now := pendingEVFixture(t, 5000, 0, 3000, 0) - pendingEVOrder(store, 8000, now.Add(-4*time.Second), "accepted") - // Battery feedback is newer than the site and has no matching site sample. - data, _ := json.Marshal(map[string]any{"control_power_observed_at": now.Add(-time.Second).Format(time.RFC3339Nano)}) - soc := .5 - store.Update("battery", telemetry.DerBattery, 8000, &soc, data) - if got := pendingEVBudget(store, state); math.Abs(got-3560) > .01 { - t.Fatalf("newer battery reading financed an EV rise: cap %.1fW", got) - } -} - -func TestEVPendingBatteryRecoveryIgnoresOldAllocatorCapButKeepsPeak(t *testing.T) { - store, state, _ := pendingEVFixture(t, 2000, 0, 0, 0) - state.FuseSaturated, state.FuseEVMaxW = true, 0 - if got := pendingEVBudget(store, state); math.Abs(got-14560) > .01 { - t.Fatalf("old allocator cap blocked measured room: %.1fW", got) - } - state.PeakImportCeilingW = 6000 - if got := pendingEVBudget(store, state); math.Abs(got-4000) > .01 { - t.Fatalf("current tariff peak lost: %.1fW", got) - } -} - -func TestEVPendingBatteryMissingPowerCannotInventRoom(t *testing.T) { - store, state, now := pendingEVFixture(t, 5000, 0, 3000, 0) - pendingEVOrder(store, 8000, now.Add(-4*time.Second), "accepted") - data := []byte(`{"control_power_available":false}`) - store.Update("battery", telemetry.DerBattery, 0, nil, data) - // The meter alone cuts; the car keeps its 3 kW but cannot rise. - if got := pendingEVBudget(store, state); got > 3000.01 { - t.Fatalf("missing battery power earned %.1fW EV room", got) - } -} - -func TestEVPendingBatteryReservesComputedOfferWaitingForActor(t *testing.T) { - store, state, now := pendingEVFixture(t, 5000, 0, 3000, 0) - pendingEVOrder(store, 0, now.Add(-5*time.Second), "accepted") - offeredAt := now.Add(-4 * time.Second) - state.PrevTargets["battery"], state.LastDispatch = 8000, &offeredAt - if got := pendingEVBudget(store, state); math.Abs(got-3560) > .01 { - t.Fatalf("computed battery offer waiting for actor funded %.1fW EV", got) - } -} - -func TestEVPendingBatteryFirstSampleWaitsForSiteCorrespondence(t *testing.T) { - store, state, now := pendingEVFixture(t, 10000, -5000, 3000, -5000) - // Restart loses the battery history. The next zero reading cannot tell - // us how much discharge the older 8kW site sample still contains. - store.Remove("battery") - pendingEVOrder(store, 0, now.Add(-4*time.Second), "accepted") - emit := func(driver string, kind telemetry.DerType, at time.Time, watts float64) { - data, _ := json.Marshal(map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano)}) - soc := .5 - store.Update(driver, kind, watts, &soc, data) - store.RecordDriverSuccess(driver) - } - emit("battery", telemetry.DerBattery, now.Add(-3*time.Second), 0) - if got := pendingEVBudget(store, state); got > 3000.01 { - t.Fatalf("unknown earlier discharge financed %.1fW EV room", got) - } - // The next bracket proves the new balance; the cap then rises one step - // once the last change has had time to reach the meter. - amps := 13000.0 / 690 - data, _ := json.Marshal(map[string]any{"control_power_observed_at": now.Add(-2 * time.Second).Format(time.RFC3339Nano), - "l1_a": amps, "l2_a": amps, "l3_a": amps}) - store.Update("meter", telemetry.DerMeter, 13000, nil, data) - emit("battery", telemetry.DerBattery, now.Add(-time.Second), 0) - emit("car", telemetry.DerEV, now.Add(-time.Second), 3000) - state.evLimit.changedAt = now.Add(-evSettle) - if got := pendingEVBudget(store, state); math.Abs(got-3690) > .01 { - t.Fatalf("matching fresh readings did not restore room: %.1fW", got) - } -} - -func TestEVPendingBatteryOrderArrivalKeepsPhysicalImportBounded(t *testing.T) { - rng := rand.New(rand.NewSource(8000)) - for i := 0; i < 200; i++ { - house := 1000 + rng.Float64()*6000 - actual := -5000 + rng.Float64()*10000 - target := -5000 + rng.Float64()*15000 - if house+math.Max(actual, target) > 16560 { - continue // The base load alone is already impossible to fix with EV. - } - store, state, now := pendingEVFixture(t, house, actual, 3000, actual) - pendingEVOrder(store, target, now.Add(-4*time.Second), "accepted") - // The car already draws 3 kW. Only the meter may cut it; a pending - // order must not fund a rise. - ev := pendingEVBudget(store, state) - if grid := house + math.Max(actual, target) + ev; ev > 3000.01 && grid > 16560.01 { - t.Fatalf("case %d: late signed order gives %.1fW import; house %.1f actual %.1f target %.1f EV %.1f", i, grid, house, actual, target, ev) - } - } -} - -func TestEVPendingBatterySteadyEarlierPollDoesNotBlockEVStart(t *testing.T) { - now, store := time.Now(), telemetry.NewStore() - state := NewState(0, 0, "meter") - state.SiteFuseAmps, state.SiteFuseVoltage, state.SiteFusePhases, state.SiteFuseSafetyA = 25, 230, 3, 1 - state.DriverLimits = map[string]PowerLimits{"battery": {MaxChargeW: 10000, MaxDischargeW: 10000}} - pendingEVOrder(store, 8000, now.Add(-19*time.Second), "accepted") - emit := func(driver string, kind telemetry.DerType, at time.Time, watts float64) { - d := map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano)} - if kind == telemetry.DerMeter { - amps := watts / 690 - d["l1_a"], d["l2_a"], d["l3_a"] = amps, amps, amps - } - data, _ := json.Marshal(d) - soc := .5 - store.Update(driver, kind, watts, &soc, data) - store.RecordDriverSuccess(driver) - } - for i := 0; i < 6; i++ { - at := now.Add(-8*time.Second + time.Duration(i)*1250*time.Millisecond) - // Every battery poll precedes its site poll. No latest site sample - // ever has an after bracket, but the preceding site sample does. - emit("battery", telemetry.DerBattery, at, 8000) - emit("car", telemetry.DerEV, at, 0) - emit("meter", telemetry.DerMeter, at.Add(250*time.Millisecond), 9000) - if got := pendingEVBudget(store, state); i > 0 && math.Abs(got-7560) > .01 { - t.Fatalf("tick %d: steady earlier battery poll blocked EV start: %.1fW", i, got) - } - } - // A newer house-load increase still wins over the older common sample. - emit("meter", telemetry.DerMeter, now.Add(-100*time.Millisecond), 14000) - if got := pendingEVBudget(store, state); got > 2560.01 { - t.Fatalf("older coherent site hid newer load: %.1fW", got) - } -} - -func TestEVPendingBatteryObservedStopCannotDisappearFromCredit(t *testing.T) { - store := telemetry.NewStore() - at := time.Now().Truncate(time.Second).Add(-2 * time.Second) - const house = 5000.0 - emit := func(driver string, kind telemetry.DerType, sourceAt time.Time, w float64) { - d := map[string]any{"control_power_observed_at": sourceAt.Format(time.RFC3339Nano)} - if kind == telemetry.DerMeter { - a := w / 690 - d["l1_a"], d["l2_a"], d["l3_a"] = a, a, a - } - data, _ := json.Marshal(d) - soc := .5 - store.Update(driver, kind, w, &soc, data) - store.RecordDriverSuccess(driver) - } - pendingEVOrder(store, 8000, at.Add(-time.Second), "accepted") - emit("battery", telemetry.DerBattery, at.Add(-100*time.Millisecond), 8000) - emit("battery", telemetry.DerBattery, at.Add(100*time.Millisecond), 0) - emit("car", telemetry.DerEV, at.Add(400*time.Millisecond), 0) - emit("meter", telemetry.DerMeter, at.Add(500*time.Millisecond), house) - emit("battery", telemetry.DerBattery, at.Add(900*time.Millisecond), 8000) - state := NewState(0, 0, "meter") - state.SiteFuseAmps, state.SiteFuseVoltage, state.SiteFusePhases, state.SiteFuseSafetyA = 25, 230, 3, 1 - state.DriverLimits = map[string]PowerLimits{"battery": {MaxChargeW: 10000, MaxDischargeW: 10000}} - capW := pendingEVBudget(store, state) - windows := store.ControlWindows(time.Now().Add(-20*time.Second), time.Now()) - t.Logf("battery points=%+v cap=%.0fW latest battery=8000W site snapshot=5000W actual import after offer=%.0fW", windows["battery:battery"].Points, capW, house+8000+capW) - if total := house + 8000 + capW; total > 16560.01 { - t.Fatalf("observed zero lost from battery credit: actual import %.0fW, cap %.0fW (safe <=3560W)", total, capW) - } -} - -func TestEVPendingBatteryNewMeasuredACPowerBeforeSiteMustBeReserved(t *testing.T) { - for _, ordered := range []bool{false, true} { - for _, tc := range []struct { - name string - house, before, after, dcRaw float64 - wantCap float64 - }{ - {"new_charge", 5000, 0, 8000, 2000, 3560}, - {"lost_discharge", 10000, -5000, 0, -300, 6560}, - } { - t.Run(fmt.Sprintf("%s/order=%v", tc.name, ordered), func(t *testing.T) { - store, state, now := pendingEVFixture(t, tc.house, tc.before, 3000, tc.before) - if ordered { - pendingEVOrder(store, 0, now.Add(-10*time.Second), "accepted") - } - data, _ := json.Marshal(map[string]any{"control_power_w": tc.after, - "control_power_observed_at": now.Add(-time.Second).Format(time.RFC3339Nano)}) - soc := .5 - store.Update("battery", telemetry.DerBattery, tc.dcRaw, &soc, data) - cap := pendingEVBudget(store, state) - if math.Abs(cap-tc.wantCap) > .01 { - t.Fatalf("new measured AC power missing from site balance: cap%.1f want%.1f", cap, tc.wantCap) - } - if grid := tc.house + tc.after + cap; grid > 16560.01 { - t.Fatalf("observed AC change gives %.1fW physical import", grid) - } - }) - } - } -} - -func TestEVPendingBatteryMeasuredRiseDoesNotCountTwiceAcrossPolls(t *testing.T) { - now, store := time.Now(), telemetry.NewStore() - state := NewState(0, 0, "meter") - state.SiteFuseAmps, state.SiteFuseVoltage, state.SiteFusePhases, state.SiteFuseSafetyA = 25, 230, 3, 1 - state.DriverLimits = map[string]PowerLimits{"battery": {MaxChargeW: 10000, MaxDischargeW: 10000}} - emit := func(driver string, kind telemetry.DerType, at time.Time, watts float64) { - d := map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano)} - if kind == telemetry.DerMeter { - d["l1_a"], d["l2_a"], d["l3_a"] = watts/690, watts/690, watts/690 - } - data, _ := json.Marshal(d) - soc := .5 - store.Update(driver, kind, watts, &soc, data) - store.RecordDriverSuccess(driver) - } - start := now.Add(-8 * time.Second) - emit("battery", telemetry.DerBattery, start, 0) - emit("car", telemetry.DerEV, start, 10350) - emit("meter", telemetry.DerMeter, start.Add(250*time.Millisecond), 15350) - emit("battery", telemetry.DerBattery, start.Add(500*time.Millisecond), 0) - emit("car", telemetry.DerEV, start.Add(500*time.Millisecond), 10350) - pendingEVOrder(store, 1210, start.Add(750*time.Millisecond), "accepted") - for tick := range 6 { - at := start.Add(time.Duration(tick+1) * time.Second) - emit("battery", telemetry.DerBattery, at, 1210) - emit("car", telemetry.DerEV, at, 10350) - emit("meter", telemetry.DerMeter, at.Add(250*time.Millisecond), 16560) - emit("car", telemetry.DerEV, at.Add(500*time.Millisecond), 10350) - if cap := pendingEVBudget(store, state); math.Abs(cap-10350) > .01 { - t.Fatalf("tick%d: measured battery rise counted twice, EVcap%.1f want10350; physical5000+1210+10350=16560", tick, cap) - } - } -} diff --git a/go/internal/control/testdata/golden/seeded_ev_boost.json b/go/internal/control/testdata/golden/seeded_ev_boost.json index 83492f59c..f4fd9c77b 100644 --- a/go/internal/control/testdata/golden/seeded_ev_boost.json +++ b/go/internal/control/testdata/golden/seeded_ev_boost.json @@ -3085,8 +3085,8 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": true, - "fuse_ev_max_w": 4136, + "fuse_saturated": false, + "fuse_ev_max_w": 0, "fuse_hold_latched": true, "fuse_hold_max_charge_w": 3931, "fuse_hold_max_discharge_w": 0, @@ -4529,8 +4529,8 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": true, - "fuse_ev_max_w": 7055, + "fuse_saturated": false, + "fuse_ev_max_w": 0, "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 2f3e9fb93..5bd019f2b 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -56,8 +56,8 @@ type Controller struct { tickDirectiveActive bool tickDirectiveOK bool tickDirective Directive - // offerPhase is each loadpoint's last offered phase mode (serial tick). - offerPhase map[string]string + // offerPeaks is each loadpoint's highest recent offer (serial tick). + offerPeaks map[string]offerPeak // fuseResumeTicks is owned by the serial tick. A paused charger needs // two consecutive dispatch ticks with room for a legal step. fuseResumeTicks map[string]int @@ -206,6 +206,11 @@ const phaseSwitchMinHold = 30 * time.Minute const fuseResumeSafeTicks = 2 +// CommandSettle is how long a new current may take to reach the site meter. +// Easee Cloud needs about 5-15 s (#1674). Until then the meter may still show +// an earlier, higher offer. +const CommandSettle = 20 * time.Second + // defaultPhaseSplitW mirrors loadpoint.Config.PhaseSplitW's default — // 3680 W is a 16 A 1Φ ceiling at 230 V. Kept in sync with the comment // on Config.PhaseSplitW. @@ -1562,14 +1567,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s if w, ok := cmd["power_w"].(float64); ok { offerW, haveOffer = w, true c.manager.setCommandedForManual(lpCfg.ID, w, cmdReason, manualCommandUpdatedAt) - phase, _ := cmd["phase_mode"].(string) - if phase == "" { - phase = "3p" // The driver's locked default, as in phaseModeForFuse. - } - if c.offerPhase == nil { - c.offerPhase = map[string]string{} - } - c.offerPhase[lpCfg.ID] = phase + c.recordOfferPeak(now, lpCfg.ID, w, cmd) } payload, err := json.Marshal(cmd) if err != nil { diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index 4681a7ac6..f8fcbc1eb 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -40,13 +40,11 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample st, _ := c.manager.State(cfg.ID) st.PluggedIn = sample.Connected && !sample.ConnectionUnknown request := fuseRequest{cfg: c.fuseConfig(now, cfg), deadline: st.TargetTime} - if sample.Connected && finite(sample.PowerW) { - request.baseW = math.Max(0, sample.PowerW) - } - // Keeping the current offer on the same phases is not an increase, - // even while a late or lone reading says the car draws less. - if st.PluggedIn && st.CommandedKnown && finite(st.CommandedW) && c.offerPhase[cfg.ID] == c.phaseModeForFuse(now, request.cfg) { - request.baseW = math.Max(request.baseW, st.CommandedW) + // The meter may still show a car's highest recent offer on the same + // phases. Up to that is not an increase; the car's own readings, + // late or lone, never count. + if peak := c.offerPeaks[cfg.ID]; st.PluggedIn && peak.phase == c.phaseModeForFuse(now, request.cfg) { + request.baseW = peak.w } baseTotal += request.baseW if st.PluggedIn && c.driverCanDispatch(cfg.DriverName) { @@ -91,7 +89,7 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample for _, request := range requests { target := floorChargeW(math.Min(request.wantW, remaining), request.cfg.MinChargeW, request.cfg.MaxChargeW, request.cfg.AllowedStepsW) remaining -= target - // Do not spend another car's curtailment before its meter confirms it. + // Another car's curtailment is spent only once it has settled. want := math.Min(target, request.baseW+increase) cap := floorChargeW(want, request.cfg.MinChargeW, request.cfg.MaxChargeW, request.cfg.AllowedStepsW) cap = c.recoverFuseCap(request.cfg.ID, request.wantW, cap) @@ -100,6 +98,27 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample } } +type offerPeak struct { + w float64 + phase string + at time.Time +} + +// recordOfferPeak keeps the highest offer the meter may still show. A new +// phase mode starts over: the same watts on other phases are other currents. +func (c *Controller) recordOfferPeak(now time.Time, id string, w float64, cmd map[string]any) { + phase, _ := cmd["phase_mode"].(string) + if phase == "" { + phase = "3p" // The driver's locked default, as in phaseModeForFuse. + } + if c.offerPeaks == nil { + c.offerPeaks = map[string]offerPeak{} + } + if peak := c.offerPeaks[id]; peak.phase != phase || w >= peak.w || now.Sub(peak.at) >= CommandSettle { + c.offerPeaks[id] = offerPeak{w: w, phase: phase, at: now} + } +} + func (c *Controller) fuseConfig(now time.Time, cfg Config) Config { if hold, held := c.GetManualHold(cfg.ID, now); held { cfg.MinChargeW = manualMinChargeW(cfg, hold, c.siteFuse()) diff --git a/go/internal/loadpoint/controller_fuse_phase_test.go b/go/internal/loadpoint/controller_fuse_phase_test.go index b1950a50b..1df0ce47a 100644 --- a/go/internal/loadpoint/controller_fuse_phase_test.go +++ b/go/internal/loadpoint/controller_fuse_phase_test.go @@ -159,11 +159,13 @@ func TestLoneZeroReadingKeepsOfferThroughPhaseGate(t *testing.T) { sender := &fakeSender{} c := newTestController(t, []Config{cfg}, nil, samples, sender) c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 20.45, 17.05, 15.87, true }) + l1 := 5.0 // The car starts while L1 has room for it. + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return l1, 2.2, 1.0, true }) c.SetFuseEVMax(func() (float64, bool) { return 16905, true }) c.SetManualHold(cfg.ID, ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) c.TickWithDispatch(context.Background(), now, true) - for i, w := range []float64{0, 10242} { + l1 = 20.45 + for i, w := range []float64{10242, 0, 10242} { samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true, PowerW: w} c.TickWithDispatch(context.Background(), now.Add(time.Duration(5*(i+1))*time.Second), true) if command, known := lastSetCurrent(sender.calls); !known || command.power != 10350 { diff --git a/go/internal/loadpoint/controller_fuse_recovery_test.go b/go/internal/loadpoint/controller_fuse_recovery_test.go index 0037eb686..3a9b4fdbb 100644 --- a/go/internal/loadpoint/controller_fuse_recovery_test.go +++ b/go/internal/loadpoint/controller_fuse_recovery_test.go @@ -98,12 +98,17 @@ func TestSharedPhaseBudgetDoesNotIntegrateSameOverload(t *testing.T) { c := newTestController(t, []Config{cfg}, nil, samples, sender) c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) c.SetManualHold(cfg.ID, ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) - c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 27, 20, 20, true }) - c.SetFuseEVMax(func() (float64, bool) { return 6210, true }) + // Our own 12 A offer is established before the overload; a reading + // from the car is never what the guard credits. + worstA, otherA, budgetW := 12.0, 12.0, 8280.0 + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return worstA, otherA, otherA, true }) + c.SetFuseEVMax(func() (float64, bool) { return budgetW, true }) + c.Tick(context.Background(), now.Add(-5*time.Second)) + worstA, otherA, budgetW = 27, 20, 6210 for tick := 0; tick < 12; tick++ { c.Tick(context.Background(), now.Add(time.Duration(tick)*5*time.Second)) set, ok := lastSetCurrent(sender.calls) - // Metered EV=12 A, worst site phase=27 A. A 9 A EV offer + // Offered EV=12 A, worst site phase=27 A. A 9 A EV offer // leaves that phase at 24 A, below the 25 A fuse. Re-reading // the same sample cannot justify a second accumulated trim. if !ok || set.power != 6210 { From 37e682a11a9d38e7ad7dc3bc781794fae4b2c37d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 15:34:45 +0200 Subject: [PATCH 3/3] fix(control): keep our last offer across a restart; battery holds its order Core restarts on every update. The pool counted a charging car as idle on the first tick and, at 05:49 currents (2,449.5 W of room), stopped it. FTW now saves its own last order per charger, keyed by hardware identity, and restores it as the hold base while the same plug-in session is connected: a matching session id, or a session energy counter that has not gone back, within 10 minutes. A commands-only battery counted its own repeated order twice and hunted. Keeping a battery's latest order is now never an increase; a measured shortfall takes charge back and new charge needs room after our unseen increases. A command log younger than the settle time counts the time before it as 0, so a new EV offer is not taken as already in the meter. The EV limit reads the meter once (telemetry-read check 8 -> 5). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/ev-meter-hard-limit.md | 2 +- go/internal/control/ev_battery_headroom.go | 133 +++++----------- .../control/ev_battery_headroom_test.go | 9 +- .../control/ev_battery_sequence_test.go | 149 ++++++++++++++++++ go/internal/control/ev_fuse_budget.go | 27 ++-- go/internal/control/ev_fuse_budget_test.go | 98 +++++++++++- go/internal/loadpoint/controller.go | 6 +- .../loadpoint/controller_fuse_budget.go | 3 +- .../loadpoint/controller_offer_restore.go | 98 ++++++++++++ 9 files changed, 408 insertions(+), 117 deletions(-) create mode 100644 go/internal/loadpoint/controller_offer_restore.go diff --git a/.changeset/ev-meter-hard-limit.md b/.changeset/ev-meter-hard-limit.md index 8bebd4835..6f53e4bee 100644 --- a/.changeset/ev-meter-hard-limit.md +++ b/.changeset/ev-meter-hard-limit.md @@ -2,4 +2,4 @@ "ftw": patch --- -The EV's hard limit now reads only the site meter and FTW's own commands, never the charger's power readings. A charge is cut by the overage the meter measures on the worst phase or in total import, holds while the site is inside its limits, and rises one step at a time once the meter shows the last one. After a stop it restarts only when the meter has shown room for 35 seconds. A late, missing or lone 0 W reading from the charger can no longer stop a charge, and an import ceiling no longer makes the charger start and stop every half minute. +The EV's hard limit now reads only the site meter and FTW's own commands, never the charger's power readings. A charge is cut by the overage the meter measures on the worst phase or in total import, holds while the site is inside its limits, and rises one step at a time once the meter shows the last one. After a stop it restarts only when the meter has shown room for 35 seconds, and a Core restart keeps FTW's last offer while the same charging session is still plugged in. A late, missing or lone 0 W reading from the charger can no longer stop a charge, and an import ceiling no longer makes the charger start and stop every half minute. diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index a087001ed..7f01bce2b 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -7,10 +7,9 @@ import ( ) // clampEVBatteryHeadroom keeps optional battery charge out of room an EV -// still needs. It reads only the site meter and our own commands: within -// evSettle the meter may show any command sent, so a device's lowest recent -// command is its baseline and the rest of its range is still pending. No -// charger or battery reading takes part. +// still needs. It reads only the site meter and our own commands; no charger +// or battery reading takes part. The EV still needs its demand less the +// lowest offer of the last evSettle, which the meter surely shows. func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, state *State, fuseMaxW float64) []DispatchTarget { if store == nil || state == nil || len(targets) == 0 || fuseMaxW <= 0 { return targets @@ -37,28 +36,18 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st if meter == nil || !finiteFuseValue(meter.RawW) || !finiteFuseValue(fuseMaxW) { return floorPositiveEVBatteryTargets(targets) } - drivers := make(map[string]bool, len(targets)) - for driver := range state.DriverLimits { - drivers[driver] = true - } - for driver := range state.batteryCommands { - drivers[driver] = true - } + latest := make(map[string]float64, len(targets)) targeted := make(map[string]bool, len(targets)) for _, target := range targets { if targeted[target.Driver] { return floorPositiveEVBatteryTargets(targets) } - targeted[target.Driver], drivers[target.Driver] = true, true - } - baselines := make(map[string]float64, len(drivers)) - uppers := make(map[string]float64, len(drivers)) - for driver := range drivers { - if low, high, _, sent := state.batteryCommands[driver].span(now); sent { - baselines[driver], uppers[driver] = low, high + targeted[target.Driver] = true + if _, _, last, sent := state.batteryCommands[target.Driver].span(now); sent { + latest[target.Driver] = last } else { - // Without a recent command it may be discharging and stop. - baselines[driver] = -state.DriverLimits[driver].dischargeCap() + // Without a recent order it may be discharging and stop. + latest[target.Driver] = -state.DriverLimits[target.Driver].dischargeCap() } } spare := state.effectiveImportCeilingW(fuseMaxW) - meter.RawW - needW @@ -70,7 +59,7 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st spare = math.Min(spare, phaseSpare) } } - out := capBatteryOffersToHeadroom(targets, baselines, uppers, spare) + out := capBatteryCharge(targets, latest, state.pendingBatteryCommandsW(now), spare) // Charge needed to bring measured export inside its hard ceiling is // not optional. Preserve that part of the already validated offer; // this stage never invents or increases an original battery command. @@ -91,91 +80,41 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st return out } -// A reduction at one battery never funds an increase at another, and a lost -// discharge also needs room. -func capBatteryOffersToHeadroom(targets []DispatchTarget, baselines, uppers map[string]float64, spare float64) []DispatchTarget { - if !finiteFuseValue(spare) { +// capBatteryCharge keeps each battery's latest order: holding it is never an +// increase. A shortfall of measured room takes positive charge back; new +// charge needs room left after our increases the meter may not show yet. A +// reduction this tick funds nothing, as the meter does not show it, and only +// positive targets change. +func capBatteryCharge(targets []DispatchTarget, latest map[string]float64, pendingW, spare float64) []DispatchTarget { + if !finiteFuseValue(spare) || !finiteFuseValue(pendingW) { return floorPositiveEVBatteryTargets(targets) } - byDriver := make(map[string]DispatchTarget, len(targets)) - allocationBases := make(map[string]float64, len(targets)) - var positiveBase, dischargeLoss, pendingExtra, pendingOutsidePositive float64 - for _, target := range targets { - byDriver[target.Driver] = target - baseline := baselines[target.Driver] - if !finiteFuseValue(baseline) { - return floorPositiveEVBatteryTargets(targets) - } - allocationBases[target.Driver] = math.Max(0, baseline) - if upper, known := uppers[target.Driver]; known { - allocationBases[target.Driver] = math.Max(allocationBases[target.Driver], upper) - } - if target.TargetW > 0 { - positiveBase += math.Max(0, baseline) - } - dischargeLoss += math.Max(0, math.Min(0, target.TargetW)-math.Min(0, baseline)) - } - for driver, upper := range uppers { - floor := baselines[driver] - target, targeted := byDriver[driver] - if targeted { - floor = math.Max(floor, math.Min(0, target.TargetW)) - } - pending := math.Max(0, upper-floor) - pendingExtra += pending - if !targeted || target.TargetW <= 0 { - pendingOutsidePositive += pending - } - } - spare -= dischargeLoss out := append([]DispatchTarget(nil), targets...) - var wantedIncrease float64 - for _, target := range out { + holds := make([]float64, len(out)) + var holdW, increaseW float64 + for i, target := range out { + last := latest[target.Driver] + spare -= math.Max(0, math.Min(0, target.TargetW)-math.Min(0, last)) // Lost discharge. if target.TargetW > 0 { - wantedIncrease += math.Max(0, target.TargetW-allocationBases[target.Driver]) + holds[i] = math.Min(target.TargetW, math.Max(0, last)) + holdW += holds[i] + increaseW += target.TargetW - holds[i] } } - increaseScale := 1.0 - if wantedIncrease > 0 { - increaseScale = math.Min(1, math.Max(0, spare-pendingExtra)/wantedIncrease) + holdScale, increaseScale := 1.0, 0.0 + if spare < 0 && holdW > 0 { + holdScale = math.Max(0, 1+spare/holdW) } - var positiveTotal float64 - for i := range out { - if out[i].TargetW > 0 { - baseline := allocationBases[out[i].Driver] - capped := math.Min(out[i].TargetW, baseline) + math.Max(0, out[i].TargetW-baseline)*increaseScale - if capped < out[i].TargetW { - out[i].TargetW, out[i].Clamped = capped, true - } - positiveTotal += capped - } - } - capW := math.Max(0, positiveBase+spare-pendingOutsidePositive) - if positiveTotal > capW && positiveTotal > 0 { - for i := range out { - if out[i].TargetW > 0 { - out[i].TargetW *= capW / positiveTotal - out[i].Clamped = true - } - } - } - // A partial positive reduction also leaves its old higher order pending. - // Bound every offer against the same snapshot of its siblings; one - // commanded reduction must not fund another offer in this pass. - covered := make([]float64, len(out)) - var coveredTotal float64 - for i, target := range out { - if target.TargetW > 0 { - covered[i] = math.Max(target.TargetW, math.Max(0, uppers[target.Driver])) - coveredTotal += covered[i] - } + if room := spare - pendingW; room > 0 && increaseW > 0 { + increaseScale = math.Min(1, room/increaseW) } for i := range out { - if out[i].TargetW > 0 { - room := math.Max(0, capW-(coveredTotal-covered[i])) - if out[i].TargetW > room { - out[i].TargetW, out[i].Clamped = room, true - } + if out[i].TargetW <= 0 { + continue + } + capped := holds[i]*holdScale + (out[i].TargetW-holds[i])*increaseScale + if capped < out[i].TargetW { + out[i].TargetW, out[i].Clamped = capped, true } } return out diff --git a/go/internal/control/ev_battery_headroom_test.go b/go/internal/control/ev_battery_headroom_test.go index 97ed34c6f..9fafb4872 100644 --- a/go/internal/control/ev_battery_headroom_test.go +++ b/go/internal/control/ev_battery_headroom_test.go @@ -34,12 +34,17 @@ func evSplitFixture(t *testing.T, gridW float64, phaseA []float64, demandW float return store, state } -// sent logs our commands for a device, oldest first, ending now. +// sent logs our commands for a device, oldest first, ending now. The first +// has been in force for longer than the settle time. func sent(state *State, driver string, watts ...float64) { now := state.now() var log commandLog for i, w := range watts { - log = log.add(now.Add(time.Duration(i-len(watts)+1)*2*time.Second), w) + at := now.Add(time.Duration(i-len(watts)+1) * 2 * time.Second) + if i == 0 { + at = now.Add(-evSettle - time.Second) + } + log = log.add(at, w) } if driver == "ev" { state.evOffers, state.EVCommandedW = log, watts[len(watts)-1] diff --git a/go/internal/control/ev_battery_sequence_test.go b/go/internal/control/ev_battery_sequence_test.go index b941e956c..77de95aca 100644 --- a/go/internal/control/ev_battery_sequence_test.go +++ b/go/internal/control/ev_battery_sequence_test.go @@ -156,3 +156,152 @@ func TestEVBatteryPendingAndNewOrdersShareOnePhysicalBudget(t *testing.T) { } } } + +// evBatterySite runs the EV guard and battery dispatch together, as main.go +// does: charger tick first, then battery dispatch, then the next reading. +type evBatterySite struct { + t *testing.T + now time.Time + store *telemetry.Store + state *State + mgr *loadpoint.Manager + ctrl *loadpoint.Controller + houseW, batteryW float64 + evW float64 + commands, offers []evTimed + maxGridW float64 +} + +func newEVBatterySite(t *testing.T, houseW float64) *evBatterySite { + t.Helper() + s := &evBatterySite{t: t, now: time.Now().Truncate(time.Second), store: telemetry.NewStore(), houseW: houseW} + config := loadpoint.Config{ID: "garage", DriverName: "charger", MinChargeW: 4140, MaxChargeW: 10350, + PhaseMode: "3p", AllowedStepsW: []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350}} + s.mgr = loadpoint.NewManager() + s.mgr.Load([]loadpoint.Config{config}) + dir := SlotDirective{SlotStart: s.now, SlotEnd: s.now.Add(time.Hour), BatteryEnergyWh: 10000, Strategy: "arbitrage"} + s.state = newStateWithEnergyDispatch(dir, "meter") + s.state.clock = func() time.Time { return s.now } + s.state.SiteFuseAmps, s.state.SiteFuseVoltage, s.state.SiteFusePhases, s.state.SiteFuseSafetyA = 25, 230, 3, 1 + s.state.SlewEnabled = false + s.state.DriverLimits = map[string]PowerLimits{"pixii": {MaxChargeW: 10000, MaxDischargeW: 10000}} + 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.evW, PowerAt: s.now, SessionWhUnavailable: true}, true + }, + func(_ context.Context, _ string, payload []byte) error { + var cmd struct { + Action string `json:"action"` + PowerW float64 `json:"power_w"` + } + if err := json.Unmarshal(payload, &cmd); err != nil { + return err + } + if cmd.Action == "ev_set_current" { + s.offers = append(s.offers, evTimed{s.now, cmd.PowerW}) + s.commands = append(s.commands, evTimed{s.now.Add(5 * time.Second), cmd.PowerW}) + } + return nil + }) + s.ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + s.ctrl.SetManualHold(config.ID, loadpoint.ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) + s.ctrl.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { + a := s.gridW() / 690 + return a, a, a, true + }) + s.ctrl.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(s.store, s.state, s.mgr.States(), s.mgr.Configs(), 17250) }) + return s +} + +func (s *evBatterySite) gridW() float64 { return s.houseW + s.batteryW + s.evW } + +func (s *evBatterySite) emit(driver string, kind telemetry.DerType, watts float64) { + d := map[string]any{"control_power_available": true, "control_power_observed_at": s.now.Format(time.RFC3339Nano)} + if kind == telemetry.DerMeter { + a := watts / 690 + d["l1_a"], d["l2_a"], d["l3_a"] = a, a, a + } + data, _ := json.Marshal(d) + soc := .5 + s.store.Update(driver, kind, watts, &soc, data) + s.store.Get(driver, kind).UpdatedAt = s.now + s.store.RecordDriverSuccess(driver) +} + +func (s *evBatterySite) step() { + s.now = s.now.Add(time.Second) + for len(s.commands) > 0 && !s.commands[0].at.After(s.now) { + s.evW, s.commands = s.commands[0].w, s.commands[1:] + } + s.maxGridW = math.Max(s.maxGridW, s.gridW()) + s.emit("pixii", telemetry.DerBattery, s.batteryW) + s.emit("charger", telemetry.DerEV, s.evW) + s.emit("meter", telemetry.DerMeter, s.gridW()) + s.ctrl.TickWithDispatch(context.Background(), s.now, true) + s.state.EVCommandedW = loadpoint.CommandedEVPowerW(s.mgr.States()) + s.state.SetEVFuseDemands(s.ctrl.FuseDemands()) + targets := ComputeDispatch(s.store, s.state, caps(map[string]float64{"pixii": 15000}), 17250) + if len(targets) != 1 { + s.t.Fatalf("targets %+v", targets) + } + s.batteryW = targets[0].TargetW // A local battery follows within a tick. +} + +func (s *evBatterySite) run(d time.Duration) { + for end := s.now.Add(d); s.now.Before(end); { + s.step() + } +} + +func (s *evBatterySite) offer() float64 { + if len(s.offers) == 0 { + return 0 + } + return s.offers[len(s.offers)-1].w +} + +// A 2 kW house step lands while the battery and the car both charge. The +// battery dispatch and the EV guard answer the same overage, so the car is +// cut too. EV first: the car must not stop, the battery must yield, and the +// car must get back to its offer within a minute. +func TestEVFirstHouseStepWhileBatteryAndEVCharge(t *testing.T) { + s := newEVBatterySite(t, 3000) + s.run(3*time.Minute + 30*time.Second) + low, high := math.Inf(1), math.Inf(-1) + for range 30 { + s.step() + low, high = math.Min(low, s.batteryW), math.Max(high, s.batteryW) + } + if high-low > 1 { + t.Fatalf("battery hunts under a steady load: %.0f..%.0f W", low, high) + } + evBefore, batteryBefore := s.offer(), s.batteryW + if evBefore != 10350 || batteryBefore <= 0 { + t.Fatalf("setup: EV %.0f W battery %.0f W", evBefore, batteryBefore) + } + stepAt := s.now + s.houseW += 2000 + var lowest float64 = evBefore + var backAt time.Time + for s.now.Sub(stepAt) < 3*time.Minute { + s.step() + lowest = math.Min(lowest, s.offer()) + if backAt.IsZero() && lowest < evBefore && s.offer() == evBefore { + backAt = s.now + } + } + t.Logf("EV %.0f -> lowest %.0f W, back after %v; battery %.0f -> %.0f W", evBefore, lowest, backAt.Sub(stepAt), batteryBefore, s.batteryW) + if lowest <= 0 { + t.Fatal("the house step stopped the car") + } + if s.batteryW > batteryBefore-2000+.01 { + t.Fatalf("battery %.0f -> %.0f W did not yield the step", batteryBefore, s.batteryW) + } + if backAt.IsZero() || backAt.Sub(stepAt) > time.Minute { + t.Fatalf("EV not back at %.0f W within a minute (now %.0f W)", evBefore, s.offer()) + } + if s.gridW() > 16560.01 { + t.Fatalf("grid %.0f W above 24 A after the step", s.gridW()) + } +} diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 4cd9652cb..0286fb922 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -56,6 +56,8 @@ func (l commandLog) add(now time.Time, w float64) commandLog { } // span returns the lowest, highest and latest command the meter may show. +// When the log is younger than evSettle, the meter may still show the time +// before our first command, which counts as 0. func (l commandLog) span(now time.Time) (low, high, latest float64, ok bool) { for j, c := range l { if j+1 < len(l) && now.Sub(l[j+1].at) >= evSettle { @@ -69,6 +71,9 @@ func (l commandLog) span(now time.Time) (low, high, latest float64, ok bool) { } latest, ok = c.w, true } + if ok && now.Sub(l[0].at) < evSettle { + low, high = math.Min(low, 0), math.Max(high, 0) + } return low, high, latest, ok } @@ -103,7 +108,11 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State return 0, true } meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - if meter == nil || !finiteFuseValue(meter.RawW) { + if meter == nil { + return 0, true + } + gridW := meter.RawW + if !finiteFuseValue(gridW) { return 0, true } lim := &state.evLimit @@ -116,12 +125,12 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State state.evOffers = state.evOffers.add(state.now(), pool.offerW) } if !lim.set || meter.UpdatedAt.After(lim.readingAt) { - state.stepEVMeterLimit(meter, pool, fuseMaxW) + state.stepEVMeterLimit(meter, gridW, pool, fuseMaxW) } return lim.capW, true } -func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, pool evPool, fuseMaxW float64) { +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 @@ -135,14 +144,14 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, pool evPool, fuseM if _, high, _, ok := s.evOffers.span(now); ok { peakW = math.Max(peakW, high) } - roomW, measured, worstA := s.evMeterRoomW(meter, pool.phases, fuseMaxW) + roomW, measured, worstA := s.evMeterRoomW(meter, gridW, pool.phases, fuseMaxW) if roomW < 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{} if lim.capW < prev { lim.changedAt, lim.raised = now, false - slog.Info("EV hard limit: site meter over limit", "grid_w", meter.RawW, "worst_phase_a", worstA, + 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) } return @@ -181,18 +190,18 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, pool evPool, fuseM // The meter shows the last change once import rose with the step, or // in any reading taken evSettle after it. seen := at.Sub(lim.changedAt) >= evSettle || - (lim.raised && at.After(lim.changedAt) && meter.RawW >= lim.raisedGridW+step/2) + (lim.raised && at.After(lim.changedAt) && gridW >= lim.raisedGridW+step/2) if lim.capW < pool.wantW && room >= 2*step && seen { lim.capW += step - lim.changedAt, lim.raised, lim.raisedGridW = now, true, meter.RawW + lim.changedAt, lim.raised, lim.raisedGridW = now, true, gridW } } // 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, phases, fuseMaxW float64) (roomW float64, measured bool, worstA float64) { - roomW = s.effectiveImportCeilingW(fuseMaxW) - meter.RawW +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 diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go index 9a19812e1..8256d12af 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -53,25 +53,46 @@ type evMeterSite struct { commands, draws []evTimed offers []evTimed // every ev_set_current maxGridW float64 + config memConfigStore // survives a restart + sessionWh float64 + ceilingW float64 } +// memConfigStore stands in for state.db across a simulated Core restart. +type memConfigStore map[string]string + +func (m memConfigStore) LoadConfig(key string) (string, bool) { v, ok := m[key]; return v, ok } +func (m memConfigStore) SaveConfig(key, value string) error { m[key] = value; return nil } + func newEVMeterSite(t *testing.T, ceilingW, houseW float64) *evMeterSite { t.Helper() s := &evMeterSite{t: t, now: time.Now().Truncate(time.Second), store: telemetry.NewStore(), - chargerDelay: 5 * time.Second, telemetryDelay: 10 * time.Second, carMaxW: math.Inf(1)} + chargerDelay: 5 * time.Second, telemetryDelay: 10 * time.Second, carMaxW: math.Inf(1), + config: memConfigStore{}, ceilingW: ceilingW} s.setHouse(houseW) + s.boot() + s.draws = []evTimed{{s.now.Add(-time.Hour), 0}} + s.report(0) + return s +} + +// boot starts Core: new control state, loadpoint manager and controller. +// The charger, car, meter and state.db keep running across it. +func (s *evMeterSite) boot() { s.state = NewState(0, 0, "meter") s.state.SiteFuseAmps, s.state.SiteFuseVoltage, s.state.SiteFusePhases, s.state.SiteFuseSafetyA = 25, 230, 3, 0.5 - s.state.PeakImportCeilingW = ceilingW + s.state.PeakImportCeilingW = s.ceilingW s.state.clock = func() time.Time { return s.now } cfg := loadpoint.Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 10350, PhaseMode: "3p", AllowedStepsW: []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350}} s.mgr = loadpoint.NewManager() s.mgr.Load([]loadpoint.Config{cfg}) + s.mgr.SetSessionStore(s.config) 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, SessionWhUnavailable: true}, true + return loadpoint.EVSample{Connected: true, 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 { var cmd struct { @@ -96,9 +117,6 @@ func newEVMeterSite(t *testing.T, ceilingW, houseW float64) *evMeterSite { 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}) - s.draws = []evTimed{{s.now.Add(-time.Hour), 0}} - s.report(0) - return s } func (s *evMeterSite) setHouse(w float64) { @@ -158,6 +176,7 @@ func (s *evMeterSite) step() { s.evW = draw s.draws = append(s.draws, evTimed{s.now, draw}) } + s.sessionWh += s.evW / 3600 if !s.now.Before(s.nextPoll) && s.readings != evReadingsMissing { w := s.cloudW() if s.readings == evReadingsHigh { @@ -252,6 +271,47 @@ func TestEVHardLimitHoldsOfferThroughLoneZeroSample(t *testing.T) { } } +// Every update restarts Core. At the 05:49 currents the meter alone shows +// 2,449.5 W of room, below a start. Our own last order, kept for the same +// plug-in session, holds the charge through the restart. +func TestEVHardLimitRestartKeepsChargingCarAtFieldCurrents(t *testing.T) { + s := newEVMeterSite(t, 0, 2081) + s.carMaxW = 10242 + s.housePhaseA = [3]float64{20.45 - 10242.0/690, 17.05 - 10242.0/690, 15.87 - 10242.0/690} + s.run(3 * time.Minute) + if s.offer() != 10350 { + t.Fatalf("setup: offer %.0f W", s.offer()) + } + restartAt := s.now.Add(time.Second) + s.boot() + s.run(time.Minute) + got := offerWatts(s.offersSince(restartAt)) + if len(got) == 0 { + t.Fatal("no command after the restart") + } + for _, w := range got { + if w != 10350 { + t.Fatalf("restart moved the offer: %v", got) + } + } +} + +// A new plug-in session while Core was down is not ours to hold: its energy +// counter went back, so the pool starts idle on measured room. +func TestEVHardLimitRestartAfterReplugStartsIdle(t *testing.T) { + s := newEVMeterSite(t, 0, 2081) + s.carMaxW = 10242 + s.housePhaseA = [3]float64{20.45 - 10242.0/690, 17.05 - 10242.0/690, 15.87 - 10242.0/690} + s.run(3 * time.Minute) + s.sessionWh = 1 // replugged during the restart + restartAt := s.now.Add(time.Second) + s.boot() + s.step() + if got := offerWatts(s.offersSince(restartAt)); len(got) == 0 || got[0] != 0 { + t.Fatalf("a new session inherited the old order: %v", got) + } +} + // #1674, 23:45: a 7.5 kW import ceiling, the car at 9 A beside 0.9 kW of // house load, and Easee telemetry 5-15 s late. The charge started and // stopped every 30 s. It must start once, keep a step of margin and stay @@ -380,6 +440,32 @@ func TestEVHardLimitCutsAgainAfterSettleWhenChargerIgnores(t *testing.T) { } } +// A car that draws well below its offer hides a cut: the cut comes off the +// offer, not the draw. With the car 4 kW under its offer and a 2 kW +// overage, the first two cuts change nothing and the third, one settle time +// later each, takes effect after the charger's delay. +func TestEVHardLimitCarUnderItsOfferDelaysCut(t *testing.T) { + s := newEVMeterSite(t, 14000, 1000) + s.carMaxW = 6350 + s.run(2 * time.Minute) + if s.offer() != 10350 || s.evW != 6350 { + t.Fatalf("setup: offer %.0f draw %.0f", s.offer(), s.evW) + } + stepAt := s.now + s.setHouse(14000 + 2000 - 6350) + for s.houseW+s.evW > 14000 { + if s.now.Sub(stepAt) > 2*time.Minute { + t.Fatalf("overage never cleared: offer %.0f draw %.0f", s.offer(), s.evW) + } + s.step() + } + took := s.now.Sub(stepAt) + t.Logf("2 kW overage with the car 4 kW under its offer cleared after %v; offers %v", took, offerWatts(s.offersSince(stepAt))[:1]) + if took > 2*evSettle+s.chargerDelay+3*time.Second { + t.Fatalf("overage lasted %v", took) + } +} + // The charger's readings never move the offer: missing for the whole run, // late by 15 s during a start, above what we offered, or a lone 0 W. func TestEVHardLimitOffersIgnoreChargerReadings(t *testing.T) { diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 5bd019f2b..f5ac6472f 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -58,6 +58,8 @@ type Controller struct { tickDirective Directive // offerPeaks is each loadpoint's highest recent offer (serial tick). offerPeaks map[string]offerPeak + // savedOffers mirrors each loadpoint's last order kept across restarts. + savedOffers map[string]savedOffer // fuseResumeTicks is owned by the serial tick. A paused charger needs // two consecutive dispatch ticks with room for a legal step. fuseResumeTicks map[string]int @@ -1205,6 +1207,7 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat if dispatchAllowed { c.tickDirective, c.tickDirectiveOK = c.currentDirective(now) c.tickDirectiveActive = true + c.restoreOffers(now, samples) c.freezeFuseBudget(now, samples) defer func() { c.tickFuseCaps = nil; c.tickFusePhases = 0; c.tickDirectiveActive = false }() } @@ -1567,7 +1570,8 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s if w, ok := cmd["power_w"].(float64); ok { offerW, haveOffer = w, true c.manager.setCommandedForManual(lpCfg.ID, w, cmdReason, manualCommandUpdatedAt) - c.recordOfferPeak(now, lpCfg.ID, w, cmd) + phase := c.recordOfferPeak(now, lpCfg.ID, w, cmd) + c.saveOffer(now, lpCfg, sample, w, phase, cmdReason) } payload, err := json.Marshal(cmd) if err != nil { diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index f8fcbc1eb..93ef1185e 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -106,7 +106,7 @@ type offerPeak struct { // recordOfferPeak keeps the highest offer the meter may still show. A new // phase mode starts over: the same watts on other phases are other currents. -func (c *Controller) recordOfferPeak(now time.Time, id string, w float64, cmd map[string]any) { +func (c *Controller) recordOfferPeak(now time.Time, id string, w float64, cmd map[string]any) string { phase, _ := cmd["phase_mode"].(string) if phase == "" { phase = "3p" // The driver's locked default, as in phaseModeForFuse. @@ -117,6 +117,7 @@ func (c *Controller) recordOfferPeak(now time.Time, id string, w float64, cmd ma if peak := c.offerPeaks[id]; peak.phase != phase || w >= peak.w || now.Sub(peak.at) >= CommandSettle { c.offerPeaks[id] = offerPeak{w: w, phase: phase, at: now} } + return phase } func (c *Controller) fuseConfig(now time.Time, cfg Config) Config { diff --git a/go/internal/loadpoint/controller_offer_restore.go b/go/internal/loadpoint/controller_offer_restore.go new file mode 100644 index 000000000..f44a0de30 --- /dev/null +++ b/go/internal/loadpoint/controller_offer_restore.go @@ -0,0 +1,98 @@ +package loadpoint + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "time" +) + +// savedOffer is FTW's own last order to one charger, never a reading from +// it. A charger keeps that order while Core restarts; without it the first +// tick would count a charging car as idle and stop it near a limit. +type savedOffer struct { + Version int `json:"version"` + DeviceID string `json:"device_id"` + SessionID string `json:"session_id,omitempty"` + SessionWh float64 `json:"session_wh"` + PowerW float64 `json:"power_w"` + PhaseMode string `json:"phase_mode"` + Reason string `json:"reason,omitempty"` + At time.Time `json:"at"` +} + +// offerRestoreMaxAge bounds a restart. An older order may have been replaced +// by the charger's own app or schedule. +const offerRestoreMaxAge = 10 * time.Minute + +func offerKey(deviceID string) string { + h := sha256.Sum256([]byte(deviceID)) + return "ev_offer:" + hex.EncodeToString(h[:]) +} + +// saveOffer keeps the order when it changes, and refreshes it once a minute +// so its session energy and age still describe the running session. +func (c *Controller) saveOffer(now time.Time, cfg Config, sample EVSample, powerW float64, phase, reason string) { + store := c.manager.sessionStore + if store == nil || sample.DeviceID == "" || !finite(powerW) { + return + } + if c.savedOffers == nil { + c.savedOffers = map[string]savedOffer{} + } + last, saved := c.savedOffers[cfg.ID] + if !sample.Connected { + return + } + if saved && last.DeviceID == sample.DeviceID && last.SessionID == sample.SessionID && + last.PowerW == powerW && last.PhaseMode == phase && last.Reason == reason && now.Sub(last.At) < time.Minute { + return + } + record := savedOffer{Version: 1, DeviceID: sample.DeviceID, SessionID: sample.SessionID, + SessionWh: sample.SessionWh, PowerW: powerW, PhaseMode: phase, Reason: reason, At: now} + if b, err := json.Marshal(record); err == nil && store.SaveConfig(offerKey(record.DeviceID), string(b)) == nil { + c.savedOffers[cfg.ID] = record + } +} + +// restoreOffers runs before the first command after a restart. It brings back +// our last order only while the same plug-in session is connected: a matching +// session id, or else an energy counter that has not gone back. An observed +// unplug clears the order; a missing reading does not. +func (c *Controller) restoreOffers(now time.Time, samples map[string]EVSample) { + store := c.manager.sessionStore + if store == nil { + return + } + for _, cfg := range c.manager.Configs() { + sample, observed := samples[cfg.DriverName] + if last, saved := c.savedOffers[cfg.ID]; saved && observed && !sample.Connected && !sample.ConnectionUnknown { + _ = store.SaveConfig(offerKey(last.DeviceID), "{}") + delete(c.savedOffers, cfg.ID) + continue + } + st, _ := c.manager.State(cfg.ID) + if st.CommandedKnown || !sample.Connected || sample.ConnectionUnknown || sample.DeviceID == "" { + continue + } + raw, ok := store.LoadConfig(offerKey(sample.DeviceID)) + var saved savedOffer + if !ok || json.Unmarshal([]byte(raw), &saved) != nil || saved.Version != 1 || saved.DeviceID != sample.DeviceID || + !finite(saved.PowerW) || saved.PowerW < 0 || saved.At.After(now) || now.Sub(saved.At) > offerRestoreMaxAge { + continue + } + sameSession := saved.SessionID != "" && saved.SessionID == sample.SessionID + if saved.SessionID == "" && sample.SessionID == "" { + sameSession = !sample.SessionWhUnavailable && saved.SessionWh > 0 && sample.SessionWh >= saved.SessionWh + } + if !sameSession { + continue + } + c.manager.setCommandedForManual(cfg.ID, saved.PowerW, saved.Reason, time.Time{}) + c.recordOfferPeak(now, cfg.ID, saved.PowerW, map[string]any{"phase_mode": saved.PhaseMode}) + if c.savedOffers == nil { + c.savedOffers = map[string]savedOffer{} + } + c.savedOffers[cfg.ID] = saved + } +}