Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ev-unrealized-offer-reserve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"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.
34 changes: 29 additions & 5 deletions go/internal/control/ev_battery_headroom.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@ 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 only caps optional battery charge; 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
Expand All @@ -31,11 +33,12 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st
if len(state.evFuseDemands) == 0 {
return targets
}
needW := math.Max(0, demandW-offeredW)
meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter)
if meter == nil || !finiteFuseValue(meter.RawW) || !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 {
Expand Down Expand Up @@ -142,3 +145,24 @@ 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
}
30 changes: 10 additions & 20 deletions go/internal/control/ev_battery_headroom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
Expand Down Expand Up @@ -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")
}
}
81 changes: 81 additions & 0 deletions go/internal/control/ev_realized_reserve_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
46 changes: 46 additions & 0 deletions go/internal/control/ev_unrealized_offer_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
22 changes: 22 additions & 0 deletions go/internal/telemetry/ev_control_power.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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) {
s.mu.RLock()
defer s.mu.RUnlock()
r := s.readings[key(driver, DerEV)]
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
}
38 changes: 38 additions & 0 deletions go/internal/telemetry/ev_control_power_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading