From c4ab22cfc5956cfeb8732069a2ca2dd44450c043 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 19:24:26 +0200 Subject: [PATCH 1/3] fix(control): let battery charging yield to the EV Signed-off-by: Fredrik Ahlgren --- .changeset/quiet-ev-fuse-recovery.md | 5 + go/cmd/ftw/main.go | 5 + go/internal/control/dispatch.go | 5 + go/internal/control/ev_battery_headroom.go | 409 +++++++++++++++++ .../control/ev_battery_headroom_test.go | 432 ++++++++++++++++++ .../control/ev_battery_sequence_test.go | 159 +++++++ go/internal/control/ev_fuse_budget.go | 69 ++- go/internal/control/ev_pending_battery.go | 101 ++++ .../control/ev_pending_battery_test.go | 305 +++++++++++++ go/internal/control/ev_plan_priority.go | 48 +- go/internal/control/ev_plan_priority_test.go | 60 +++ go/internal/loadpoint/controller.go | 179 ++++---- .../controller_commanded_reason_test.go | 8 +- .../loadpoint/controller_fuse_budget.go | 42 +- .../loadpoint/controller_fuse_demand.go | 48 ++ .../loadpoint/controller_fuse_demand_test.go | 118 +++++ .../controller_fuse_recovery_test.go | 264 +++++++++++ go/internal/loadpoint/manual_limits.go | 43 +- go/internal/telemetry/command_evidence.go | 105 ++++- .../telemetry/command_pending_battery_test.go | 161 +++++++ go/internal/telemetry/control_observations.go | 8 +- .../telemetry/control_observations_test.go | 21 + go/internal/telemetry/store.go | 9 +- 23 files changed, 2468 insertions(+), 136 deletions(-) create mode 100644 .changeset/quiet-ev-fuse-recovery.md create mode 100644 go/internal/control/ev_battery_headroom.go create mode 100644 go/internal/control/ev_battery_headroom_test.go create mode 100644 go/internal/control/ev_battery_sequence_test.go create mode 100644 go/internal/control/ev_pending_battery.go create mode 100644 go/internal/control/ev_pending_battery_test.go create mode 100644 go/internal/loadpoint/controller_fuse_demand.go create mode 100644 go/internal/loadpoint/controller_fuse_demand_test.go create mode 100644 go/internal/loadpoint/controller_fuse_recovery_test.go create mode 100644 go/internal/telemetry/command_pending_battery_test.go diff --git a/.changeset/quiet-ev-fuse-recovery.md b/.changeset/quiet-ev-fuse-recovery.md new file mode 100644 index 000000000..cc50e2971 --- /dev/null +++ b/.changeset/quiet-ev-fuse-recovery.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Let home battery charging yield to the car and resume EV charging once measured fuse headroom is stable, without a fixed five-minute pause. Avoid repeated cuts from the same phase reading and reserve room for commands that have not yet appeared in meter data. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index e97e0c2e5..df204de03 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -3027,6 +3027,10 @@ func main() { // resume charging. evCurtailHeadroomW := loadpoint.SurplusPotentialW(lpStatesSnapshot) boostEVW, boostReserveSoC := activeBatteryBoostTotals(lpController, lpStatesSnapshot, tickNow) + var evFuseDemands []loadpoint.FuseDemand + if lpController != nil { + evFuseDemands = lpController.FuseDemands() + } ctrlMu.Lock() ctrl.EVSurplusOnlyReserveW = evReserveW @@ -3035,6 +3039,7 @@ func main() { ctrl.BatteryBoostEVChargingW = boostEVW ctrl.BatteryBoostReserveSoC = boostReserveSoC ctrl.EVCommandedW = loadpoint.CommandedEVPowerW(lpStatesSnapshot) + ctrl.SetEVFuseDemands(evFuseDemands) fuseMaxW := ctrl.SiteFuseAmps * ctrl.SiteFuseVoltage * float64(ctrl.SiteFusePhases) targets := control.ComputeDispatch(tel, ctrl, capsSnap, fuseMaxW) planMissingNow := ctrl.Mode.IsPlannerMode() && ctrl.PlanStale diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 695d4d0ff..8d894f8f1 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -367,6 +367,10 @@ type State struct { // It reserves site capacity before optional battery charging, even before // an EV ramps up and in modes without an accepted optimizer plan. EVCommandedW float64 + // evFuseDemands keeps this tick's intent before the fuse clamp so optional + // battery charging yields even when the EV cannot start yet. + evFuseDemands []evFuseDemand + evFuseDemandsSet bool // PI controller (outer, site-level) PI *PIController @@ -2354,6 +2358,7 @@ func applyDispatchSafetyPipeline( } targets = floorMissingSoCDischarge(targets, store) targets = state.floorFullBatteryCharge(targets) + targets = clampEVBatteryHeadroom(targets, store, state, fuseMaxW) republishFuseEVCapAfterFuseDischarge(targets, store, state, fuseMaxW) recordDispatchTargets(targets, state, opts.updatePrevTargets, opts.recordDispatch) return targets diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go new file mode 100644 index 000000000..8e2f0d71a --- /dev/null +++ b/go/internal/control/ev_battery_headroom.go @@ -0,0 +1,409 @@ +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. +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 + } + } + 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)} + } + } + if len(need) == 0 { + return targets + } + meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) + site, known := qualifiedFuseObservation(meter, now) + if !known || !finiteFuseValue(fuseMaxW) { + return floorPositiveEVBatteryTargets(targets) + } + baselines := make(map[string]float64, len(targets)) + latestBaselines := make(map[string]float64, len(targets)) + batteryReadings := make(map[string]*telemetry.DerReading, len(targets)) + byDriver := make(map[string]DispatchTarget, len(targets)) + drivers := make(map[string]bool, len(targets)) + for driver := range state.DriverLimits { + drivers[driver] = true + } + for _, reading := range store.ReadingsByType(telemetry.DerBattery) { + drivers[reading.Driver] = true + } + for _, target := range targets { + if _, duplicate := byDriver[target.Driver]; duplicate { + 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)) + } + } + 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 + // 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 + } + } + // 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. + var offeredCharge, finalCharge float64 + for i := range targets { + offeredCharge += math.Max(0, targets[i].TargetW) + finalCharge += math.Max(0, out[i].TargetW) + } + exportFloor := math.Min(offeredCharge, math.Max(0, -meter.RawW-state.effectiveExportCeilingW(fuseMaxW))) + if exportFloor > finalCharge && offeredCharge > finalCharge { + scale := (exportFloor - finalCharge) / (offeredCharge - finalCharge) + for i := range out { + if targets[i].TargetW > out[i].TargetW && targets[i].TargetW > 0 { + out[i].TargetW += (targets[i].TargetW - out[i].TargetW) * scale + } + } + } + // 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) { + 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 + } + } + } + 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 + } + coherent := true + for _, ev := range evs { + if _, known := bracketedEVPower(ev.points, site.At, ev.latest); !known { + coherent = false + break + } + } + if coherent { + for driver := range batteries { + if _, _, known := evBatteryBaseline(store, windows, state, driver, site.At, now); !known { + coherent = false + break + } + } + } + if coherent { + return site.At, site.PowerW + } + } + return current.At, gridW +} + +func evBatteryBaseline(store *telemetry.Store, windows map[string]telemetry.ControlBaseline, state *State, driver string, at, now time.Time) (float64, *telemetry.DerReading, bool) { + reading := store.Get(driver, telemetry.DerBattery) + observation, known := qualifiedFuseObservation(reading, now) + if !known { + return -state.DriverLimits[driver].dischargeCap(), reading, false + } + points := windows[driver+":"+telemetry.DerBattery.String()].Points + latest := observation.PowerW // Qualified site-side AC, which can differ from raw DC. + if baseline, known := bracketedBatteryPower(append(points, observation), at, latest); known { + return baseline, reading, true + } + // No positive replacement credit without correspondence. Retain any + // observed discharge as a possible import rise, even in a sparse ring. + baseline := math.Min(0, latest) + for _, point := range points { + if finiteFuseValue(point.PowerW) { + baseline = math.Min(baseline, point.PowerW) + } + } + return baseline, reading, false +} + +// Unlike an EV, a battery's baseline may be negative. Retaining the lower +// signed value also reserves a loss of discharge before a positive offer. +func bracketedBatteryPower(points []telemetry.ControlObservation, at time.Time, latest float64) (float64, bool) { + var before, after telemetry.ControlObservation + hasBefore, hasAfter := false, false + for _, point := range points { + if !finiteFuseValue(point.PowerW) { + continue + } + if !point.At.After(at) && (!hasBefore || point.At.After(before.At)) { + before, hasBefore = point, true + } + if !point.Seen().Before(at) && (!hasAfter || point.At.Before(after.At)) { + after, hasAfter = point, true + } + } + if !hasBefore || !hasAfter || !finiteFuseValue(latest) { + return 0, false + } + baseline := math.Min(latest, math.Min(before.PowerW, after.PowerW)) + for _, point := range points { + if !point.Seen().Before(at) && finiteFuseValue(point.PowerW) { + baseline = math.Min(baseline, point.PowerW) + } + } + return baseline, true +} + +func evBatteryPhaseSpareW(meter *telemetry.DerReading, state *State, evIncreaseA float64) (float64, bool) { + 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) { + return 0, false + } + if state.SiteFusePhases < 1 || state.SiteFusePhases > 3 || state.SiteFuseVoltage <= 0 || state.SiteFuseAmps <= 0 { + 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 +} + +func floorPositiveEVBatteryTargets(targets []DispatchTarget) []DispatchTarget { + out := append([]DispatchTarget(nil), targets...) + for i := range out { + if out[i].TargetW > 0 { + out[i].TargetW, out[i].Clamped = 0, true + } + } + return out +} diff --git a/go/internal/control/ev_battery_headroom_test.go b/go/internal/control/ev_battery_headroom_test.go new file mode 100644 index 000000000..f3207fcaf --- /dev/null +++ b/go/internal/control/ev_battery_headroom_test.go @@ -0,0 +1,432 @@ +package control + +import ( + "encoding/json" + "fmt" + "math" + "testing" + "time" + + "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) { + 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}) + } + } + 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) + } + 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") + } +} + +func TestEVBatteryFutureDischargeDoesNotFundCharge(t *testing.T) { + store, state := evBatteryFixture(t, 7000, nil, + evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 4000, after: 4000, requested: 4000, phaseMode: "3p"}, + evBatteryFixturePower{driver: "first", kind: telemetry.DerBattery, before: 0, after: 0}, + evBatteryFixturePower{driver: "second", kind: telemetry.DerBattery, before: 0, after: 0}) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: -9000}, {Driver: "second", TargetW: 10000}}, store, state, 17250) + if out[0].TargetW != -9000 || math.Abs(out[1].TargetW-9560) > .01 { + t.Fatalf("future discharge financed positive offer: %v", out) + } +} + +func TestEVBatteryStoppingDischargeReservesItsLostSupply(t *testing.T) { + store, state := evBatteryFixture(t, 2000, nil, + evBatteryFixturePower{driver: "car", kind: telemetry.DerEV, before: 4000, after: 4000, requested: 4000, phaseMode: "3p"}, + evBatteryFixturePower{driver: "battery", kind: telemetry.DerBattery, before: -5000, after: -5000}) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "battery", TargetW: 10000}}, store, state, 17250) + if math.Abs(out[0].TargetW-9560) > .01 { + t.Fatalf("stopped discharge still funded charge: %v", out) + } +} + +func 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) + } +} + +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) + } + }) + } +} + +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) + } +} + +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.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) + } +} + +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}, + } { + 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 _, siblingTarget := range []bool{false, true} { + t.Run(fmt.Sprint(siblingTarget), 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 siblingTarget { + targets = append(targets, DispatchTarget{Driver: "sibling", TargetW: 0}) + } + 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 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) + } + }) + } +} diff --git a/go/internal/control/ev_battery_sequence_test.go b/go/internal/control/ev_battery_sequence_test.go new file mode 100644 index 000000000..a01dc1bf9 --- /dev/null +++ b/go/internal/control/ev_battery_sequence_test.go @@ -0,0 +1,159 @@ +package control + +import ( + "context" + "encoding/json" + "math" + "math/rand" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestJoinedEVWaitsForMeasuredBatteryBackoffThenResumes(t *testing.T) { + wall := time.Now() + const houseW, desiredEVW = 5000.0, 10350.0 + actualBattery, actualEV := 8000.0, 0.0 + config := loadpoint.Config{ID: "garage", DriverName: "charger", MinChargeW: 4140, MaxChargeW: desiredEVW, + PhaseMode: "3p", AllowedStepsW: []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350}} + 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"} + st := newStateWithEnergyDispatch(dir, "meter") + 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}} + var sampledPhaseA float64 + 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 + }, + 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" { + actualEV = cmd.PowerW + // The physical battery may ignore a reduction. Check this at + // the EV send, before a later battery command can conceal it. + if phase := (houseW + actualBattery + actualEV) / 690; phase > 24.001 { + t.Fatalf("EV spent unmeasured battery relief: %.1f A", phase) + } + } + return nil + }) + controller.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + 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)} + 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.RecordDriverSuccess(driver) + } + for tick := 0; tick < 6; tick++ { + if tick == 3 { + actualBattery = 0 + } // Only now does the battery obey. + at := wall.Add(-9*time.Second + time.Duration(tick)*1500*time.Millisecond) + 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) + 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 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") + } + } + } +} + +func TestEVBatteryCommandOrdersStayWithinMeasuredHeadroom(t *testing.T) { + rng := rand.New(rand.NewSource(1667)) + for sample := 0; sample < 300; sample++ { + house := rng.Float64() * 5000 + first, second := rng.Float64()*4000, rng.Float64()*4000 + 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}) + 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} + for _, i := range order { + // A battery may ignore a reduction while its sibling obeys. + batteries[i] = math.Max(batteries[i], out[i].TargetW) + if total := house + actualEV + batteries[0] + batteries[1]; total > 16560.001 { + t.Fatalf("sample %d order %v: grid %.1f W exceeds 24 A; before %.1f W targets %+v", sample, order, total, grid, out) + } + } + } + } +} + +func TestEVBatteryPendingAndNewOrdersShareOnePhysicalBudget(t *testing.T) { + rng := rand.New(rand.NewSource(1668)) + for sample := 0; sample < 300; sample++ { + house, first, second, ev := rng.Float64()*3000, rng.Float64()*2000, rng.Float64()*2000, rng.Float64()*2000 + grid := house + first + second + ev + pendingFirst := first + rng.Float64()*(16560-grid) + 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) + actualEV := math.Max(ev, math.Min(requestedEV, capW)) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: rng.Float64() * 10000}, {Driver: "second", TargetW: rng.Float64() * 10000}}, store, state, 17250) + // Either old order can arrive after either new one. A newer command + // on the same device replaces its offer, never its pending evidence. + worst := house + actualEV + math.Max(pendingFirst, out[0].TargetW) + math.Max(pendingSecond, out[1].TargetW) + if worst > 16560.001 { + t.Fatalf("sample %d: old/new battery orders and EV reach %.1f W; pending %.1f/%.1f EV %.1f targets %+v", sample, worst, pendingFirst, pendingSecond, actualEV, out) + } + } +} diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 7a80aee4d..2a4d1d2a3 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -21,6 +21,11 @@ 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 +} + // 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. @@ -32,15 +37,17 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State if meter == nil || !finiteFuseValue(meter.RawW) { return 0, true } - gridW, poolW := coherentEVBalance(store, state.SiteMeterDriver, meter, points, state.now()) - cap := evHeadroomW(state.phaseImportCeilingW(fuseMaxW, meter), gridW, poolW) - if phaseW, known := evPhaseIncreaseW(meter, state, configs, poolW); known { - cap = math.Min(cap, math.Max(0, poolW+phaseW)) - } - if state.FuseSaturated { - cap = math.Min(cap, state.FuseEVMaxW) - } - return cap, 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 } // A managed EV earns replacement credit only across a site observation that @@ -48,18 +55,22 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State // 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, meterDriver string, meter *telemetry.DerReading, points []loadpoint.State, now time.Time) (gridW, poolW float64) { - gridW = meter.RawW +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) - sites := windows[meterDriver+":"+telemetry.DerMeter.String()].Points + 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 - points []telemetry.ControlObservation + latest float64 + current telemetry.ControlObservation + points []telemetry.ControlObservation } var series []evSeries seen := map[string]bool{} @@ -76,8 +87,12 @@ func coherentEVBalance(store *telemetry.Store, meterDriver string, meter *teleme } 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), ps}) + series = append(series, evSeries{math.Min(current.PowerW, r.RawW), current, ps}) + } + for _, ev := range series { + balance.latestPoolW += evPowerAtLatestSite(ev.points, siteAt, ev.current) } + 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) { @@ -93,12 +108,15 @@ func coherentEVBalance(store *telemetry.Store, meterDriver string, meter *teleme credit += w } if coherent { - // A fresh increase in house/site demand always wins over an - // older coherent sample. A fresh EV drop already reduced credit. - return math.Max(gridW, site.PowerW), credit + balance.pendingW, coherent = pendingBatteryIncreaseAt(batteries, site.At) + } + if coherent { + balance.gridW, balance.poolW = site.PowerW, credit + return balance } } - return gridW, 0 + balance.pendingW, _ = pendingBatteryIncreaseAt(batteries, siteAt) + return balance } func qualifiedFuseObservation(r *telemetry.DerReading, now time.Time) (telemetry.ControlObservation, bool) { @@ -126,7 +144,16 @@ func bracketedEVPower(points []telemetry.ControlObservation, at time.Time, lates if !hasBefore || !hasAfter { return 0, false } - return math.Max(0, math.Min(latest, math.Min(before.PowerW, after.PowerW))), true + baseline := math.Max(0, math.Min(latest, math.Min(before.PowerW, after.PowerW))) + // Another slow device may force an older common site observation. A + // later observed stop still removes its replacement credit, even if + // the charger has since started again. + for _, point := range points { + if !point.Seen().Before(at) && finiteFuseValue(point.PowerW) && point.PowerW >= 0 { + baseline = math.Min(baseline, point.PowerW) + } + } + return baseline, true } // Phase-current magnitudes cannot tell us that an exporting phase makes @@ -159,7 +186,7 @@ func evPhaseIncreaseW(meter *telemetry.DerReading, state *State, configs []loadp phases := state.SiteFusePhases unknownPhase := false for _, cfg := range configs { - if cfg.PhaseMode == "1p" || cfg.PhaseMode == "auto" { + if cfg.PhaseMode != "3p" { phases = 1 // No promise about an auto charger's current phase dwell. unknownPhase = true } diff --git a/go/internal/control/ev_pending_battery.go b/go/internal/control/ev_pending_battery.go new file mode 100644 index 000000000..0000ec582 --- /dev/null +++ b/go/internal/control/ev_pending_battery.go @@ -0,0 +1,101 @@ +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 new file mode 100644 index 000000000..46d7acd6a --- /dev/null +++ b/go/internal/control/ev_pending_battery_test.go @@ -0,0 +1,305 @@ +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/ev_plan_priority.go b/go/internal/control/ev_plan_priority.go index 2889e0e1a..549ab81ae 100644 --- a/go/internal/control/ev_plan_priority.go +++ b/go/internal/control/ev_plan_priority.go @@ -1,6 +1,42 @@ package control -import "math" +import ( + "math" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +type evFuseDemand struct { + driverName string + powerW float64 + phaseMode string +} + +// SetEVFuseDemands copies the charger decisions made before the fuse clamp. +// Callers replace the snapshot each tick while holding the control lock. +func (state *State) SetEVFuseDemands(demands []loadpoint.FuseDemand) { + state.evFuseDemandsSet = true + state.evFuseDemands = nil + byDriver := make(map[string]int, len(demands)) + for _, demand := range demands { + if demand.DriverName == "" || !finiteFuseValue(demand.PowerW) || demand.PowerW <= 0 { + continue + } + phase := demand.PhaseMode + if phase != "1p" && phase != "3p" { + phase = "auto" + } + if i, ok := byDriver[demand.DriverName]; ok { + state.evFuseDemands[i].powerW = math.Max(state.evFuseDemands[i].powerW, demand.PowerW) + if state.evFuseDemands[i].phaseMode != phase { + state.evFuseDemands[i].phaseMode = "auto" + } + continue + } + byDriver[demand.DriverName] = len(state.evFuseDemands) + state.evFuseDemands = append(state.evFuseDemands, evFuseDemand{demand.DriverName, demand.PowerW, phase}) + } +} // The active plan contains EV energy only for a connected scheduled goal. // Keep its demand available while the charger ramps up, even before it draws. @@ -15,6 +51,16 @@ func scheduledEVPowerW(state *State) float64 { if math.IsNaN(commanded) || math.IsInf(commanded, 0) || commanded < 0 { commanded = 0 } + var requested float64 + for _, demand := range state.evFuseDemands { + requested += demand.powerW + } + commanded = math.Max(commanded, requested) + // A live charger decision supersedes an earlier plan, including an + // empty snapshot after Stop, a completed goal or a duty-cycle pause. + if state.evFuseDemandsSet { + return commanded + } if !state.Mode.IsPlannerMode() { return commanded } diff --git a/go/internal/control/ev_plan_priority_test.go b/go/internal/control/ev_plan_priority_test.go index bdcebc7c9..bbbd667fd 100644 --- a/go/internal/control/ev_plan_priority_test.go +++ b/go/internal/control/ev_plan_priority_test.go @@ -6,9 +6,69 @@ import ( "testing" "time" + "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/mpc" ) +func TestFuseLimitedManualDemandMakesBatteryYield(t *testing.T) { + st := NewState(0, 0, "ferroamp") + st.Mode = ModeSelfConsumption + st.SetEVFuseDemands([]loadpoint.FuseDemand{{DriverName: "car", PowerW: 10350, PhaseMode: "3p"}}) + // A zero final offer must not erase the demand that asks the battery to + // back off. The next charger offer still needs measured fuse headroom. + st.EVCommandedW = 0 + if got := scheduledEVPowerW(st); got != 10350 { + t.Fatalf("clamped manual demand lost: %v W", got) + } + store := seedStore(-6000, []struct { + name string + currentW, soc float64 + }{{"battery", 0, .5}}) + targets := ComputeDispatch(store, st, caps(map[string]float64{"battery": 10000}), 5000) + if len(targets) != 1 || targets[0].TargetW > 650.001 { + t.Fatalf("battery used space requested by the car: %v", targets) + } + st.SetEVFuseDemands(nil) + if got := scheduledEVPowerW(st); got != 0 { + t.Fatalf("cleared demand still reserves %v W", got) + } +} + +func TestFuseDemandSnapshotDoesNotDoubleCountOrAlias(t *testing.T) { + st := NewState(0, 0, "meter") + demands := []loadpoint.FuseDemand{ + {DriverName: "car", PowerW: 6900, PhaseMode: "3p"}, + {DriverName: "car", PowerW: 10350, PhaseMode: "1p"}, + {DriverName: "other", PowerW: 1380, PhaseMode: "unknown"}, + {DriverName: "", PowerW: 5000}, + {DriverName: "invalid", PowerW: math.NaN()}, + {DriverName: "invalid", PowerW: math.Inf(1)}, + {DriverName: "invalid", PowerW: -1}, + } + st.SetEVFuseDemands(demands) + demands[0].PowerW = 100000 + if got := scheduledEVPowerW(st); got != 11730 { + t.Fatalf("deduplicated demand = %v W", got) + } + if len(st.evFuseDemands) != 2 || st.evFuseDemands[0].phaseMode != "auto" || st.evFuseDemands[1].phaseMode != "auto" { + t.Fatalf("mixed or unknown phase must stay conservative: %+v", st.evFuseDemands) + } +} + +func TestLiveFuseDemandReplacesOldPlanReservation(t *testing.T) { + now := time.Now() + st := newStateWithEnergyDispatch(SlotDirective{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), + LoadpointEnergyWh: map[string]float64{"car": 100}, LoadpointMaxPowerW: map[string]float64{"car": 10350}}, "meter") + st.SetEVFuseDemands(nil) + if got := scheduledEVPowerW(st); got != 0 { + t.Fatalf("stopped/completed/duty-off car still reserves %v W from old plan", got) + } + st.SetEVFuseDemands([]loadpoint.FuseDemand{{DriverName: "car", PowerW: 4140, PhaseMode: "3p"}}) + if got := scheduledEVPowerW(st); got != 4140 { + t.Fatalf("live request must replace old on-power: %v W", got) + } +} + func TestScheduledEVKeepsBudgetBeforeBatteryCharge(t *testing.T) { for _, draw := range []float64{0, 4000, 8000} { t.Run(fmt.Sprint(draw), func(t *testing.T) { diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index f088cb1b8..2de085705 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -58,6 +58,11 @@ type Controller struct { tickDirectiveActive bool tickDirectiveOK bool tickDirective Directive + // 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 + fuseDemandMu sync.RWMutex + fuseDemands []FuseDemand // siteSurplusForEVW is live PV left after house load and battery soak. // A false reading pauses surplus_only rather than guessing an import. @@ -129,11 +134,6 @@ type Controller struct { nearTermLogMu sync.Mutex nearTermLogLast map[string]time.Time - // fusePauseUntil keeps a loadpoint at 0 W for fusePauseCooldown after - // the fuse cap falls below its minimum step. - fusePauseMu sync.Mutex - fusePauseUntil map[string]time.Time - // phaseLockMu guards the day-long 1Φ lock and the 30 min phase dwell. // Without the dwell, a forecast near 4140 W flips the Easee contactor // every tick and winds up the battery PI. @@ -204,14 +204,7 @@ const nearTermLogCooldown = 10 * time.Minute // Easee contactor and winds up the battery PI. const phaseSwitchMinHold = 30 * time.Minute -// fusePauseCooldown is how long an LP stays at 0 W after a fuse-over- -// limit force-pause. Long enough that whatever house load caused the -// overload (oven, EV from another LP, sauna…) has time to clear or for -// the operator to notice; short enough that a transient inrush doesn't -// stop charging for the rest of the day. 5 min is the operator-stated -// preference; chosen vs. e.g. 1 min because the typical "did the oven -// kick on?" disturbance lasts longer than a minute. -const fusePauseCooldown = 5 * time.Minute +const fuseResumeSafeTicks = 2 // defaultPhaseSplitW mirrors loadpoint.Config.PhaseSplitW's default — // 3680 W is a 16 A 1Φ ceiling at 230 V. Kept in sync with the comment @@ -582,10 +575,6 @@ func (c *Controller) SetPeakRemainingSurplusW(f func() (float64, bool)) { c.peakRemainingSurplusW = f } -// applyFuseClampAndCooldown caps wantW at the joint fuse share. -// Below the minimum step it pauses for fusePauseCooldown, so a -// transient does not flap. A hold cannot override it. - // fusePhaseMarginA is the per-phase amp headroom held below the breaker. const fusePhaseMarginA = 1.0 @@ -628,6 +617,12 @@ 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 @@ -652,23 +647,13 @@ func (c *Controller) applyPerPhaseFuseClamp(lpCfg Config, cmd map[string]any) { } } -// The reason is "", "fuse_limit", or "fuse_cooldown". +// 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) { if c == nil { return wantW, "" } - c.fusePauseMu.Lock() - until, has := c.fusePauseUntil[lpCfg.ID] - if has && !now.Before(until) { - // Cooldown elapsed; release lazily so subsequent ticks see no - // hold and can re-attempt charging. - delete(c.fusePauseUntil, lpCfg.ID) - has = false - } - c.fusePauseMu.Unlock() - if has { - return 0, "fuse_cooldown" - } + lpCfg = c.fuseConfig(now, lpCfg) if c.fuseEVMax == nil { return wantW, "" } @@ -676,6 +661,12 @@ func (c *Controller) applyFuseClampAndCooldown(now time.Time, lpCfg Config, want if !ok || cap < 0 { return wantW, "" } + if !finite(cap) { + cap = 0 + } + if c.fuseResumeTicks[lpCfg.ID] > 0 { + return 0, "fuse_cooldown" + } if wantW <= cap { return wantW, "" } @@ -686,18 +677,10 @@ func (c *Controller) applyFuseClampAndCooldown(now time.Time, lpCfg Config, want "lp", lpCfg.ID, "want_w", wantW, "fuse_cap_w", cap, "snapped_w", snapped) return snapped, "fuse_limit" } - // Cap is below the LP's min step → pause + arm cooldown. - c.fusePauseMu.Lock() - if c.fusePauseUntil == nil { - c.fusePauseUntil = map[string]time.Time{} - } - c.fusePauseUntil[lpCfg.ID] = now.Add(fusePauseCooldown) - c.fusePauseMu.Unlock() - slog.Warn("loadpoint fuse-clamp: paused for cooldown", + slog.Info("loadpoint fuse-clamp: below minimum step", "lp", lpCfg.ID, "want_w", wantW, "fuse_cap_w", cap, - "min_step_w", lpCfg.MinChargeW, - "cooldown_s", int(fusePauseCooldown.Seconds())) - return 0, "fuse_cooldown" + "min_step_w", lpCfg.MinChargeW) + return 0, "fuse_limit" } // SetNearTermPeakSurplusW wires the short-horizon "peak surplus over @@ -1236,12 +1219,20 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat if c == nil || c.manager == nil || c.tel == nil { return } + c.clearFuseDemands() + if !dispatchAllowed { + for id := range c.fuseResumeTicks { + c.fuseResumeTicks[id] = 0 + } + } // Stop every affected charger before observing any session. Observation // may restore or sync state.db; a stalled disk must not delay standdown, // including the chargers after the one whose checkpoint is blocked. type observation struct { - config Config - sample EVSample + config Config + sample EVSample + wasPlugged bool + wasDelivering bool } var observations []observation samples := map[string]EVSample{} @@ -1250,7 +1241,7 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat if !observed { continue } - observations = append(observations, observation{cfg, sample}) + observations = append(observations, observation{config: cfg, sample: sample}) samples[cfg.DriverName] = sample // Only a stale site meter stands chargers down. An old charger power // reading from a live driver is not a hardware risk: the fuse clamps @@ -1265,6 +1256,13 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat if c.plan == nil && dispatchAllowed { return } + for i := range observations { + observed := &observations[i] + previous, _ := c.manager.State(observed.config.ID) + observed.wasPlugged = previous.PluggedIn + observed.wasDelivering = previous.CurrentPowerW >= DeliveringW + c.observeTick(now, observed.config, observed.sample, dispatchAllowed) + } if dispatchAllowed { c.tickDirective, c.tickDirectiveOK = c.currentDirective(now) c.tickDirectiveActive = true @@ -1272,10 +1270,31 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat defer func() { c.tickFuseCaps = nil; c.tickDirectiveActive = false }() } for _, observed := range observations { - c.tickOne(ctx, now, observed.config, observed.sample, dispatchAllowed) + c.tickOne(ctx, now, observed.config, observed.sample, dispatchAllowed, observed.wasPlugged, observed.wasDelivering) } } +func (c *Controller) observeTick(now time.Time, cfg Config, sample EVSample, dispatchAllowed bool) { + c.observeEnergy(cfg, sample, now) + c.manager.observeConnectionProof(cfg.ID, sample.ConnectionGeneration, sample.ConnectionUnknown) + if sample.ConnectionUnknown { + c.restoreManualHoldForSession(cfg.ID) + return + } + sched, _ := c.manager.GetSchedule(cfg.ID) + enteringSurplusPaused, _ := c.getSurplusPause(cfg.ID) + // Observe the prior pause without advancing the battery-SoC hysteresis; + // tickOne evaluates the new surplus decision once, after the plan freezes. + c.batSoCArmedMu.Lock() + armed := c.batSoCArmed[cfg.ID] + c.batSoCArmedMu.Unlock() + wasSurplus := cfg.SurplusOnly || (!sched.HasTarget() && armed) + c.manager.SetSurplusWithheld(cfg.ID, wasSurplus && enteringSurplusPaused) + c.manager.ObserveSample(cfg.ID, sample) + c.restoreManualHoldForSession(cfg.ID) + c.evaluateBatteryBoost(cfg.ID, now, sample.Connected, dispatchAllowed && !sample.PowerUnavailable) +} + func (c *Controller) standDownBeforeStorage(ctx context.Context, now time.Time, cfg Config, sample EVSample) { var manualUpdatedAt time.Time if hold, held := c.GetManualHold(cfg.ID, now); held { @@ -1294,12 +1313,9 @@ func (c *Controller) standDownBeforeStorage(ctx context.Context, now time.Time, } } -func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, sample EVSample, dispatchAllowed bool) { - c.observeEnergy(lpCfg, sample, now) - c.manager.observeConnectionProof(lpCfg.ID, sample.ConnectionGeneration, sample.ConnectionUnknown) +func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, sample EVSample, dispatchAllowed, wasPlugged, wasDelivering bool) { if sample.ConnectionUnknown { delete(c.resumeOffers, lpCfg.ID) - c.restoreManualHoldForSession(lpCfg.ID) return } // Resolve the schedule once per tick — used for the bat-SoC unlock @@ -1315,6 +1331,8 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s // both true. surplusOn := c.surplusActive(lpCfg, sched) surplusAdds := c.surplusAddsToPlan(lpCfg, sched) + enteringSurplusPaused, _ := c.getSurplusPause(lpCfg.ID) + selfWithheld := surplusOn && enteringSurplusPaused // Detect the disconnected→connected edge (state.PluggedIn flips // from false to true) so we can reset session-scoped state // before the new session's first dispatch tick. Without this @@ -1327,24 +1345,6 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s // (or the proxy's cache to refresh on its own) to learn the // car's current charge_limit_pct + SoC, which costs accuracy // on the first ~15 min of a new charging session. - wasPlugged := false - wasDelivering := false - if st, ok := c.manager.State(lpCfg.ID); ok { - wasPlugged = st.PluggedIn - wasDelivering = st.CurrentPowerW >= DeliveringW - } - // As of last tick, were we pausing this surplus loadpoint below its - // floor? (computeSurplusCmd for THIS tick runs later.) A charger that - // reports "not requesting current" while we're withholding power is - // responding to our own pause, not declining — so it must not count - // toward session completion, and the same signal drives the resume - // wallbox-cycle in maybeWakeVehicle below. - enteringSurplusPaused, _ := c.getSurplusPause(lpCfg.ID) - selfWithheld := surplusOn && enteringSurplusPaused - c.manager.SetSurplusWithheld(lpCfg.ID, selfWithheld) - c.manager.ObserveSample(lpCfg.ID, sample) - c.restoreManualHoldForSession(lpCfg.ID) - c.evaluateBatteryBoost(lpCfg.ID, now, sample.Connected, dispatchAllowed && !sample.PowerUnavailable) if !sample.Connected { delete(c.resumeOffers, lpCfg.ID) c.resetSurplusSession(lpCfg.ID) @@ -1440,16 +1440,6 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s // into PV-surplus-only charging. The fuse clamp below is the one // guard a manual hold can never override. (surplusOn still drives // the phase-mode fallback further down.) - // Fuse protection: even an operator-pinned hold must not bust - // the fuse. Apply the joint allocator's FuseEVMax cap and the - // pause-cooldown guard before sending. A sticky 11 kW Start - // hold + house drawing 7 A on one phase = fuse trip without - // this clamp. - var fuseReason string - holdW, fuseReason = c.applyFuseClampAndCooldown(now, lpCfg, holdW) - if fuseReason != "" { - cmdReason = fuseReason - } cmd["power_w"] = holdW // Phase mode: explicit hold > explicit LP config > surplus // default ("auto") > driver default. Same surplus-active fallback @@ -1496,7 +1486,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s cmd["site_phases"] = site.Phases() } } else { - cmdW, planReady, fuseCapped := c.computeCommand(now, lpCfg, sample.PowerW) + cmdW, planReady, _ := c.computeCommandForBudget(now, lpCfg, sample.PowerW, false) cmdReason = "plan" if c.plan != nil { if d, ok := c.currentDirective(now); ok && d.LoadpointFallback[lpCfg.ID] { @@ -1506,9 +1496,6 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s } } } - if fuseCapped { - cmdReason = "fuse_limit" - } if !planReady { // No plan budget for this loadpoint right now — explicit // 0 W standdown so the charger pauses cleanly. @@ -1562,17 +1549,6 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s if d, ok := c.currentDirective(now); ok && d.LoadpointFallback[lpCfg.ID] && sample.PowerUnavailable { cmdW = 0 } - // Fuse protection: applied LAST (after MPC budget, surplus - // clamp, wake-kick) so all upstream sources see their nominal - // wantW; only the actual ceiling we send to the wallbox is - // reduced when the fuse demands it. Partial ramp-downs are - // immediate; only "cap below min step → must pause" arms the - // 5-min cooldown. - var fuseReason string - cmdW, fuseReason = c.applyFuseClampAndCooldown(now, lpCfg, cmdW) - if fuseReason != "" { - cmdReason = fuseReason - } cmd["power_w"] = cmdW // Easee treats an unset phase_mode as 3p and then ignores a 1Φ step. // A schedule still overrides the surplus 1Φ lock, or a deadline @@ -1610,6 +1586,25 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s // A manual diagnostic may lower a site limit, but never replace the // installation's voltage, phase count or fuse with a larger offer. c.applyInstallationLimits(cmd) + if applyCurrentCeiling(cmd) { + cmdReason = "charger_limit" + } + if wantW, ok := cmd["power_w"].(float64); ok { + phaseMode, _ := cmd["phase_mode"].(string) + c.recordFuseDemand(lpCfg.DriverName, wantW, phaseMode) + // The reserve directive requires a known physical ceiling. Capture + // its intent above, but never turn an unknown cap into permission. + if d, ready := c.currentDirective(now); ready && d.LoadpointFallback[lpCfg.ID] { + if cap, known := c.fuseCapFor(lpCfg.ID); !known || !finite(cap) || cap < 0 { + wantW, cmdReason = 0, "fuse_limit" + } + } + limitedW, reason := c.applyFuseClampAndCooldown(now, lpCfg, wantW) + cmd["power_w"] = limitedW + if reason != "" { + cmdReason = reason + } + } c.applyPerPhaseFuseClamp(lpCfg, cmd) if applyCurrentCeiling(cmd) { cmdReason = "fuse_limit" diff --git a/go/internal/loadpoint/controller_commanded_reason_test.go b/go/internal/loadpoint/controller_commanded_reason_test.go index 5306f0ea0..57084ea28 100644 --- a/go/internal/loadpoint/controller_commanded_reason_test.go +++ b/go/internal/loadpoint/controller_commanded_reason_test.go @@ -101,7 +101,7 @@ func TestCommandedReasonFuseCapZero(t *testing.T) { } } -func TestCommandedReasonFuseCooldownOverridesManualHold(t *testing.T) { +func TestCommandedReasonFuseLimitOverridesManualHold(t *testing.T) { base := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC) sender := &fakeSender{} cfg := reasonLoadpoint() @@ -109,14 +109,14 @@ func TestCommandedReasonFuseCooldownOverridesManualHold(t *testing.T) { c := newTestController(t, []Config{cfg}, nil, samples, sender) c.SetManualHold(cfg.ID, ManualHold{PowerW: 11040, PhaseMode: "3p", Persistent: true}) // The hold wants 11 kW, the fuse allows nothing below the minimum - // step — the clamp pauses, arms the cooldown, and the reason must + // step — the clamp pauses, and the reason must // say fuse, not manual_hold. c.SetFuseEVMax(func() (float64, bool) { return 0, true }) c.Tick(context.Background(), base) - if w, r := commandedReason(t, c, cfg.ID); w != 0 || r != "fuse_cooldown" { - t.Errorf("hold vs fuse: want (0, fuse_cooldown), got (%.0f, %q)", w, r) + if w, r := commandedReason(t, c, cfg.ID); w != 0 || r != "fuse_limit" { + t.Errorf("hold vs fuse: want (0, fuse_limit), got (%.0f, %q)", w, r) } } diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index 148a7f153..d7a891b11 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -20,10 +20,12 @@ type fuseRequest struct { func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample) { c.tickFuseCaps = nil if c.fuseEVMax == nil { + clear(c.fuseResumeTicks) return } total, ok := c.fuseEVMax() if !ok || total < 0 { + clear(c.fuseResumeTicks) return } if !finite(total) { @@ -36,7 +38,7 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample sample := samples[cfg.DriverName] st, _ := c.manager.State(cfg.ID) st.PluggedIn = sample.Connected && !sample.ConnectionUnknown - request := fuseRequest{cfg: cfg, deadline: st.TargetTime} + 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 @@ -90,11 +92,42 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample // Do not spend another car's curtailment before its meter confirms it. want := math.Min(target, request.liveW+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) } } +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()) + } + return cfg +} + +func (c *Controller) recoverFuseCap(id string, wantW, capW float64) float64 { + if wantW <= 0 { + delete(c.fuseResumeTicks, id) + return capW + } + if capW <= 0 { + if c.fuseResumeTicks == nil { + c.fuseResumeTicks = map[string]int{} + } + c.fuseResumeTicks[id] = 0 + return 0 + } + if ready, paused := c.fuseResumeTicks[id]; paused { + ready++ + if ready < fuseResumeSafeTicks { + c.fuseResumeTicks[id] = ready + return 0 + } + delete(c.fuseResumeTicks, id) + } + 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) { @@ -107,7 +140,7 @@ func (c *Controller) sharedPhaseIncreaseW(now time.Time, requests []fuseRequest, return 0, false } if !finite(l1) || !finite(l2) || !finite(l3) { - return 0, true + return -liveTotal, true } phaseCount := site.Phases() unknownPhase := false @@ -116,7 +149,7 @@ func (c *Controller) sharedPhaseIncreaseW(now time.Time, requests []fuseRequest, if hold, ok := c.GetManualHold(request.cfg.ID, now); ok && hold.PhaseMode != "" { mode = hold.PhaseMode } - if mode == "1p" || mode == "auto" { + if mode != "3p" { phaseCount = 1 unknownPhase = true } @@ -143,6 +176,9 @@ func (c *Controller) fuseCapFor(id string) (float64, bool) { if c.tickFuseCaps != nil { return c.tickFuseCaps[id], true } + if c.tickDirectiveActive { + return 0, false // The tick's one callback read found no constraint. + } if c.fuseEVMax != nil { return c.fuseEVMax() } diff --git a/go/internal/loadpoint/controller_fuse_demand.go b/go/internal/loadpoint/controller_fuse_demand.go new file mode 100644 index 000000000..ab6668b68 --- /dev/null +++ b/go/internal/loadpoint/controller_fuse_demand.go @@ -0,0 +1,48 @@ +package loadpoint + +// FuseDemand is an eligible charger's requested power before live fuse limits. +// It is intent, never measured power or permission to command the charger. +type FuseDemand struct { + DriverName string + PowerW float64 + PhaseMode string +} + +// FuseDemands returns a copy of the latest tick's positive requests. A stale +// dispatch tick clears them before sending any safety standdown. +func (c *Controller) FuseDemands() []FuseDemand { + if c == nil { + return nil + } + c.fuseDemandMu.RLock() + defer c.fuseDemandMu.RUnlock() + return append([]FuseDemand(nil), c.fuseDemands...) +} + +func (c *Controller) clearFuseDemands() { + c.fuseDemandMu.Lock() + c.fuseDemands = nil + c.fuseDemandMu.Unlock() +} + +func (c *Controller) recordFuseDemand(driver string, powerW float64, phaseMode string) { + if driver == "" || !finite(powerW) || powerW <= 0 { + return + } + if phaseMode != "1p" && phaseMode != "3p" { + phaseMode = "auto" + } + c.fuseDemandMu.Lock() + defer c.fuseDemandMu.Unlock() + for i := range c.fuseDemands { + d := &c.fuseDemands[i] + if d.DriverName == driver { + d.PowerW = max(d.PowerW, powerW) + if d.PhaseMode != phaseMode { + d.PhaseMode = "auto" + } + return + } + } + c.fuseDemands = append(c.fuseDemands, FuseDemand{DriverName: driver, PowerW: powerW, PhaseMode: phaseMode}) +} diff --git a/go/internal/loadpoint/controller_fuse_demand_test.go b/go/internal/loadpoint/controller_fuse_demand_test.go new file mode 100644 index 000000000..e3527ee6d --- /dev/null +++ b/go/internal/loadpoint/controller_fuse_demand_test.go @@ -0,0 +1,118 @@ +package loadpoint + +import ( + "context" + "math" + "sync" + "testing" + "time" +) + +func TestFuseDemandsPreserveIntentBehindZeroCap(t *testing.T) { + for _, mode := range []string{"scheduled", "manual", "manual_1p", "manual_current_limit", "fallback", "price_pause", "manual_stop", "no_surplus", "vehicle_complete", "fallback_power_unknown"} { + t.Run(mode, func(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + cfg := fuseRecoveryLoadpoint() + cfg.VehicleCapacityWh, cfg.PluginSoC = 60000, .3 + cfg.AllowedStepsW = append(cfg.AllowedStepsW, 1380, 2300, 3680) + d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{cfg.ID: 2587.5}, LoadpointMaxPowerW: map[string]float64{cfg.ID: 10350}} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + wantW, phase := 10350.0, "3p" + switch mode { + case "price_pause": + d.LoadpointEnergyWh[cfg.ID], wantW = 0, 0 + case "manual_stop", "no_surplus", "vehicle_complete", "fallback_power_unknown": + wantW = 0 + } + if mode == "no_surplus" { + cfg.SurplusOnly = true + } + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, d, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetFuseEVMax(func() (float64, bool) { return 0, true }) + switch mode { + case "manual": + c.SetManualHold(cfg.ID, ManualHold{PowerW: wantW, PhaseMode: phase, Persistent: true}) + case "manual_1p": + wantW, phase = 3680, "1p" + c.SetManualHold(cfg.ID, ManualHold{PowerW: wantW, PhaseMode: phase, Persistent: true}) + case "manual_current_limit": + wantW = 4140 + c.SetManualHold(cfg.ID, ManualHold{PowerW: 10350, PhaseMode: phase, MaxAmpsPerPhase: 6, Persistent: true}) + case "manual_stop": + c.SetManualHold(cfg.ID, ManualHold{Persistent: true}) + case "no_surplus": + c.SetSiteSurplusForEV(func() (float64, bool) { return 0, true }) + case "vehicle_complete": + c.manager.SetSchedule(cfg.ID, Schedule{FinishAtVehicleLimit: true, TimeOfDayMinUTC: 13 * 60, Recurring: true}) + c.manager.RollSchedules(now) + c.SetVehicleChargeState(func(string) (VehicleChargeState, bool) { + return VehicleChargeState{SoC: .8, Limit: .8, State: "Complete"}, true + }) + case "fallback", "fallback_power_unknown": + d.LoadpointFallback = map[string]bool{cfg.ID: true} + c.manager.SetTarget(cfg.ID, .8, now.Add(time.Hour)) + if mode == "fallback_power_unknown" { + samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true, PowerUnavailable: true} + } + } + c.Tick(context.Background(), now) + set, ok := lastSetCurrent(sender.calls) + if !ok || set.power != 0 { + t.Fatalf("zero physical cap did not stop: %+v", set) + } + got := c.FuseDemands() + if wantW == 0 { + if len(got) != 0 { + t.Fatalf("inactive intent reserved fuse room: %+v", got) + } + } else { + if len(got) != 1 || got[0].DriverName != cfg.DriverName || got[0].PowerW != wantW || got[0].PhaseMode != phase { + t.Fatalf("pre-clamp intent lost: got=%+v want=%.0f W/%s", got, wantW, phase) + } + got[0].PowerW = 1 + if c.FuseDemands()[0].PowerW != wantW { + t.Fatal("snapshot aliases the controller") + } + } + c.TickWithDispatch(context.Background(), now.Add(5*time.Second), false) + if got := c.FuseDemands(); len(got) != 0 { + t.Fatalf("stale meter retained demand: %+v", got) + } + }) + } +} + +func TestFuseDemandsDeduplicateDriverAndCopySnapshots(t *testing.T) { + c := &Controller{} + c.recordFuseDemand("charger", 4000, "3p") + c.recordFuseDemand("charger", 5000, "1p") + c.recordFuseDemand("charger", 2000, "3p") + c.recordFuseDemand("other", math.NaN(), "3p") + c.recordFuseDemand("other", math.Inf(1), "3p") + c.recordFuseDemand("other", 0, "3p") + got := c.FuseDemands() + if len(got) != 1 || got[0].PowerW != 5000 || got[0].PhaseMode != "auto" { + t.Fatalf("duplicate driver must use max power and unknown phase: %+v", got) + } + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + c.recordFuseDemand("charger", 5000, "3p") + } + }() + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + copy := c.FuseDemands() + copy[0].PowerW = -1 + } + }() + wg.Wait() + if c.FuseDemands()[0].PowerW != 5000 { + t.Fatal("concurrent snapshot changed controller state") + } +} diff --git a/go/internal/loadpoint/controller_fuse_recovery_test.go b/go/internal/loadpoint/controller_fuse_recovery_test.go new file mode 100644 index 000000000..0037eb686 --- /dev/null +++ b/go/internal/loadpoint/controller_fuse_recovery_test.go @@ -0,0 +1,264 @@ +package loadpoint + +import ( + "context" + "math" + "testing" + "time" +) + +func fuseRecoveryLoadpoint() Config { + cfg := reasonLoadpoint() + cfg.MaxChargeW = 11040 + cfg.AllowedStepsW = []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350, 11040} + return cfg +} + +func TestFuseBudgetRecoversAfterTwoSafeTicks(t *testing.T) { + for _, manual := range []bool{false, true} { + for _, initial := range []struct { + name string + capW float64 + }{{"below_minimum", 3560}, {"zero", 0}, {"invalid", math.NaN()}} { + name := "scheduled/" + initial.name + if manual { + name = "manual/" + initial.name + } + t.Run(name, func(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + cfg := fuseRecoveryLoadpoint() + d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{cfg.ID: 2587.5}, LoadpointMaxPowerW: map[string]float64{cfg.ID: 10350}} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true, PowerW: 10000}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, d, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + if manual { + c.SetManualHold(cfg.ID, ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) + } + capW := initial.capW + c.SetFuseEVMax(func() (float64, bool) { return capW, true }) + check := func(at time.Time, allowed bool, want float64) { + t.Helper() + c.TickWithDispatch(context.Background(), at, allowed) + set, ok := lastSetCurrent(sender.calls) + if !ok || set.power != want { + t.Fatalf("at %v: command=%+v, want %.0f W", at.Sub(now), set, want) + } + } + check(now, true, 0) + capW = 13560 + check(now.Add(5*time.Second), true, 0) + check(now.Add(10*time.Second), true, 10350) + // A later loss of headroom must stop immediately and require + // two safe dispatch ticks again, never a five-minute lock. + capW = 3000 + check(now.Add(15*time.Second), true, 0) + capW = 13560 + check(now.Add(20*time.Second), true, 0) + check(now.Add(25*time.Second), false, 0) + check(now.Add(30*time.Second), true, 0) + check(now.Add(35*time.Second), true, 10350) + // A saved Stop remains a Stop after the fuse budget recovers. + c.SetManualHold(cfg.ID, ManualHold{Persistent: true}) + check(now.Add(40*time.Second), true, 0) + }) + } + } +} + +func TestFirstConnectionIsObservedBeforeFuseBudget(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + cfg := fuseRecoveryLoadpoint() + sender := &fakeSender{} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true, PowerW: 10000}} + 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}) + seenConnected := false + c.SetFuseEVMax(func() (float64, bool) { + st, _ := c.manager.State(cfg.ID) + seenConnected = st.PluggedIn + if !st.PluggedIn { + return 3560, true + } + return 13560, true + }) + c.Tick(context.Background(), now) + set, ok := lastSetCurrent(sender.calls) + if !seenConnected || !ok || set.power != 10350 { + t.Fatalf("fresh connection absent from shared budget: connected=%v command=%+v", seenConnected, set) + } +} + +func TestSharedPhaseBudgetDoesNotIntegrateSameOverload(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + cfg := fuseRecoveryLoadpoint() + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true, PowerW: 8280}} + sender := &fakeSender{} + 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 }) + 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 + // 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 { + t.Fatalf("tick %d: command=%+v, want stable legal 6210 W", tick, set) + } + } +} + +func TestManualSinglePhaseUsesItsLegalMinimumInSharedFuseBudget(t *testing.T) { + for _, phaseMode := range []string{"1p", "auto"} { + t.Run(phaseMode, func(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + cfg := fuseRecoveryLoadpoint() + cfg.AllowedStepsW = append(cfg.AllowedStepsW, 1380, 2300, 3680) + sender := &fakeSender{} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 3680, PhaseMode: phaseMode, Persistent: true}) + c.SetFuseEVMax(func() (float64, bool) { return 5000, true }) + c.Tick(context.Background(), now) + set, ok := lastSetCurrent(sender.calls) + if !ok || set.power != 3680 { + t.Fatalf("legal single-phase offer was treated as below the three-phase minimum: %+v", set) + } + }) + } +} + +func TestNewConnectionDoesNotInheritFuseRecovery(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + cfg := fuseRecoveryLoadpoint() + sender := &fakeSender{} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + 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}) + capW := 0.0 + c.SetFuseEVMax(func() (float64, bool) { return capW, true }) + c.Tick(context.Background(), now) + capW = 13560 + c.Tick(context.Background(), now.Add(5*time.Second)) + samples[cfg.DriverName] = EVSample{Connected: false} + c.Tick(context.Background(), now.Add(10*time.Second)) + samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true} + c.SetManualHold(cfg.ID, ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) + c.Tick(context.Background(), now.Add(15*time.Second)) + set, ok := lastSetCurrent(sender.calls) + if !ok || set.power != 10350 { + t.Fatalf("new session inherited the previous session's recovery wait: %+v", set) + } +} + +func TestManualPhaseOverrideKeepsSixAmpMinimum(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + cfg := fuseRecoveryLoadpoint() + cfg.MinChargeW, cfg.AllowedStepsW = 1380, nil + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 1000, PhaseMode: "1p", Persistent: true}) + c.SetFuseEVMax(func() (float64, bool) { return 5000, true }) + c.Tick(context.Background(), now) + set, ok := lastSetCurrent(sender.calls) + if !ok || set.power != 0 || len(c.FuseDemands()) != 0 { + t.Fatalf("sub6A request became a charger or battery reservation: command=%+v demand=%+v", set, c.FuseDemands()) + } +} + +func TestUnspecifiedSurplusPhaseKeepsSinglePhaseFuseBound(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + cfg := Config{ID: "garage", DriverName: "charger", MinChargeW: 1380, MaxChargeW: 11040, SurplusOnly: true, + AllowedStepsW: []float64{0, 1380, 2760, 4140, 5520, 6900, 8280, 9660, 11040}} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 20, 20, 20, true }) + // A hold without a phase override inherits surplus mode's auto choice. + // At2760 W that may be a12 A offer on the already20 A phase. + c.SetManualHold(cfg.ID, ManualHold{PowerW: 2760, Persistent: true}) + c.SetFuseEVMax(func() (float64, bool) { return 2760, true }) + c.Tick(context.Background(), now) + set, ok := lastSetCurrent(sender.calls) + if !ok || set.power != 0 { + t.Fatalf("only4 A remains per phase, no legal6 A auto offer fits: %+v", set) + } +} + +func TestUnknownFuseVerdictIsFrozenForAllCars(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + configs := []Config{fuseRecoveryLoadpoint(), fuseRecoveryLoadpoint()} + configs[0].ID, configs[0].DriverName = "a", "charger-a" + configs[1].ID, configs[1].DriverName = "b", "charger-b" + sender := &fakeSender{} + samples := map[string]EVSample{"charger-a": {Connected: true, RequestActive: true}, "charger-b": {Connected: true, RequestActive: true}} + c := newTestController(t, configs, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + for _, cfg := range configs { + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true}) + } + reads := 0 + c.SetFuseEVMax(func() (float64, bool) { + reads++ + return 5000, reads > 1 + }) + c.Tick(context.Background(), now) + if reads != 1 { + t.Fatalf("one unknown verdict became different per-car budgets: reads=%d", reads) + } + // (_, false) keeps its existing no-constraint meaning for the whole + // first tick. Only the next tick may adopt the new shared5000 W cap. + var firstTotal float64 + for _, cmd := range sender.calls { + if cmd.action == "ev_set_current" { + firstTotal += cmd.power + } + } + if firstTotal != 8280 { + t.Fatalf("unknown verdict changed its no-constraint meaning: %.0f W", firstTotal) + } + sender.calls = nil + c.Tick(context.Background(), now.Add(5*time.Second)) + var nextTotal float64 + for _, cmd := range sender.calls { + if cmd.action == "ev_set_current" { + nextTotal += cmd.power + } + } + if reads != 2 || nextTotal <= 0 || nextTotal > 5000 { + t.Fatalf("next known budget was not shared: reads=%d total=%.0f W", reads, nextTotal) + } +} + +func TestManualCurrentCeilingLeavesFuseRoomForSecondCar(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + configs := []Config{fuseRecoveryLoadpoint(), fuseRecoveryLoadpoint()} + configs[0].ID, configs[0].DriverName = "a", "charger-a" + configs[1].ID, configs[1].DriverName = "b", "charger-b" + sender := &fakeSender{} + samples := map[string]EVSample{"charger-a": {Connected: true, RequestActive: true}, "charger-b": {Connected: true, RequestActive: true}} + c := newTestController(t, configs, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetFuseEVMax(func() (float64, bool) { return 9000, true }) + c.SetManualHold("a", ManualHold{PowerW: 10350, PhaseMode: "3p", MaxAmpsPerPhase: 6, Persistent: true}) + c.SetManualHold("b", ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true}) + c.Tick(context.Background(), now) + commands := map[string]float64{} + for _, cmd := range sender.calls { + if cmd.action == "ev_set_current" { + commands[cmd.driver] = cmd.power + } + } + if commands["charger-a"] != 4140 || commands["charger-b"] != 4140 { + t.Fatalf("current-limited car reserved unused fuse room: %+v", commands) + } +} diff --git a/go/internal/loadpoint/manual_limits.go b/go/internal/loadpoint/manual_limits.go index 7d497af1c..c1392c12a 100644 --- a/go/internal/loadpoint/manual_limits.go +++ b/go/internal/loadpoint/manual_limits.go @@ -5,15 +5,54 @@ import "math" // A manual selection is a ceiling. Never round it up to a larger step or // let it bypass the configured charger's rating. func clampManualPower(cfg Config, hold ManualHold, site SiteFuse) float64 { + minimum := manualMinChargeW(cfg, hold, site) + w := floorChargeW(hold.PowerW, minimum, cfg.MaxChargeW, cfg.AllowedStepsW) + amps := hold.MaxAmpsPerPhase + if site.MaxAmps > 0 && (!finite(amps) || amps <= 0 || amps > site.MaxAmps) { + amps = site.MaxAmps + } + if amps <= 0 { + return w + } + mode := hold.PhaseMode + if mode == "" { + mode = cfg.PhaseMode + } + if site.Phases() == 1 { + mode = "1p" + } else if mode == "" && cfg.SurplusOnly { + mode = "auto" + } + voltage := site.Voltage + if voltage <= 0 { + voltage = hold.Voltage + } + split := hold.PhaseSplitW + if split <= 0 { + split = cfg.PhaseSplitW + } + cmd := map[string]any{"power_w": w, "phase_mode": mode, "voltage": voltage, "phase_split_w": split, "max_amps_per_phase": amps} + // A current reduction may move an auto offer from three phases to one. + // Apply the same final ceiling until that phase choice is stable. + for applyCurrentCeiling(cmd) { + } + return floorChargeW(cmd["power_w"].(float64), minimum, cfg.MaxChargeW, cfg.AllowedStepsW) +} + +func manualMinChargeW(cfg Config, hold ManualHold, site SiteFuse) float64 { floor := cfg.MinChargeW mode := hold.PhaseMode if mode == "" { mode = cfg.PhaseMode } if (mode == "1p" || mode == "auto") && site.Phases() == 3 { - floor /= 3 + voltage := site.Voltage + if !finite(voltage) || voltage <= 0 { + voltage = 230 + } + floor = math.Max(floor/3, 6*voltage) } - return floorChargeW(hold.PowerW, floor, cfg.MaxChargeW, cfg.AllowedStepsW) + return floor } func (c *Controller) applyInstallationLimits(cmd map[string]any) { diff --git a/go/internal/telemetry/command_evidence.go b/go/internal/telemetry/command_evidence.go index bdd10588d..9dc020b48 100644 --- a/go/internal/telemetry/command_evidence.go +++ b/go/internal/telemetry/command_evidence.go @@ -8,7 +8,8 @@ import ( // CommandEvidence records a driver call, not proof that hardware obeyed it. // Keep one command per device function so a hybrid's PV cap cannot replace -// its battery command. The registry clears these records on driver restart. +// its battery command. A restart clears response proof; outstanding battery +// orders retain only their small unconfirmed bound until raw power replaces it. // // A device keeps one measurement record while FTW sends it the same action. // Modes that retune the target every tick must not restart the proof on each @@ -40,6 +41,17 @@ type CommandEvidence struct { // household load cannot rewrite the site comparison for that step. StepAfter map[string]ControlBaseline StepAfterAt time.Time + // A battery order can reach hardware after a newer, lower order. Keep + // its highest signed target even when Recent evicts it. Only a fresh + // source observation after the lower run's response delay can reduce it. + // This is command evidence, not a second power history or a live reading. + batteryUpperW float64 + batteryUpperKnown bool + batteryLowerW float64 + batteryLowerSince time.Time + batteryReleased bool + batteryOrderAt time.Time + cleared bool } // CommandPoint is one command FTW sent, kept for the response-delay check. @@ -105,6 +117,18 @@ func (s *Store) BeginCommand(driver string, payload []byte, now time.Time) Comma } k := driver + ":" + kind old, ok := s.commands[k] + if kind == "battery" { + c.batteryOrderAt = now + c.batteryUpperW, c.batteryUpperKnown = old.batteryUpperW, old.batteryUpperKnown + c.batteryLowerW, c.batteryLowerSince = old.batteryLowerW, old.batteryLowerSince + if c.PowerW != nil { + if !c.batteryUpperKnown || *c.PowerW >= c.batteryUpperW { + c.batteryUpperW, c.batteryUpperKnown, c.batteryLowerSince = *c.PowerW, true, time.Time{} + } else if c.batteryLowerSince.IsZero() || *c.PowerW > c.batteryLowerW { + c.batteryLowerW, c.batteryLowerSince = *c.PowerW, now + } + } + } if ok && old.Result != "released" { c.LastMeasuredAt = old.LastMeasuredAt } @@ -169,6 +193,9 @@ func (s *Store) CommandEvidence(driver, kind string) (CommandEvidence, bool) { s.mu.RLock() defer s.mu.RUnlock() c, ok := s.commands[driver+":"+kind] + if c.cleared { + return CommandEvidence{}, false + } c.Baseline, c.StepAfter = copyWindows(c.Baseline), copyWindows(c.StepAfter) c.Recent = append([]CommandPoint(nil), c.Recent...) for _, p := range []**float64{&c.PowerW, &c.LastGapW} { @@ -183,9 +210,42 @@ func (s *Store) CommandEvidence(driver, kind string) (CommandEvidence, bool) { func (s *Store) ClearCommandEvidence(driver string) { s.mu.Lock() defer s.mu.Unlock() + s.clearCommandEvidenceLocked(driver, time.Now()) +} + +// BatteryCommandUpperW is the highest battery target not yet replaced by +// source-time power evidence. Failed calls and driver resets do not prove +// that an order failed to reach hardware. A newer computed offer also needs +// room while it waits for the driver actor; an earlier default/reset retires +// old computed offers. Offers are not retained as measurements or commands. +func (s *Store) BatteryCommandUpperW(driver string, offers ...CommandPoint) (float64, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + c := s.commands[driver+":battery"] + upper := c.batteryUpperW + for _, offer := range offers { + if c.batteryUpperKnown && offer.At.After(c.batteryOrderAt) { + upper = math.Max(upper, offer.PowerW) + } + } + return upper, c.batteryUpperKnown +} + +// Called under mu. Drop response/UI evidence on restart, but retain the +// small outstanding order bound until a new raw observation clears it. +func (s *Store) clearCommandEvidenceLocked(driver string, now time.Time) { for k, c := range s.commands { if c.Driver == driver { - delete(s.commands, k) + if c.Kind == "battery" && c.batteryUpperKnown { + since := c.batteryLowerSince + if !c.batteryReleased || since.IsZero() { + since = now + } + s.commands[k] = CommandEvidence{Driver: driver, Kind: "battery", Result: "released", cleared: true, + batteryUpperW: math.Max(0, c.batteryUpperW), batteryUpperKnown: true, batteryLowerSince: since, batteryReleased: true, batteryOrderAt: now} + } else { + delete(s.commands, k) + } } } } @@ -193,12 +253,27 @@ func (s *Store) ClearCommandEvidence(driver string) { // EndCommandControl invalidates comparisons with an earlier command when the // actor returns the device to its own control. Keep failed attempts visible. func (s *Store) EndCommandControl(driver string, failed bool) { + s.endCommandControlAt(driver, failed, time.Now()) +} + +func (s *Store) endCommandControlAt(driver string, failed bool, now time.Time) { s.mu.Lock() defer s.mu.Unlock() for k, c := range s.commands { if c.Driver != driver { continue } + if c.Kind == "battery" && c.batteryUpperKnown { + // Returning to autonomous control can also stop discharge. A + // successful default call is still not a measured response. + c.batteryUpperW = math.Max(0, c.batteryUpperW) + if !c.batteryReleased || c.batteryLowerSince.IsZero() { + c.batteryLowerSince = now + } + c.batteryLowerW = 0 + c.batteryReleased = true + c.batteryOrderAt = now + } if failed { c.Result = "default_failed" } else if c.Result == "accepted" || c.Result == "pending" || c.Result == "default_failed" { @@ -255,7 +330,31 @@ func (c CommandEvidence) commandGap(power float64, at time.Time) float64 { func (s *Store) observeCommand(driver string, kind DerType, power float64, data json.RawMessage, now time.Time) { k := driver + ":" + kind.String() c, ok := s.commands[k] - if !ok || c.Result != "accepted" || c.PowerW == nil { + if !ok { + return + } + if kind == DerBattery && c.batteryUpperKnown && !c.batteryLowerSince.IsZero() { + observation, fresh := ControlPowerObservation(power, data, now) + if fresh && !observation.Seen().Before(c.batteryLowerSince.Add(ResponseDelay("battery"))) && + !observation.Seen().After(now) && now.Sub(observation.Seen()) <= ControlWindowDuration { + // The lower run's maximum also covers intermediate orders that + // may not yet have arrived, even when Recent no longer has them. + upper := math.Max(c.batteryLowerW, observation.PowerW) + if c.batteryReleased { + upper = math.Max(0, upper) + } else if c.PowerW != nil { + upper = math.Max(*c.PowerW, upper) + } + if upper < c.batteryUpperW { + c.batteryUpperW = upper + if upper <= c.batteryLowerW { + c.batteryLowerSince = time.Time{} + } + } + } + s.commands[k] = c + } + if c.Result != "accepted" || c.PowerW == nil || c.cleared { return } var d struct { diff --git a/go/internal/telemetry/command_pending_battery_test.go b/go/internal/telemetry/command_pending_battery_test.go new file mode 100644 index 000000000..92a0e82a3 --- /dev/null +++ b/go/internal/telemetry/command_pending_battery_test.go @@ -0,0 +1,161 @@ +package telemetry + +import ( + "encoding/json" + "fmt" + "testing" + "time" +) + +func pendingBatteryOrder(s *Store, watts float64, at time.Time, result string) { + payload := []byte(fmt.Sprintf(`{"action":"battery","power_w":%g}`, watts)) + c := s.BeginCommand("battery", payload, at) + s.CompleteCommand(c, result) +} + +func pendingBatterySample(s *Store, rawW float64, sourceAt, receivedAt time.Time, extra map[string]any) { + d := map[string]any{"control_power_observed_at": sourceAt.Format(time.RFC3339Nano)} + for k, v := range extra { + d[k] = v + } + data, _ := json.Marshal(d) + s.mu.Lock() + s.observeCommand("battery", DerBattery, rawW, data, receivedAt) + s.mu.Unlock() +} + +func requireBatteryUpper(t *testing.T, s *Store, want float64) { + t.Helper() + if got, known := s.BatteryCommandUpperW("battery"); !known || got != want { + t.Fatalf("battery order upper=%v known=%v; want %v", got, known, want) + } +} + +func TestBatteryCommandUpperRetainsUnconfirmedOrder(t *testing.T) { + for _, result := range []string{"pending", "accepted", "failed", "unknown"} { + t.Run(result, func(t *testing.T) { + s, start := NewStore(), time.Now() + pendingBatteryOrder(s, 8000, start, result) + // More than Recent's eight entries, all lower and all successful. + for i := 1; i <= 12; i++ { + pendingBatteryOrder(s, 0, start.Add(time.Duration(i)*time.Second), "accepted") + } + requireBatteryUpper(t, s, 8000) + // Receipt time and a setpoint echo cannot replace source time. + pendingBatterySample(s, 0, start.Add(2*time.Second), start.Add(20*time.Second), map[string]any{"setpoint_w": 0}) + requireBatteryUpper(t, s, 8000) + pendingBatterySample(s, 0, start.Add(15*time.Second), start.Add(20*time.Second), nil) + requireBatteryUpper(t, s, 8000) + // AC power, rather than a different DC power, proves the lower run. + pendingBatterySample(s, 2500, start.Add(16*time.Second), start.Add(20*time.Second), map[string]any{"control_power_w": 0}) + requireBatteryUpper(t, s, 0) + }) + } +} + +func TestBatteryCommandUpperRetainsIntermediateOrder(t *testing.T) { + s, start := NewStore(), time.Now() + pendingBatteryOrder(s, 8000, start, "accepted") + pendingBatteryOrder(s, 3000, start.Add(time.Second), "failed") + for i := 2; i <= 12; i++ { + pendingBatteryOrder(s, 0, start.Add(time.Duration(i)*time.Second), "accepted") + } + // The 3 kW order may still arrive even though it left Recent. + pendingBatterySample(s, 0, start.Add(16*time.Second), start.Add(16*time.Second), nil) + requireBatteryUpper(t, s, 3000) + for i := 17; i <= 32; i++ { + pendingBatteryOrder(s, 0, start.Add(time.Duration(i)*time.Second), "accepted") + } + pendingBatterySample(s, 0, start.Add(32*time.Second), start.Add(32*time.Second), nil) + requireBatteryUpper(t, s, 0) +} + +func TestBatteryCommandUpperWaitsForTheLowerRun(t *testing.T) { + s, start := NewStore(), time.Now() + pendingBatteryOrder(s, 8000, start.Add(-time.Minute), "accepted") + pendingBatteryOrder(s, 0, start, "accepted") + // The high order is old, but this source sample predates the new lower run. + pendingBatterySample(s, 0, start.Add(-time.Second), start.Add(20*time.Second), nil) + requireBatteryUpper(t, s, 8000) + for _, d := range []map[string]any{ + {"control_power_available": false}, + {"control_power_observed_at": "bad"}, + {"control_power_observed_at": start.Add(time.Hour).Format(time.RFC3339Nano)}, + } { + pendingBatterySample(s, 0, start.Add(20*time.Second), start.Add(20*time.Second), d) + requireBatteryUpper(t, s, 8000) + } + pendingBatterySample(s, 0, start.Add(20*time.Second), start.Add(20*time.Second), nil) + requireBatteryUpper(t, s, 0) +} + +func TestBatteryCommandUpperSurvivesDefaultAndReset(t *testing.T) { + for _, mode := range []string{"default", "failed default", "failed order then default", "unknown order then default", "clear", "remove"} { + t.Run(mode, func(t *testing.T) { + s, start := NewStore(), time.Now() + pendingBatteryOrder(s, 8000, start.Add(-time.Minute), "accepted") + if mode == "failed order then default" { + pendingBatteryOrder(s, 8000, start.Add(-time.Second), "failed") + } else if mode == "unknown order then default" { + pendingBatteryOrder(s, 8000, start.Add(-time.Second), "unknown") + } + switch mode { + case "default", "failed default", "failed order then default", "unknown order then default": + s.endCommandControlAt("battery", mode == "failed default", start) + case "clear": + s.ClearCommandEvidence("battery") + case "remove": + s.Remove("battery") + } + requireBatteryUpper(t, s, 8000) + if mode == "clear" || mode == "remove" { + if _, visible := s.CommandEvidence("battery", "battery"); visible { + t.Fatal("reset retained response/UI evidence") + } + } + pendingBatterySample(s, 0, start.Add(10*time.Second), start.Add(20*time.Second), nil) + requireBatteryUpper(t, s, 8000) + pendingBatterySample(s, 4000, start.Add(20*time.Second), start.Add(20*time.Second), nil) + requireBatteryUpper(t, s, 4000) + pendingBatterySample(s, 0, start.Add(40*time.Second), start.Add(40*time.Second), nil) + requireBatteryUpper(t, s, 0) + }) + } +} + +func TestBatteryCommandUpperRepeatedDefaultDoesNotRestartWait(t *testing.T) { + for _, failed := range []bool{false, true} { + s, start := NewStore(), time.Now() + pendingBatteryOrder(s, 8000, start, "accepted") + for i := 1; i <= 12; i++ { + at := start.Add(time.Duration(i) * 5 * time.Second) + s.endCommandControlAt("battery", failed, at) + pendingBatterySample(s, 0, at, at, nil) + } + requireBatteryUpper(t, s, 0) + } +} + +func TestBatteryDefaultCanStopDischarge(t *testing.T) { + s, start := NewStore(), time.Now() + pendingBatteryOrder(s, -5000, start, "accepted") + s.endCommandControlAt("battery", true, start.Add(time.Second)) + requireBatteryUpper(t, s, 0) +} + +func TestBatteryCommandUpperIncludesQueuedOfferAfterItsLastOrder(t *testing.T) { + s, start := NewStore(), time.Now() + pendingBatteryOrder(s, 0, start, "accepted") + offer := CommandPoint{PowerW: 8000, At: start.Add(time.Second)} + if upper, _ := s.BatteryCommandUpperW("battery", offer); upper != 8000 { + t.Fatalf("computed offer waiting for driver actor lost: %.0fW", upper) + } + s.endCommandControlAt("battery", false, start.Add(2*time.Second)) + if upper, _ := s.BatteryCommandUpperW("battery", offer); upper != 0 { + t.Fatalf("default retained an older computed offer: %.0fW", upper) + } + offer.At = start.Add(3 * time.Second) + if upper, _ := s.BatteryCommandUpperW("battery", offer); upper != 8000 { + t.Fatalf("new computed offer after default lost: %.0fW", upper) + } +} diff --git a/go/internal/telemetry/control_observations.go b/go/internal/telemetry/control_observations.go index 50f97d356..59612953f 100644 --- a/go/internal/telemetry/control_observations.go +++ b/go/internal/telemetry/control_observations.go @@ -30,9 +30,9 @@ type ControlBaseline struct { const ControlWindowDuration = 20 * time.Second -// Caller holds mu. At most 64 points per DER. EVs retain every new source -// sample: dropping an observed stop could create false replacement credit. -// Other DERs retain at most one point per second for response checks. +// Caller holds mu. At most 64 points per DER. Controlled consumers retain +// every new source sample: dropping an observed stop could create false +// replacement credit. Other DERs keep at most one point per second. func (s *Store) recordControlObservation(driver string, kind DerType, power float64, data json.RawMessage, now time.Time) { if kind == DerVehicle { return @@ -52,7 +52,7 @@ func (s *Store) recordControlObservation(driver string, kind DerType, power floa if len(points) > 0 && !at.After(points[len(points)-1].At) { return } - if kind != DerEV && len(points) > 0 && at.Unix() == points[len(points)-1].At.Unix() { + if kind != DerEV && kind != DerBattery && kind != DerV2X && len(points) > 0 && at.Unix() == points[len(points)-1].At.Unix() { points[len(points)-1] = p } else { points = append(points, p) diff --git a/go/internal/telemetry/control_observations_test.go b/go/internal/telemetry/control_observations_test.go index f3e640504..4c8f1b4ae 100644 --- a/go/internal/telemetry/control_observations_test.go +++ b/go/internal/telemetry/control_observations_test.go @@ -6,6 +6,27 @@ import ( "time" ) +func TestControlObservationsRetainBatteryAndV2XStopsWithinASecond(t *testing.T) { + for _, kind := range []DerType{DerBattery, DerV2X} { + t.Run(kind.String(), func(t *testing.T) { + s, at := NewStore(), time.Now().Truncate(time.Second) + for i, w := range []float64{8000, 0, 8000} { + s.recordControlObservation("device", kind, w, nil, at.Add(time.Duration(i+1)*100*time.Millisecond)) + } + points := s.controlObservations[key("device", kind)] + if len(points) != 3 || points[1].PowerW != 0 { + t.Fatalf("lost an observed stop within one second: %+v", points) + } + for i := 0; i < 100; i++ { + s.recordControlObservation("device", kind, 8000, nil, at.Add(400*time.Millisecond+time.Duration(i)*time.Millisecond)) + } + if n := len(s.controlObservations[key("device", kind)]); n != 64 { + t.Fatalf("raw ring has %d points, want bounded 64", n) + } + }) + } +} + func TestControlWindowsAreBoundedAndMissingDataBreaksProof(t *testing.T) { s := NewStore() now := time.Now() diff --git a/go/internal/telemetry/store.go b/go/internal/telemetry/store.go index 427d04353..216101e2f 100644 --- a/go/internal/telemetry/store.go +++ b/go/internal/telemetry/store.go @@ -757,7 +757,8 @@ func (s *Store) RecordDriverError(name, err string) { // rendering the stale card. A replacement under the same name that no // longer emits a metric (e.g. site-meter phase currents) must not inherit // the old, ageing snapshot, which would read as stale and block dispatch. -// Historical TS-DB samples are NOT touched; they stay queryable. +// A battery's outstanding order bound survives until raw power confirms its +// replacement. Historical TS-DB samples stay queryable. func (s *Store) Remove(driver string) { s.mu.Lock() for _, t := range allDerTypes { @@ -773,11 +774,7 @@ func (s *Store) Remove(driver string) { c.Baseline, c.StepAfter = nil, nil s.commands[k] = c } - for k, command := range s.commands { - if command.Driver == driver { - delete(s.commands, k) - } - } + s.clearCommandEvidenceLocked(driver, time.Now()) s.mu.Unlock() prefix := driver + ":" From a24f3bec6448ca2b9742fd3fd6316b06c4dac4d6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 19:27:47 +0200 Subject: [PATCH 2/3] fix(control): reserve partial battery backoffs across siblings Signed-off-by: Fredrik Ahlgren --- go/internal/control/ev_battery_headroom.go | 19 ++++++++++++ .../control/ev_battery_headroom_test.go | 31 ++++++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 8e2f0d71a..209873f17 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -260,6 +260,25 @@ func capBatteryOffersToHeadroom(targets []DispatchTarget, baselines, uppers map[ } } } + // A partial positive reduction also leaves its old higher order pending. + // Bound every offer against the same snapshot of its siblings; one + // commanded reduction must not fund another offer in this pass. + covered := make([]float64, len(out)) + var coveredTotal float64 + for i, target := range out { + if target.TargetW > 0 { + covered[i] = math.Max(target.TargetW, math.Max(0, uppers[target.Driver])) + coveredTotal += covered[i] + } + } + 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 } diff --git a/go/internal/control/ev_battery_headroom_test.go b/go/internal/control/ev_battery_headroom_test.go index f3207fcaf..35cf02f44 100644 --- a/go/internal/control/ev_battery_headroom_test.go +++ b/go/internal/control/ev_battery_headroom_test.go @@ -357,8 +357,16 @@ func TestEVBatteryObservedSiblingRiseNeedsRoomWithoutOrder(t *testing.T) { } func TestEVBatteryPendingSiblingBacksOffExistingChargeForEV(t *testing.T) { - for _, siblingTarget := range []bool{false, true} { - t.Run(fmt.Sprint(siblingTarget), func(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}, @@ -366,8 +374,8 @@ func TestEVBatteryPendingSiblingBacksOffExistingChargeForEV(t *testing.T) { old := store.BeginCommand("sibling", []byte(`{"action":"battery","power_w":8000}`), time.Now()) store.CompleteCommand(old, "accepted") targets := []DispatchTarget{{Driver: "battery", TargetW: 4000}} - if siblingTarget { - targets = append(targets, DispatchTarget{Driver: "sibling", TargetW: 0}) + 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 { @@ -377,6 +385,21 @@ func TestEVBatteryPendingSiblingBacksOffExistingChargeForEV(t *testing.T) { } } +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 From 10b8d661a48d823b21ead3cd63a97c56f8dcf4f1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 19:44:05 +0200 Subject: [PATCH 3/3] fix(loadpoint): keep fuse limits aligned with phase changes Signed-off-by: Fredrik Ahlgren --- go/internal/control/ev_fuse_budget.go | 5 +- go/internal/control/ev_fuse_budget_test.go | 26 +++ go/internal/loadpoint/controller.go | 83 ++++++---- .../loadpoint/controller_fuse_budget.go | 36 ++++- .../loadpoint/controller_fuse_demand.go | 4 +- .../loadpoint/controller_fuse_phase_test.go | 149 ++++++++++++++++++ 6 files changed, 263 insertions(+), 40 deletions(-) create mode 100644 go/internal/loadpoint/controller_fuse_phase_test.go diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 2a4d1d2a3..1008b0d84 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -29,6 +29,7 @@ type evFuseBalance struct { // 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. 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 @@ -186,7 +187,9 @@ func evPhaseIncreaseW(meter *telemetry.DerReading, state *State, configs []loadp phases := state.SiteFusePhases unknownPhase := false for _, cfg := range configs { - if cfg.PhaseMode != "3p" { + // Empty retains the config's declared three-phase default. The + // controller's final guard 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 } diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go index 5d9bc436a..718224281 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -2,6 +2,7 @@ package control import ( "encoding/json" + "math" "testing" "time" @@ -9,6 +10,31 @@ 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) + } + }) + } +} + func TestLiveFuseBudgetPhysicalBalance(t *testing.T) { cases := []struct { name string diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 2de085705..fa41d1308 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -55,6 +55,7 @@ type Controller struct { 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 @@ -1262,12 +1263,15 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat observed.wasPlugged = previous.PluggedIn observed.wasDelivering = previous.CurrentPowerW >= DeliveringW c.observeTick(now, observed.config, observed.sample, dispatchAllowed) + if dispatchAllowed && observed.sample.Connected && !observed.sample.ConnectionUnknown && c.driverCanDispatch(observed.config.DriverName) { + c.releaseCompletedManualHold(now, observed.config, observed.sample) + } } if dispatchAllowed { c.tickDirective, c.tickDirectiveOK = c.currentDirective(now) c.tickDirectiveActive = true c.freezeFuseBudget(now, samples) - defer func() { c.tickFuseCaps = nil; c.tickDirectiveActive = false }() + defer func() { c.tickFuseCaps = nil; c.tickFusePhases = 0; c.tickDirectiveActive = false }() } for _, observed := range observations { c.tickOne(ctx, now, observed.config, observed.sample, dispatchAllowed, observed.wasPlugged, observed.wasDelivering) @@ -1295,6 +1299,43 @@ func (c *Controller) observeTick(now time.Time, cfg Config, sample EVSample, dis c.evaluateBatteryBoost(cfg.ID, now, sample.Connected, dispatchAllowed && !sample.PowerUnavailable) } +// Release before freezing the shared budget so its phase matches the order. +func (c *Controller) releaseCompletedManualHold(now time.Time, lpCfg Config, sample EVSample) { + // Auto-release an operator manual hold once the vehicle has stopped + // requesting current (it hit its own charge limit / is full). Without + // this a "Start" hold pins the wallbox at a fixed amperage and the + // loadpoint shows "charging" at 0 W until the operator presses Stop. + // Debounced by SessionCompletionTimeout so a brief ramp/handshake dip + // — or a car that hasn't woken yet — doesn't drop the hold early. Only + // trips for drivers that distinguish "explicitly not requesting" from + // "throttled to 0" (RequestActive); others leave it true and never + // auto-release. Done before the dispatch branch below so the freed + // tick falls straight through to automatic (surplus/plan) dispatch. + if hold, held := c.GetManualHold(lpCfg.ID, now); held && hold.PowerW > 0 { + if !sample.RequestActive { + if c.manualHoldIdleFor(lpCfg.ID, now) >= SessionCompletionTimeout && c.releaseManualHoldIfCurrent(lpCfg.ID, hold) { + slog.Info("loadpoint manual hold auto-released — vehicle stopped requesting current (full/declined)", + "lp", lpCfg.ID, "idle", SessionCompletionTimeout) + } + } else { + c.resetManualIdle(lpCfg.ID) + } + } + + // Release a "charge now" hold at its target SoC. The operator asked + // for immediate charge up to a level, not a pin-forever: clearing + // here lets this same tick fall straight through to automatic + // surplus/plan dispatch instead of holding the wallbox at a fixed + // amperage the rest of the session. + if hold, held := c.GetManualHold(lpCfg.ID, now); held && hold.ReleaseAtSoC > 0 { + if st, ok := c.manager.State(lpCfg.ID); ok && st.CurrentSoC >= hold.ReleaseAtSoC && c.releaseManualHoldIfCurrent(lpCfg.ID, hold) { + slog.Info("loadpoint manual hold released — charge-now target reached", + "lp", lpCfg.ID, "soc", st.CurrentSoC, "release_at_soc", hold.ReleaseAtSoC) + } + } + +} + func (c *Controller) standDownBeforeStorage(ctx context.Context, now time.Time, cfg Config, sample EVSample) { var manualUpdatedAt time.Time if hold, held := c.GetManualHold(cfg.ID, now); held { @@ -1381,39 +1422,6 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s go c.wakeVehicleAuto(context.Background(), lpCfg.ID, "delivering_edge") } - // Auto-release an operator manual hold once the vehicle has stopped - // requesting current (it hit its own charge limit / is full). Without - // this a "Start" hold pins the wallbox at a fixed amperage and the - // loadpoint shows "charging" at 0 W until the operator presses Stop. - // Debounced by SessionCompletionTimeout so a brief ramp/handshake dip - // — or a car that hasn't woken yet — doesn't drop the hold early. Only - // trips for drivers that distinguish "explicitly not requesting" from - // "throttled to 0" (RequestActive); others leave it true and never - // auto-release. Done before the dispatch branch below so the freed - // tick falls straight through to automatic (surplus/plan) dispatch. - if hold, held := c.GetManualHold(lpCfg.ID, now); held && hold.PowerW > 0 { - if !sample.RequestActive { - if c.manualHoldIdleFor(lpCfg.ID, now) >= SessionCompletionTimeout && c.releaseManualHoldIfCurrent(lpCfg.ID, hold) { - slog.Info("loadpoint manual hold auto-released — vehicle stopped requesting current (full/declined)", - "lp", lpCfg.ID, "idle", SessionCompletionTimeout) - } - } else { - c.resetManualIdle(lpCfg.ID) - } - } - - // Release a "charge now" hold at its target SoC. The operator asked - // for immediate charge up to a level, not a pin-forever: clearing - // here lets this same tick fall straight through to automatic - // surplus/plan dispatch instead of holding the wallbox at a fixed - // amperage the rest of the session. - if hold, held := c.GetManualHold(lpCfg.ID, now); held && hold.ReleaseAtSoC > 0 { - if st, ok := c.manager.State(lpCfg.ID); ok && st.CurrentSoC >= hold.ReleaseAtSoC && c.releaseManualHoldIfCurrent(lpCfg.ID, hold) { - slog.Info("loadpoint manual hold released — charge-now target reached", - "lp", lpCfg.ID, "soc", st.CurrentSoC, "release_at_soc", hold.ReleaseAtSoC) - } - } - cmd := map[string]any{"action": "ev_set_current"} // cmdReason names the branch that decides this tick's power_w; every // clamp that overrides the value overrides the reason with it. Fed @@ -1605,6 +1613,13 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s cmdReason = reason } } + // A new API choice can change phase after the shared budget was frozen. + // Do not spend a 3p share on one phase; the next tick uses the new choice. + if w, ok := cmd["power_w"].(float64); ok && w > 0 && c.tickFusePhases > 1 { + if mode, _ := cmd["phase_mode"].(string); mode != "" && mode != "3p" { + cmd["power_w"], cmdReason = float64(0), "fuse_limit" + } + } c.applyPerPhaseFuseClamp(lpCfg, cmd) if applyCurrentCeiling(cmd) { cmdReason = "fuse_limit" diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index d7a891b11..5682ad37a 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -19,6 +19,7 @@ type fuseRequest struct { // already offered share. Metered reductions earn new room only next tick. func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample) { c.tickFuseCaps = nil + c.tickFusePhases = 0 if c.fuseEVMax == nil { clear(c.fuseResumeTicks) return @@ -145,15 +146,13 @@ func (c *Controller) sharedPhaseIncreaseW(now time.Time, requests []fuseRequest, phaseCount := site.Phases() unknownPhase := false for _, request := range requests { - mode := request.cfg.PhaseMode - if hold, ok := c.GetManualHold(request.cfg.ID, now); ok && hold.PhaseMode != "" { - mode = hold.PhaseMode - } + mode := c.phaseModeForFuse(now, request.cfg) if mode != "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 { @@ -162,6 +161,35 @@ func (c *Controller) sharedPhaseIncreaseW(now time.Time, requests []fuseRequest, return deltaA * site.Voltage * float64(phaseCount), true } +// Empty is the driver's locked-3p default unless the command branch selects +// auto or the surplus day lock selects 1p. Do not advance surplus hysteresis +// here: an enabled unlock may arm later in this tick, so retain its 1p bound. +func (c *Controller) phaseModeForFuse(now time.Time, cfg Config) string { + sched, _ := c.manager.GetSchedule(cfg.ID) + surplus := cfg.SurplusOnly + if !sched.HasTarget() && sched.SurplusUnlockBatSoC > 0 && c.batSoC != nil && + !(c.tickDirectiveActive && c.tickDirectiveOK && c.tickDirective.LoadpointFallback[cfg.ID]) { + surplus = true + } + mode := cfg.PhaseMode + if hold, held := c.GetManualHold(cfg.ID, now); held { + if hold.PhaseMode != "" { + mode = hold.PhaseMode + } + if mode == "" && surplus { + mode = "auto" + } + } else { + // A surplus dwell can change while the offer is computed. Only a + // fixed 3p mode earns three phases of new headroom in this guard. + mode = resolvePhaseMode(mode, sched.HasTarget(), c.surplusLockedTo1P(cfg.ID), surplus, "") + } + if mode == "" { + return "3p" + } + return mode +} + func (c *Controller) currentDirective(now time.Time) (Directive, bool) { if c.tickDirectiveActive { return c.tickDirective, c.tickDirectiveOK diff --git a/go/internal/loadpoint/controller_fuse_demand.go b/go/internal/loadpoint/controller_fuse_demand.go index ab6668b68..23e7310ad 100644 --- a/go/internal/loadpoint/controller_fuse_demand.go +++ b/go/internal/loadpoint/controller_fuse_demand.go @@ -29,7 +29,9 @@ func (c *Controller) recordFuseDemand(driver string, powerW float64, phaseMode s if driver == "" || !finite(powerW) || powerW <= 0 { return } - if phaseMode != "1p" && phaseMode != "3p" { + if phaseMode == "" { + phaseMode = "3p" // The completed command leaves the driver's default in force. + } else if phaseMode != "1p" && phaseMode != "3p" { phaseMode = "auto" } c.fuseDemandMu.Lock() diff --git a/go/internal/loadpoint/controller_fuse_phase_test.go b/go/internal/loadpoint/controller_fuse_phase_test.go new file mode 100644 index 000000000..b023b4799 --- /dev/null +++ b/go/internal/loadpoint/controller_fuse_phase_test.go @@ -0,0 +1,149 @@ +package loadpoint + +import ( + "context" + "math" + "testing" + "time" +) + +func TestSharedFuseGuardUsesCommandPhase(t *testing.T) { + for _, tc := range []struct { + name, configMode, holdMode, commandMode, demandMode string + manual, schedule, surplus bool + wantW, worstA, commandW float64 + }{ + {name: "legacy_plan", wantW: 13110, worstA: 5, commandW: 13110, demandMode: "3p"}, + {name: "legacy_manual", manual: true, wantW: 13110, worstA: 5, commandW: 13110, demandMode: "3p"}, + {name: "legacy_manual_over_schedule", manual: true, schedule: true, wantW: 13110, worstA: 5, commandW: 13110, demandMode: "3p"}, + {name: "scheduled_auto", schedule: true, wantW: 2760, worstA: 20, commandMode: "auto", demandMode: "auto"}, + {name: "surplus_manual_auto", manual: true, surplus: true, wantW: 2760, worstA: 20, commandMode: "auto", demandMode: "auto"}, + {name: "manual_1p_over_3p", configMode: "3p", manual: true, holdMode: "1p", wantW: 2760, worstA: 20, commandMode: "1p", demandMode: "1p"}, + {name: "manual_3p_over_auto", configMode: "auto", manual: true, holdMode: "3p", wantW: 13110, worstA: 5, commandW: 13110, commandMode: "3p", demandMode: "3p"}, + } { + t.Run(tc.name, func(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + cfg := Config{ID: "garage", DriverName: "charger", MinChargeW: 1380, MaxChargeW: 13110, + AllowedStepsW: []float64{0, 1380, 2760, 4140, 13110}, PhaseMode: tc.configMode, SurplusOnly: tc.surplus} + d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), + LoadpointEnergyWh: map[string]float64{cfg.ID: tc.wantW / 4}, LoadpointMaxPowerW: map[string]float64{cfg.ID: tc.wantW}} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, d, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return tc.worstA, tc.worstA, tc.worstA, true }) + c.SetFuseEVMax(func() (float64, bool) { return 20000, true }) + if tc.schedule { + c.manager.SetSchedule(cfg.ID, Schedule{SoC: .8, TimeOfDayMinUTC: 13 * 60, Recurring: true}) + } + if tc.manual { + c.SetManualHold(cfg.ID, ManualHold{PowerW: tc.wantW, PhaseMode: tc.holdMode, Persistent: true}) + } + c.Tick(context.Background(), now) + command, known := lastSetCurrent(sender.calls) + if !known || math.Abs(command.power-tc.commandW) > .01 || command.phaseMode != tc.commandMode { + t.Fatalf("command=%+v, want %.0f W with phase %q", command, tc.commandW, tc.commandMode) + } + if phases := PhaseFor(command.phaseMode, command.power, 3680); command.power > 0 && tc.worstA+command.power/(230*float64(phases)) > 24.001 { + t.Fatalf("resolved %dp command crosses the phase working limit: %+v", phases, command) + } + demand := c.FuseDemands() + if len(demand) != 1 || demand[0].PhaseMode != tc.demandMode { + t.Fatalf("command phase and battery reservation disagree: %+v, want %q", demand, tc.demandMode) + } + }) + } +} + +func TestAutomaticManualReleasePrecedesSharedPhaseBudget(t *testing.T) { + for _, release := range []string{"soc", "idle"} { + t.Run(release, func(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + cfg := Config{ID: "garage", DriverName: "charger", MinChargeW: 1380, MaxChargeW: 13110, + AllowedStepsW: []float64{0, 1380, 2760, 4140, 13110}, VehicleCapacityWh: 60000, PluginSoC: .8} + d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), + LoadpointEnergyWh: map[string]float64{cfg.ID: 690}, LoadpointMaxPowerW: map[string]float64{cfg.ID: 2760}} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: release != "idle"}} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, d, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 20, 20, 20, true }) + c.SetFuseEVMax(func() (float64, bool) { return 2070, true }) + c.manager.SetSchedule(cfg.ID, Schedule{SoC: .9, TimeOfDayMinUTC: 13 * 60, Recurring: true}) + hold := ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true} + if release == "soc" { + hold.ReleaseAtSoC = .8 + } + c.SetManualHold(cfg.ID, hold) + if release == "idle" { + c.Tick(context.Background(), now.Add(-SessionCompletionTimeout)) + if _, active := c.GetManualHold(cfg.ID, now); !active { + t.Fatal("idle hold released before its debounce elapsed") + } + } + c.Tick(context.Background(), now) + if _, active := c.GetManualHold(cfg.ID, now); active { + t.Fatal("completed manual hold was not released") + } + command, known := lastSetCurrent(sender.calls) + if !known || command.phaseMode != "auto" || command.power != 0 { + t.Fatalf("automatic order reused the earlier manual 3p budget: %+v", command) + } + }) + } +} + +func TestManualPhaseChangeAfterSharedBudgetWaitsForNextTick(t *testing.T) { + for _, tc := range []struct { + name, beforeMode, afterMode string + beforeW, afterW, worstA, commandW float64 + }{ + {"3p_to_1p", "3p", "1p", 4140, 1380, 20, 0}, + {"3p_to_auto", "3p", "auto", 4140, 1380, 20, 0}, + {"new_stop", "3p", "3p", 4140, 0, 20, 0}, + {"1p_to_3p", "1p", "3p", 1380, 4140, 17, 1380}, + } { + t.Run(tc.name, func(t *testing.T) { + now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + a := Config{ID: "first", DriverName: "first", MinChargeW: 1380, MaxChargeW: 13110, + AllowedStepsW: []float64{0, 1380, 2760, 4140, 13110}} + b := a + b.ID, b.DriverName = "second", "second" + d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute)} + samples := map[string]EVSample{a.DriverName: {Connected: true, RequestActive: true}, b.DriverName: {Connected: true, RequestActive: true}} + sender := &fakeSender{} + c := newTestController(t, []Config{a, b}, d, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + worstA := tc.worstA + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return worstA, worstA, worstA, true }) + c.SetFuseEVMax(func() (float64, bool) { return 2070, true }) + c.SetManualHold(a.ID, ManualHold{PhaseMode: "3p", Persistent: true}) + c.SetManualHold(b.ID, ManualHold{PowerW: tc.beforeW, PhaseMode: tc.beforeMode, Persistent: true}) + c.send = func(ctx context.Context, driver string, payload []byte) error { + err := sender.Send(ctx, driver, payload) + if driver == a.DriverName { + c.SetManualHold(b.ID, ManualHold{PowerW: tc.afterW, PhaseMode: tc.afterMode, Persistent: true}) + } + return err + } + c.Tick(context.Background(), now) + command, known := lastSetCurrent(sender.calls) + if !known || command.driver != b.DriverName || command.power != tc.commandW || command.phaseMode != tc.afterMode { + t.Fatalf("new choice used another phase's frozen budget: %+v", command) + } + for _, sent := range sender.calls { + if sent.driver == a.DriverName && sent.power != 0 { + t.Fatal("first car's Stop was overridden") + } + } + if tc.beforeMode == "3p" && tc.afterW > 0 { + worstA = 17 + c.Tick(context.Background(), now.Add(5*time.Second)) + command, known = lastSetCurrent(sender.calls) + if !known || command.power != 1380 || command.phaseMode != tc.afterMode { + t.Fatalf("new phase did not recover with its next safe budget: %+v", command) + } + } + }) + } +}