diff --git a/.changeset/ev-unrealized-offer-reserve.md b/.changeset/ev-unrealized-offer-reserve.md new file mode 100644 index 000000000..37978c85c --- /dev/null +++ b/.changeset/ev-unrealized-offer-reserve.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +Keep optional battery charging out of the power still needed by an EV whose accepted current offer is not fully delivered, including Charge now. Missing or unaligned EV measurements retain the full reservation; EV starts and increases still wait for measured site headroom. + +Keep the measured phase reservation active while a managed EV pause propagates, and do not release permitted battery relief twice before a prior import increase is observed. Existing fuse protection waits are unchanged. diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 12a867c61..4c69147d8 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -2,20 +2,23 @@ package control import ( "math" + "time" "github.com/srcfl/ftw/go/internal/telemetry" ) // clampEVBatteryHeadroom keeps optional battery charge out of room an EV -// still needs. It reads only the site meter and our own commands; no charger -// or battery reading takes part. The EV still needs its demand less the -// lowest offer of the last evSettle, which the meter surely shows. +// still needs. A stable offer is not proof of delivery. Fresh, aligned EV +// power may reduce the reservation, but never beyond the lowest recent offer. +// This caps optional charge and the removal of permitted fuse relief; it +// grants no EV rise or restart. 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() - // This tick's new offer is not in the meter yet; the lowest logged one is. + // A recent offer rise cannot establish load already in the meter. The + // lowest logged offer is an upper bound on credited measured EV power. offeredW, _, _, ok := state.evOffers.span(now) if !ok { offeredW = 0 @@ -28,14 +31,19 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st phases = 1 } } - if len(state.evFuseDemands) == 0 { + if len(state.evFuseDemands) == 0 && len(state.evCommanded) == 0 { return targets } - needW := math.Max(0, demandW-offeredW) meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - if meter == nil || !finiteFuseValue(meter.RawW) || !finiteFuseValue(fuseMaxW) { + if meter == nil { return floorPositiveEVBatteryTargets(targets) } + meterW := meter.RawW + if !finiteFuseValue(meterW) || !finiteFuseValue(fuseMaxW) { + return floorPositiveEVBatteryTargets(targets) + } + creditedW := realizedEVForBatteryW(store, state, meter, now) + needW := math.Max(0, demandW-math.Min(offeredW, creditedW)) latest := make(map[string]float64, len(targets)) targeted := make(map[string]bool, len(targets)) for _, target := range targets { @@ -51,16 +59,27 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st } } // Battery charge stays under the level the EV may rise to. - spare := state.importRiseCeilingW(fuseMaxW) - meter.RawW - needW + spare := state.importRiseCeilingW(fuseMaxW) - meterW - needW // During measured export overflow, the existing fuse pipeline may // require charge to reduce export. Magnitudes alone cannot label that // worst phase as import; leave this direction to the existing guard. - if meter.RawW >= -state.effectiveExportCeilingW(fuseMaxW) && state.SiteFuseVoltage > 0 { + if meterW >= -state.effectiveExportCeilingW(fuseMaxW) && state.SiteFuseVoltage > 0 { if phaseSpare, known := evBatteryPhaseSpareW(meter, state, needW/(state.SiteFuseVoltage*phases)); known { spare = math.Min(spare, phaseSpare) } } out := capBatteryCharge(targets, latest, state.pendingBatteryCommandsW(now), spare) + // Retaining discharge protects only an offer already sent, not demand + // which the EV admission guard has not allowed. Otherwise an observed EV + // cut could never release temporary battery relief. + pendingEVW := math.Max(0, state.EVCommandedW-creditedW) + reliefSpare := math.Max(0, fuseMaxW-state.fuseSafetyMarginW()) - meterW - pendingEVW + if meterW >= 0 && state.SiteFuseVoltage > 0 { + if phaseSpare, known := evBatteryPhaseSpareW(meter, state, pendingEVW/(state.SiteFuseVoltage*phases)); known { + reliefSpare = math.Min(reliefSpare, phaseSpare) + } + } + out = retainEVBatteryRelief(out, latest, reliefSpare-pendingEVBatteryImportW(store, state, meter, latest), store, state) // 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. @@ -69,7 +88,7 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st 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))) + exportFloor := math.Min(offeredCharge, math.Max(0, -meterW-state.effectiveExportCeilingW(fuseMaxW))) if exportFloor > finalCharge && offeredCharge > finalCharge { scale := (exportFloor - finalCharge) / (offeredCharge - finalCharge) for i := range out { @@ -142,3 +161,84 @@ func floorPositiveEVBatteryTargets(targets []DispatchTarget) []DispatchTarget { } return out } + +// Missing, unhealthy or unaligned EV observations reserve the full demand. +// The meter-only EV fuse gate still decides every rise/start; battery relief +// from this clamp cannot fund a rise before it appears in the site meter. +func realizedEVForBatteryW(store *telemetry.Store, state *State, meter *telemetry.DerReading, now time.Time) float64 { + const maxAge = 15 * time.Second + const maxSkew = 10 * time.Second + if meter == nil || meter.UpdatedAt.After(now) || now.Sub(meter.UpdatedAt) > maxAge { + return 0 + } + var total float64 + for _, demand := range state.evFuseDemands { + observation, known := store.OnlineEVControlPower(demand.driverName, now, maxAge) + skew := meter.UpdatedAt.Sub(observation.Seen()) + if !known || skew < -maxSkew || skew > maxSkew { + continue + } + total += math.Min(demand.powerW, math.Max(0, observation.PowerW)) + } + return total +} + +// Ending discharge raises import too. Retain only an already sent, currently +// permitted discharge while the measured phase room cannot absorb its removal. +// Never invent discharge for a missing, unhealthy or direction-blocked battery. +func retainEVBatteryRelief(targets []DispatchTarget, latest map[string]float64, spare float64, store *telemetry.Store, state *State) []DispatchTarget { + now := state.now() + available := make([]float64, len(targets)) + lost, total := 0.0, 0.0 + for i, t := range targets { + lost += math.Max(0, math.Min(0, t.TargetW)-math.Min(0, latest[t.Driver])) + if t.TargetW > 0 || latest[t.Driver] >= t.TargetW { + continue + } + if _, _, _, sent := state.batteryCommands[t.Driver].span(now); !sent { + continue + } + r, h := store.Get(t.Driver, telemetry.DerBattery), store.DriverHealth(t.Driver) + if r == nil || h == nil || !h.TelemetryLive() || h.DeviceFault || r.UpdatedAt.After(now) || now.Sub(r.UpdatedAt) > 15*time.Second { + continue + } + if r.SoC == nil || !finiteFuseValue(*r.SoC) || *r.SoC < 0.05 || *r.SoC > 1 { + continue + } + lower, _ := fuseTargetBounds(r, state.DriverLimits[t.Driver]) + available[i] = math.Max(0, t.TargetW-math.Max(latest[t.Driver], lower)) + total += available[i] + } + if total <= 0 { + return targets + } + retain := math.Min(total, math.Max(0, lost-spare)) + if retain <= 0 { + return targets + } + out := append([]DispatchTarget(nil), targets...) + for i := range out { + if available[i] > 0 { + out[i].TargetW -= available[i] * retain / total + out[i].Clamped = true + } + } + return out +} + +// A qualified battery reading aligned with the site meter may prove a prior +// import increase has arrived. Otherwise retain the existing command window. +func pendingEVBatteryImportW(store *telemetry.Store, state *State, meter *telemetry.DerReading, latest map[string]float64) float64 { + now := state.now() + pending := state.pendingBatteryCommandsW(now) + observedPending := 0.0 + for driver, last := range latest { + observation, known := store.OnlineBatteryControlPower(driver, now, 15*time.Second) + skew := meter.UpdatedAt.Sub(observation.Seen()) + if !known || skew < -10*time.Second || skew > 10*time.Second { + return pending + } + observedPending += math.Max(0, last-observation.PowerW) + } + return math.Min(pending, observedPending) +} diff --git a/go/internal/control/ev_battery_headroom_test.go b/go/internal/control/ev_battery_headroom_test.go index 9fafb4872..bcd491eeb 100644 --- a/go/internal/control/ev_battery_headroom_test.go +++ b/go/internal/control/ev_battery_headroom_test.go @@ -10,8 +10,8 @@ import ( ) // evSplitFixture is a 25 A site with a 1 A margin (16,560 W). It holds the -// meter reading, our recent commands and the EV demand; device readings are -// left out on purpose, since the split must not read them. +// meter reading, our recent commands and the EV demand. EV readings report +// realized demand unless a test overrides delivery. func evSplitFixture(t *testing.T, gridW float64, phaseA []float64, demandW float64, demandPhase string) (*telemetry.Store, *State) { t.Helper() store := telemetry.NewStore() @@ -30,6 +30,8 @@ func evSplitFixture(t *testing.T, gridW float64, phaseA []float64, demandW float state.batteryCommands = map[string]commandLog{} if demandW > 0 { state.evFuseDemands = []evFuseDemand{{"car", demandW, demandPhase}} + store.Update("car", telemetry.DerEV, demandW, nil, nil) + store.RecordDriverSuccess("car") } return store, state } @@ -168,28 +170,16 @@ func TestEVBatteryKeepsChargeNeededForMeasuredExportRelief(t *testing.T) { } } -// The split reads our commands and the meter only. A lone, missing, late -// or too high reading from the charger or battery changes nothing. -func TestEVBatterySplitIgnoresDeviceReadings(t *testing.T) { - run := func(readings func(*telemetry.Store)) float64 { +// Battery device readings never finance optional charge or EV headroom. +func TestEVBatterySplitIgnoresBatteryReadings(t *testing.T) { + run := func(power float64) float64 { store, state := evSplitFixture(t, 7140, nil, 10350, "3p") sent(state, "ev", 4140, 4140) sent(state, "first", 0, 0) - readings(store) + store.Update("first", telemetry.DerBattery, power, nil, nil) return targetW(clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state, 17250), "first") } - want := run(func(*telemetry.Store) {}) - for name, readings := range map[string]func(*telemetry.Store){ - "ev_lone_zero": func(s *telemetry.Store) { s.Update("car", telemetry.DerEV, 0, nil, nil) }, - "ev_above_offer": func(s *telemetry.Store) { s.Update("car", telemetry.DerEV, 11000, nil, nil) }, - "ev_unavailable": func(s *telemetry.Store) { - s.Update("car", telemetry.DerEV, 0, nil, []byte(`{"control_power_available":false}`)) - }, - "battery_discharging": func(s *telemetry.Store) { s.Update("first", telemetry.DerBattery, -5000, nil, nil) }, - "battery_charging": func(s *telemetry.Store) { s.Update("first", telemetry.DerBattery, 6000, nil, nil) }, - } { - if got := run(readings); got != want { - t.Fatalf("%s moved the battery target %.1f -> %.1f W", name, want, got) - } + if run(-5000) != run(6000) { + t.Fatal("battery telemetry funded charge") } } diff --git a/go/internal/control/ev_battery_relief_test.go b/go/internal/control/ev_battery_relief_test.go new file mode 100644 index 000000000..ed3b7e19e --- /dev/null +++ b/go/internal/control/ev_battery_relief_test.go @@ -0,0 +1,148 @@ +package control + +import ( + "github.com/srcfl/ftw/go/internal/telemetry" + "testing" + "time" +) + +func TestEVBatteryReliefRetentionRespectsEligibility(t *testing.T) { + for _, mode := range []string{"healthy", "missing_soc", "empty", "blocked", "fault", "offline", "stale", "future", "no_order", "disabled"} { + t.Run(mode, func(t *testing.T) { + store, state := evSplitFixture(t, 12000, []float64{21.5, 11.5, 11.5}, 10350, "3p") + sent(state, "first", -1745, -1745) + soc := .3 + store.Update("first", telemetry.DerBattery, -1745, &soc, nil) + store.RecordDriverSuccess("first") + r := store.Get("first", telemetry.DerBattery) + switch mode { + case "missing_soc": + r.SoC = nil + case "empty": + x := .04 + r.SoC = &x + case "blocked": + r.Data = []byte(`{"discharge_capable":false}`) + case "fault": + store.DriverHealthMut("first").SetDeviceFault(true, "test") + case "offline": + store.DriverHealthMut("first").SetOffline() + case "stale": + r.UpdatedAt = r.UpdatedAt.Add(-time.Minute) + case "future": + r.UpdatedAt = state.now().Add(time.Minute) + case "disabled": + state.DriverLimits["first"] = PowerLimits{MaxDischargeW: 0, MaxDischargeWSet: true} + case "no_order": + delete(state.batteryCommands, "first") + } + out := retainEVBatteryRelief([]DispatchTarget{{Driver: "first", TargetW: 0}}, map[string]float64{"first": -1745}, 1725, store, state) + want := 0.0 + if mode == "healthy" { + want = -20 + } + if got := targetW(out, "first"); got != want { + t.Fatalf("got %.1f want %.1f", got, want) + } + }) + } +} +func TestEVBatteryPausedManagedLoadStillCapsCharge(t *testing.T) { + store, state := evSplitFixture(t, 11000, []float64{21, 17, 17}, 0, "3p") + state.evCommanded = map[string]float64{"car": 0} + sent(state, "first", 2070, 2070) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 5070}}, store, state, 17250) + if got := targetW(out, "first"); got > 4140.01 { + t.Fatalf("paused car released unmeasured room: %.1f", got) + } +} + +func TestEVBatteryPendingReliefRequiresAlignedFreshProof(t *testing.T) { + for _, mode := range []string{"pending", "arrived", "stale", "future", "unavailable", "skew"} { + t.Run(mode, func(t *testing.T) { + store, state := evSplitFixture(t, 12000, []float64{21.5, 11.5, 11.5}, 10350, "3p") + now := time.Now() + state.clock = func() time.Time { return now } + sent(state, "first", -1745, -690) + soc := .3 + actual := -690.0 + if mode == "pending" { + actual = -1745 + } + store.Update("first", telemetry.DerBattery, actual, &soc, nil) + store.RecordDriverSuccess("first") + r := store.Get("first", telemetry.DerBattery) + r.UpdatedAt = now + meter := store.Get("meter", telemetry.DerMeter) + meter.UpdatedAt = now + switch mode { + case "stale": + r.UpdatedAt = now.Add(-time.Minute) + case "future": + r.UpdatedAt = now.Add(time.Second) + case "unavailable": + r.Data = []byte(`{"control_power_available":false}`) + case "skew": + r.UpdatedAt = now.Add(-11 * time.Second) + } + want := 1055.0 + if mode == "arrived" { + want = 0 + } + if got := pendingEVBatteryImportW(store, state, meter, map[string]float64{"first": -690}); got != want { + t.Fatalf("pending %.1f want %.1f", got, want) + } + }) + } +} + +// Releasing existing discharge is itself an import rise. Test the complete +// clamp, so a qualified helper alone cannot hide a missing pipeline call. +func TestEVBatteryClampRetainsReliefUntilImportFits(t *testing.T) { + store, state := evSplitFixture(t, 12000, []float64{23.5, 13.5, 13.5}, 10350, "3p") + sent(state, "ev", 10350, 10350) + sent(state, "first", -1745, -1745) + soc := .3 + store.Update("first", telemetry.DerBattery, -1745, &soc, nil) + store.RecordDriverSuccess("first") + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 0}}, store, state, 17250) + if got := targetW(out, "first"); got != -1400 { + t.Fatalf("ending relief would exceed phase room: target %.1f want -1400", got) + } +} + +func TestEVBatteryClampDoesNotSpendUnseenImportTwice(t *testing.T) { + for _, arrived := range []bool{false, true} { + store, state := evSplitFixture(t, 12000, []float64{21.5, 11.5, 11.5}, 10350, "3p") + sent(state, "ev", 10350, 10350) + sent(state, "first", -1745, -690) + soc, actual := .3, -1745.0 + if arrived { + actual = -690 + } + store.Update("first", telemetry.DerBattery, actual, &soc, nil) + store.RecordDriverSuccess("first") + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 0}}, store, state, 17250) + want := -20.0 + if arrived { + want = 0 + } + if got := targetW(out, "first"); got != want { + t.Fatalf("arrived=%v target %.1f want %.1f", arrived, got, want) + } + } +} + +func TestEVBatteryReliefDoesNotReserveUnadmittedDemand(t *testing.T) { + store, state := evSplitFixture(t, 12000, []float64{21.5, 11.5, 11.5}, 10350, "3p") + sent(state, "ev", 10350, 8280) + sent(state, "first", -1745, -1745) + store.Update("car", telemetry.DerEV, 8280, nil, nil) + soc := .3 + store.Update("first", telemetry.DerBattery, -1745, &soc, nil) + store.RecordDriverSuccess("first") + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 0}}, store, state, 17250) + if got := targetW(out, "first"); got != -20 { + t.Fatalf("unadmitted EV demand retained unnecessary discharge: %.1f", got) + } +} diff --git a/go/internal/control/ev_field_reservation_regression_test.go b/go/internal/control/ev_field_reservation_regression_test.go new file mode 100644 index 000000000..e5b1be1c2 --- /dev/null +++ b/go/internal/control/ev_field_reservation_regression_test.go @@ -0,0 +1,46 @@ +package control + +import ( + "github.com/srcfl/ftw/go/internal/telemetry" + "math" + "testing" +) + +// Reconstructed clamp input. Actual internal hardware command logs were not captured. +func TestFieldReservationUnrealized15A(t *testing.T) { + store, state := evSplitFixture(t, 12987, []float64{23.34, 16.92, 15.68}, 10350, "3p") + sent(state, "ev", 10350, 10350) + sent(state, "first", 6000, 6000) + store.Update("car", telemetry.DerEV, 4175, nil, nil) + store.RecordDriverSuccess("car") + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 6000}}, store, state, 17250) + battery := targetW(out, "first") + projected := 23.34 + (10350.0-4175)/(3*230) - (6000-battery)/(3*230) + t.Logf("battery_target_w=%.2f projected_worst_phase_a=%.3f", battery, projected) + if math.IsNaN(battery) || projected > 24.0001 { + t.Fatalf("unrealized offer is not reserved: battery %.2f W, projected %.3f A", battery, projected) + } +} + +func TestFieldReservationRealizedOfferPreservesBattery(t *testing.T) { + store, state := evSplitFixture(t, 12987, []float64{23.34, 16.92, 15.68}, 10350, "3p") + sent(state, "ev", 10350, 10350) + sent(state, "first", 6000, 6000) + store.Update("car", telemetry.DerEV, 10350, nil, nil) + store.RecordDriverSuccess("car") + got := targetW(clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 6000}}, store, state, 17250), "first") + if got != 6000 { + t.Fatalf("realized offer unnecessarily reduced battery: %.2f", got) + } +} + +func TestFieldReservationMissingDeliveryKeepsFullReserve(t *testing.T) { + store, state := evSplitFixture(t, 12987, []float64{23.34, 16.92, 15.68}, 10350, "3p") + sent(state, "ev", 10350, 10350) + sent(state, "first", 6000, 6000) + store.Remove("car") + got := targetW(clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 6000}}, store, state, 17250), "first") + if got != 0 { + t.Fatalf("missing EV measurement credited delivery: battery %.2f", got) + } +} diff --git a/go/internal/control/ev_realized_reserve_test.go b/go/internal/control/ev_realized_reserve_test.go new file mode 100644 index 000000000..6fdd929d7 --- /dev/null +++ b/go/internal/control/ev_realized_reserve_test.go @@ -0,0 +1,81 @@ +package control + +import ( + "encoding/json" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestEVBatteryRealizedCreditRequiresFreshAlignedHealthyPower(t *testing.T) { + for _, name := range []string{"fresh", "missing", "stale", "future", "skew", "fault", "offline", "unavailable", "cached source", "negative", "above demand"} { + t.Run(name, func(t *testing.T) { + store, state := evSplitFixture(t, 12000, nil, 10350, "3p") + now := time.Now() + state.clock = func() time.Time { return now } + meter := store.Get("meter", telemetry.DerMeter) + meter.UpdatedAt = now + ev := store.Get("car", telemetry.DerEV) + ev.UpdatedAt = now + ev.RawW = 4155 + want := 4155.0 + switch name { + case "missing": + store.Remove("car") + want = 0 + case "stale": + ev.UpdatedAt = now.Add(-16 * time.Second) + want = 0 + case "future": + ev.UpdatedAt = now.Add(time.Second) + want = 0 + case "skew": + ev.UpdatedAt = now.Add(-11 * time.Second) + want = 0 + case "fault": + store.DriverHealthMut("car").SetDeviceFault(true, "test") + want = 0 + case "offline": + store.DriverHealthMut("car").SetOffline() + want = 0 + case "unavailable": + ev.Data = []byte(`{"control_power_available":false}`) + want = 0 + case "cached source": + ev.Data, _ = json.Marshal(map[string]any{"control_power_observed_at": now.Add(-time.Minute).Format(time.RFC3339Nano)}) + want = 0 + case "negative": + ev.RawW = -1000 + want = 0 + case "above demand": + ev.RawW = 20000 + want = 10350 + } + if got := realizedEVForBatteryW(store, state, meter, now); got != want { + t.Fatalf("credited %.0f W want %.0f W", got, want) + } + }) + } +} + +func TestEVBatteryUnknownDeliveryKeepsFullReservation(t *testing.T) { + store, state := evSplitFixture(t, 12000, []float64{22, 21, 20}, 10350, "3p") + sent(state, "ev", 10350, 10350) + sent(state, "first", 8000, 8000) + store.Remove("car") + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state, 17250) + if got := targetW(out, "first"); got != 0 { + t.Fatalf("unknown EV delivery released battery room: %.0f W", got) + } +} + +func TestEVBatteryFreshSiblingCannotCreditMissingCharger(t *testing.T) { + store, state := evSplitFixture(t, 12000, nil, 4140, "3p") + state.evFuseDemands = append(state.evFuseDemands, evFuseDemand{"missing", 6210, "3p"}) + now := time.Now() + state.clock = func() time.Time { return now } + if got := realizedEVForBatteryW(store, state, store.Get("meter", telemetry.DerMeter), now); got != 4140 { + t.Fatalf("credited %.0f W", got) + } +} diff --git a/go/internal/control/ev_unrealized_offer_test.go b/go/internal/control/ev_unrealized_offer_test.go new file mode 100644 index 000000000..442cc1e38 --- /dev/null +++ b/go/internal/control/ev_unrealized_offer_test.go @@ -0,0 +1,46 @@ +package control + +import ( + "math" + "testing" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// A stable current offer is not proof that its power is already inside the +// site meter. These incident replays require the battery to leave room for +// the unrealized part of a stable EV offer. +// This is a battery-allocation regression, not an Easee hardware simulator. +func TestEVBatteryReservesUnrealizedStableOffer(t *testing.T) { + for _, tc := range []struct { + name string + gridW, batteryW, demandW, actualW float64 + phaseA []float64 + }{ + {"15A offered but only 4.155kW drawn", 13843, 9383, 10350, 4155, []float64{19.65, 20.52, 19.46}}, + {"6A offered but no draw", 10347, 8261, 4140, 0, []float64{18.84, 13.29, 12.09}}, + {"realized offer preserves battery allocation", 13843, 9383, 4140, 4140, []float64{19.65, 20.52, 19.46}}, + } { + t.Run(tc.name, func(t *testing.T) { + store, state := evSplitFixture(t, tc.gridW, tc.phaseA, tc.demandW, "3p") + state.SiteFuseSafetyA = .5 + sent(state, "ev", tc.demandW, tc.demandW) + sent(state, "first", tc.batteryW, tc.batteryW) + store.Update("car", telemetry.DerEV, tc.actualW, nil, nil) + store.RecordDriverSuccess("car") + got := targetW(clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: tc.batteryW}}, store, state, 17250), "first") + // Conditional balanced three-phase projection at configured 230 V, + // holding all other loads constant. Release battery charge before EV rise. + worst := math.Max(tc.phaseA[0], math.Max(tc.phaseA[1], tc.phaseA[2])) + projectedA := worst + (tc.demandW-tc.actualW)/690 + requiredCutW := math.Max(0, (projectedA-24.5)*690) + ceilingW := math.Max(0, tc.batteryW-requiredCutW) + if got > ceilingW+1 { + t.Fatalf("stable EV offer %.0f W, actual %.0f W: battery allocation %.0f W exceeds EV-priority ceiling %.0f W; projected busiest phase %.2f A", tc.demandW, tc.actualW, got, ceilingW, projectedA) + } + if tc.actualW == tc.demandW && math.Abs(got-tc.batteryW) > 1 { + t.Fatalf("realized EV offer unnecessarily reduced battery: got %.0f W want %.0f W", got, tc.batteryW) + } + }) + } +} diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 61e0fba67..bc20a18e9 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -584,7 +584,7 @@ func simScenarios() []simScenario { expect: []simExpect{{9*time.Minute + 50*time.Second, func(s *simSite) error { p := s.p offerW := p.EVSE.OfferA * p.Voltage * float64(p.EVSE.OfferPhases) - if offerW != 10350 || math.Abs(p.EVSE.DrawW-4200) > 50 || math.Abs(p.Battery.W-9400) > 200 { + if offerW != 10350 || math.Abs(p.EVSE.DrawW-4200) > 50 || p.Battery.W > 5520+1 { return fmt.Errorf("not the #1761 state before the car takes its offer: offer %.0f W, car %.0f W, battery %.0f W", offerW, p.EVSE.DrawW, p.Battery.W) } return nil diff --git a/go/internal/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt index 71f1a29a3..b91cf1d4a 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -13,12 +13,6 @@ # last raise, then pauses when both land (surplus_reserve.go:128). # relief srcfl/ftw#1789, draft: the battery ends its relief before the # car's cut shows. -pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/chargenow+battery own-raise relief -pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/pause+battery own-raise relief -pr1763/25A/3p/3p-15A/easee120s/car3p/6-2-2/chargenow+battery own-raise relief -pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/chargenow+battery own-raise relief -pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/pause+battery own-raise relief -pr1763/25A/3p/3p-15A/fast2s/car3p/6-2-2/pause+battery own-raise relief probe/25A/auto/mixed/easee40s/car3p/balanced/surplus+goal+export2500W idle readback timing/16A/3p/3p/cloud60s/car3p/L1/surplus idle export timing/16A/3p/3p/easee120s/car3p/L1/surplus idle export diff --git a/go/internal/telemetry/battery_control_power.go b/go/internal/telemetry/battery_control_power.go new file mode 100644 index 000000000..be2659af3 --- /dev/null +++ b/go/internal/telemetry/battery_control_power.go @@ -0,0 +1,9 @@ +package telemetry + +import "time" + +// OnlineBatteryControlPower qualifies battery delivery before control may +// release the reservation for an import increase already commanded. +func (s *Store) OnlineBatteryControlPower(driver string, now time.Time, maxAge time.Duration) (ControlObservation, bool) { + return s.onlineControlPower(driver, DerBattery, now, maxAge) +} diff --git a/go/internal/telemetry/battery_control_power_test.go b/go/internal/telemetry/battery_control_power_test.go new file mode 100644 index 000000000..19ad48bbe --- /dev/null +++ b/go/internal/telemetry/battery_control_power_test.go @@ -0,0 +1,51 @@ +package telemetry + +import ( + "math" + "testing" + "time" +) + +func TestOnlineBatteryControlPowerRejectsUnqualifiedCache(t *testing.T) { + for _, mode := range []string{"fresh", "missing", "offline", "fault", "stale receipt", "future receipt", "stale source", "future source", "source validity", "unavailable", "nonfinite", "missing health", "invalid age"} { + t.Run(mode, func(t *testing.T) { + s := NewStore() + now := time.Now().UTC() + s.Update("battery", DerBattery, -690, nil, nil) + s.RecordDriverSuccess("battery") + r := s.Get("battery", DerBattery) + r.UpdatedAt = now + age := 15 * time.Second + switch mode { + case "missing": + s.Remove("battery") + case "offline": + s.DriverHealthMut("battery").SetOffline() + case "fault": + s.DriverHealthMut("battery").SetDeviceFault(true, "test") + case "stale receipt": + r.UpdatedAt = now.Add(-16 * time.Second) + case "future receipt": + r.UpdatedAt = now.Add(time.Second) + case "stale source": + r.Data = []byte(`{"control_power_observed_at":"` + now.Add(-16*time.Second).Format(time.RFC3339Nano) + `"}`) + case "future source": + r.Data = []byte(`{"control_power_observed_at":"` + now.Add(time.Second).Format(time.RFC3339Nano) + `"}`) + case "source validity": + r.Data = []byte(`{"power_max_age_s":1,"control_power_observed_at":"` + now.Add(-2*time.Second).Format(time.RFC3339Nano) + `"}`) + case "unavailable": + r.Data = []byte(`{"control_power_available":false}`) + case "nonfinite": + r.RawW = math.NaN() + case "missing health": + delete(s.health, "battery") + case "invalid age": + age = 0 + } + p, ok := s.OnlineBatteryControlPower("battery", now, age) + if ok != (mode == "fresh") || (ok && p.PowerW != -690) { + t.Fatalf("observation=%+v known=%v mode=%s", p, ok, mode) + } + }) + } +} diff --git a/go/internal/telemetry/ev_control_power.go b/go/internal/telemetry/ev_control_power.go new file mode 100644 index 000000000..03468cdfe --- /dev/null +++ b/go/internal/telemetry/ev_control_power.go @@ -0,0 +1,10 @@ +package telemetry + +import "time" + +// OnlineEVControlPower qualifies raw EV delivery for optional battery-load +// reservation. A faulted charger earns no credit here (unlike total site-load +// accounting), since retaining the full reservation is the conservative choice. +func (s *Store) OnlineEVControlPower(driver string, now time.Time, maxAge time.Duration) (ControlObservation, bool) { + return s.onlineControlPower(driver, DerEV, now, maxAge) +} diff --git a/go/internal/telemetry/ev_control_power_test.go b/go/internal/telemetry/ev_control_power_test.go new file mode 100644 index 000000000..02928b03e --- /dev/null +++ b/go/internal/telemetry/ev_control_power_test.go @@ -0,0 +1,38 @@ +package telemetry + +import ( + "testing" + "time" +) + +func TestOnlineEVControlPowerRejectsUnqualifiedCache(t *testing.T) { + for _, tc := range []string{"fresh", "offline", "fault", "stale receipt", "future receipt", "unavailable", "missing health"} { + t.Run(tc, func(t *testing.T) { + s := NewStore() + s.Update("ev", DerEV, 4140, nil, nil) + s.RecordDriverSuccess("ev") + now := time.Now() + r := s.Get("ev", DerEV) + r.UpdatedAt = now + want := tc == "fresh" + switch tc { + case "offline": + s.DriverHealthMut("ev").SetOffline() + case "fault": + s.DriverHealthMut("ev").SetDeviceFault(true, "test") + case "stale receipt": + r.UpdatedAt = now.Add(-16 * time.Second) + case "future receipt": + r.UpdatedAt = now.Add(time.Second) + case "unavailable": + r.Data = []byte(`{"control_power_available":false}`) + case "missing health": + delete(s.health, "ev") + } + p, ok := s.OnlineEVControlPower("ev", now, 15*time.Second) + if ok != want || (ok && p.PowerW != 4140) { + t.Fatalf("observation=%+v known=%v want %v", p, ok, want) + } + }) + } +} diff --git a/go/internal/telemetry/online_control_power.go b/go/internal/telemetry/online_control_power.go new file mode 100644 index 000000000..a4477e88e --- /dev/null +++ b/go/internal/telemetry/online_control_power.go @@ -0,0 +1,22 @@ +package telemetry + +import "time" + +// onlineControlPower qualifies a device observation with receipt age, source +// age and driver health under one read lock. Cached power alone is not proof +// that a commanded import increase reached the site meter. +func (s *Store) onlineControlPower(driver string, kind DerType, now time.Time, maxAge time.Duration) (ControlObservation, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + r := s.readings[key(driver, kind)] + h := s.health[driver] + if r == nil || h == nil || !h.TelemetryLive() || h.DeviceFault || maxAge <= 0 || r.UpdatedAt.After(now) || now.Sub(r.UpdatedAt) > maxAge { + return ControlObservation{}, false + } + observation, known := ControlPowerObservation(r.RawW, r.Data, r.UpdatedAt) + age := now.Sub(observation.Seen()) + if !known || age < 0 || age > maxAge || age > ControlPowerMaxAge(r.Data) { + return ControlObservation{}, false + } + return observation, true +}