diff --git a/.changeset/ev-meter-hard-limit.md b/.changeset/ev-meter-hard-limit.md new file mode 100644 index 000000000..6f53e4bee --- /dev/null +++ b/.changeset/ev-meter-hard-limit.md @@ -0,0 +1,5 @@ +--- +"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, 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/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..a367b55d2 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -371,6 +371,12 @@ 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 + // 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 @@ -2028,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 @@ -2039,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 @@ -2435,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 { @@ -4582,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 209873f17..7f01bce2b 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -1,163 +1,65 @@ package control import ( - "encoding/json" "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; 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 } 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 - } + latest := make(map[string]float64, len(targets)) + 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 - } - 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)) + targeted[target.Driver] = true + if _, _, last, sent := state.batteryCommands[target.Driver].span(now); sent { + latest[target.Driver] = last + } else { + // Without a recent order it may be discharging and stop. + latest[target.Driver] = -state.DriverLimits[target.Driver].dischargeCap() } } - 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]) - } - } - - // 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 := 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. @@ -175,244 +77,57 @@ 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. -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 { - if target.TargetW > 0 { - wantedIncrease += math.Max(0, target.TargetW-allocationBases[target.Driver]) - } - } - increaseScale := 1.0 - if wantedIncrease > 0 { - increaseScale = math.Min(1, math.Max(0, spare-pendingExtra)/wantedIncrease) - } - 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 + 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 { - covered[i] = math.Max(target.TargetW, math.Max(0, uppers[target.Driver])) - coveredTotal += covered[i] - } - } - 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 - } - } - } - return out -} - -// 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 + holds[i] = math.Min(target.TargetW, math.Max(0, last)) + holdW += holds[i] + increaseW += target.TargetW - holds[i] } - 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 + holdScale, increaseScale := 1.0, 0.0 + if spare < 0 && holdW > 0 { + holdScale = math.Max(0, 1+spare/holdW) } - // 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) - } + if room := spare - pendingW; room > 0 && increaseW > 0 { + increaseScale = math.Min(1, room/increaseW) } - 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) { + for i := range out { + if out[i].TargetW <= 0 { 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) + capped := holds[i]*holdScale + (out[i].TargetW-holds[i])*increaseScale + if capped < out[i].TargetW { + out[i].TargetW, out[i].Clamped = capped, true } } - return baseline, true + return out } 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_battery_headroom_test.go b/go/internal/control/ev_battery_headroom_test.go index 35cf02f44..9fafb4872 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,187 @@ 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) - } - }) - } -} - -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) - } -} - -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") +// 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 { + 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) } -} - -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) + if driver == "ev" { + state.evOffers, state.EVCommandedW = log, watts[len(watts)-1] + return } + state.batteryCommands[driver] = log } -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..77de95aca 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,24 +142,166 @@ 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) } } } + +// 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_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..0286fb922 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,194 +21,293 @@ 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 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 gap between the cut +// and rise thresholds. +const evPhaseMarginA = 1.0 + +// 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. +// 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 { + 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 + } + if ok && now.Sub(l[0].at) < evSettle { + low, high = math.Min(low, 0), math.Max(high, 0) + } + 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 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, wantW, minW, phases float64 + offerKnown bool } -// 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. +// 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; 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 } meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - if meter == nil || !finiteFuseValue(meter.RawW) { + if meter == nil { 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 + gridW := meter.RawW + if !finiteFuseValue(gridW) { + return 0, true + } + lim := &state.evLimit + pool, plugged := evPoolFor(state, points, configs) + if !plugged { + *lim = evMeterLimit{} + return 0, true + } + if pool.offerKnown { + state.evOffers = state.evOffers.add(state.now(), pool.offerW) + } + if !lim.set || meter.UpdatedAt.After(lim.readingAt) { + state.stepEVMeterLimit(meter, gridW, 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) - } - type evSeries struct { - latest float64 - current telemetry.ControlObservation - points []telemetry.ControlObservation - } - 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. +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 + lim.readingAt = at + prev := math.Inf(1) + 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, 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", gridW, "worst_phase_a", worstA, + "overage_w", -roomW, "offer_w", peakW, "cap_w", lim.capW) } - 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) + // 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 _, fresh := qualifiedFuseObservation(meter, now); fresh && measured { + room = math.Max(0, roomW-s.pendingBatteryCommandsW(now)) } - 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 + step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases + if pool.offerW <= 0 { + lim.capW, lim.changedAt, lim.raised = 0, now, false + if room < pool.minW+step { + lim.roomSince = time.Time{} + return } - credit, coherent := 0.0, true - for _, ev := range series { - w, ok := bracketedEVPower(ev.points, site.At, ev.latest) - if !ok { - coherent = false - break + if lim.roomSince.IsZero() || gap { + lim.roomSince = at + if first { + lim.roomSince = at.Add(-evRestartDwell) // No stop of ours to wait out. } - credit += w } - if coherent { - balance.pendingW, coherent = pendingBatteryIncreaseAt(batteries, site.At) - } - if coherent { - balance.gridW, balance.poolW = site.PowerW, credit - return balance + if at.Sub(lim.roomSince) >= evRestartDwell { + lim.capW = room - step } + return } - 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 + // Keeping the current offers is never a cut. Headroom above them stays + // within what this reading measured. + lim.capW, lim.roomSince = math.Min(prev, peakW+room), time.Time{} + if first { + lim.changedAt = now + return + } + // 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) && 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, gridW } - p, ok := telemetry.ControlPowerObservation(r.RawW, r.Data, r.UpdatedAt) - return p, ok && !p.Seen().After(now) && now.Sub(p.Seen()) <= telemetry.ControlWindowDuration } -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 - } +// evMeterRoomW is the measured room below the nearer limit, total import or +// worst phase, in EV watts. Negative is an overage. measured is false when +// the meter reports phase currents that cannot be read. +func (s *State) evMeterRoomW(meter *telemetry.DerReading, gridW, phases, fuseMaxW float64) (roomW float64, measured bool, worstA float64) { + roomW = s.effectiveImportCeilingW(fuseMaxW) - gridW + worstA, present, valid := worstPhaseA(meter, s) + if !present { + return roomW, true, 0 } - if !hasBefore || !hasAfter { - return 0, false + if !valid { + return roomW, false, 0 } - 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 + return math.Min(roomW, (s.SiteFuseAmps-evPhaseMarginA-worstA)*s.SiteFuseVoltage*phases), true, worstA } -// 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)) } - phases := state.SiteFusePhases - unknownPhase := false + return worstA, true, true +} + +// 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) + } + } + return pending +} + +// 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 { - // 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), 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. 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 + } + pool.offerKnown = pool.offerKnown && point.CommandedKnown + } + if !plugged { + return pool, false + } + if pool.offerKnown { + pool.offerW = loadpoint.CommandedEVPowerW(points) + } + if state.evFuseDemandsSet { + pool.wantW = 0 + for _, demand := range state.evFuseDemands { + pool.wantW += demand.powerW } } - deltaA := state.SiteFuseAmps - 1 - worst - if deltaA < 0 && unknownPhase { - return -poolW, true // Cannot prove which single-phase car relieves it. + if state.SiteFusePhases >= 1 && float64(state.SiteFusePhases) < pool.phases { + pool.phases = float64(state.SiteFusePhases) } - return deltaA * state.SiteFuseVoltage * float64(phases), true + 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) { 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 718224281..8256d12af 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,662 @@ import ( "github.com/srcfl/ftw/go/internal/telemetry" ) -func TestLiveFuseBudgetKeepsDeclaredPhaseDefault(t *testing.T) { - for _, tc := range []struct { - name, phase string - wantW float64 - }{ - {"legacy_default", "", 13110}, - {"explicit_3p", "3p", 13110}, - {"explicit_1p", "1p", 4370}, - {"explicit_auto", "auto", 4370}, - {"unknown", "unknown", 4370}, - } { - 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) +type evTimed struct { + at time.Time + 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 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 + store *telemetry.Store + state *State + 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 + 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), + 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 = 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, + DeviceID: "easee:EH000001", SessionWh: s.sessionWh, EnergyAt: s.reportedAt}, 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}) +} + +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 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]}) +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 + } + 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) +} + +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}) + } + s.sessionWh += s.evW / 3600 + 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) + } + if s.now.Equal(s.loneZeroAt) { + s.report(0) + } + grid := s.houseW + s.evW + s.maxGridW = math.Max(s.maxGridW, grid) + 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()) +} + +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) []evTimed { + var out []evTimed + for _, o := range s.offers { + if !o.at.Before(at) { + out = append(out, o) + } + } + return out +} + +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.t.Fatalf("setup: offer %.0f draw %.0f, want %.0f W", s.offer(), s.evW, 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 offerWatts(s.offersSince(zeroAt)) { + if w != 10350 { + t.Fatalf("offer left 10,350 W after a lone 0 W sample: %v", offerWatts(s.offersSince(zeroAt))) + } + } +} + +// 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 +// 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", offerWatts(s.offers)) + } } - 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) + // 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()) } - 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 s.maxGridW > 7500 { + t.Fatalf("grid reached %.0f W above the 7,500 W ceiling", s.maxGridW) } - if tc.duplicate { - points = append(points, loadpoint.State{ID: "b", DriverName: "car", PluggedIn: true}) + }) + } +} + +// 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(5520) + stepAt := s.now + s.setHouse(2630) // 2,630 + 5,520 = 8,150 W + s.run(evSettle + 10*time.Second) + got := offerWatts(s.offersSince(stepAt)) + for _, w := range got { + if w != 5520 && w != 4830 { + t.Fatalf("house step gave offers %v, want one step to 4,830 W", got) + } + } + 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) + } +} + +// 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(5520) + s.carPaused = true + s.run(30 * time.Second) + s.setHouse(5000) + s.run(30 * time.Second) + offered := s.offer() + if offered < 5520 { + 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 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)) } - 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 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) + } + } +} + +// 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(5520) + s.chargerIgnores = true + stepAt := s.now + s.setHouse(2630) + s.run(evSettle - 2*time.Second) + 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() >= 4830 { + t.Fatalf("ignored cut was not repeated: %v", offerWatts(s.offersSince(stepAt))) + } +} + +// 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) { + 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) } - 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) + } + } +} + +// 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))) + } + } +} + +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 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") + 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 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 - 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) { + 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) } }) } } + +// 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}`}, + } { + 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 > (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 0000ec582..000000000 --- a/go/internal/control/ev_pending_battery.go +++ /dev/null @@ -1,101 +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 -} - -// 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 -} - -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 !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 46d7acd6a..000000000 --- a/go/internal/control/ev_pending_battery_test.go +++ /dev/null @@ -1,305 +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) - if got := pendingEVBudget(store, state); got != 0 { - 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 > 1560.01 { - t.Fatalf("unknown earlier discharge financed %.1fW EV room", got) - } - // The next bracket proves the new balance and releases that startup bound. - 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 { - 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") - ev := pendingEVBudget(store, state) - if grid := house + math.Max(actual, target) + ev; 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 fa41d1308..f5ac6472f 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -48,17 +48,18 @@ 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 + // 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 @@ -207,6 +208,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. @@ -579,75 +585,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) { @@ -1270,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 }() } @@ -1620,7 +1558,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 +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) + 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 5682ad37a..93ef1185e 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,19 @@ 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 + // 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) { if hold, held := c.GetManualHold(cfg.ID, now); held { request.wantW = clampManualPower(cfg, hold, c.siteFuse()) @@ -79,26 +82,44 @@ 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) + // 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) c.tickFuseCaps[request.cfg.ID] = cap - increase -= math.Max(0, cap-request.liveW) + increase -= math.Max(0, cap-request.baseW) } } +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) string { + 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} + } + return phase +} + 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()) @@ -129,9 +150,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 +162,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..1df0ce47a 100644 --- a/go/internal/loadpoint/controller_fuse_phase_test.go +++ b/go/internal/loadpoint/controller_fuse_phase_test.go @@ -147,3 +147,29 @@ 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}) + 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) + 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 { + t.Fatalf("reading %.0f W changed the offer: %+v", w, command) + } + } +} 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 { 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) } } 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 + } +}