Skip to content
Open
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-pending-battery-targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Keep pending battery import reserved when that battery has no current dispatch target.
7 changes: 7 additions & 0 deletions .changeset/ev-unrealized-offer-reserve.md
Original file line number Diff line number Diff line change
@@ -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.
128 changes: 118 additions & 10 deletions go/internal/control/ev_battery_headroom.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -142,3 +161,92 @@ 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)
// Proof from current targets cannot clear a pending rise on a battery
// that has no target in this dispatch.
for driver, log := range state.batteryCommands {
low, _, last, sent := log.span(now)
if _, present := latest[driver]; sent && last > low && !present {
return pending
}
}
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)
}
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")
}
}
103 changes: 103 additions & 0 deletions go/internal/control/ev_battery_pending_targets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package control

import (
"encoding/json"
"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/telemetry"
"math"
"testing"
"time"
)

func newEVBatteryTransitionTestState(t *testing.T, kind string) (*telemetry.Store, *State, map[string]float64, float64) {
t.Helper()
store := telemetry.NewStore()
soc := 0.6
low := 0.04
data, _ := json.Marshal(map[string]float64{"l1_a": 22.3, "l2_a": 12.3, "l3_a": 12.3, "l1_w": 22.3 * 230, "l2_w": 12.3 * 230, "l3_w": 12.3 * 230})
store.Update("review_meter", telemetry.DerMeter, 10787, nil, data)
store.RecordDriverSuccess("review_meter")
a, b := -2000.0, 0.0
if kind == "battery_at_soc_floor" {
a, b = -1000, -2000
}
store.Update("review_a", telemetry.DerBattery, a, &soc, nil)
store.RecordDriverSuccess("review_a")
store.Update("review_ev", telemetry.DerEV, 10350, nil, nil)
store.RecordDriverSuccess("review_ev")
caps := map[string]float64{"review_a": 10000}
if kind == "battery_at_soc_floor" {
store.Update("review_a", telemetry.DerBattery, a, &low, nil)
store.Update("review_b", telemetry.DerBattery, b, &soc, nil)
store.RecordDriverSuccess("review_b")
caps["review_b"] = 10000
}
now := time.Now().Add(time.Millisecond)
st := NewState(0, 0, "review_meter")
st.SiteFuseAmps = 25
st.SiteFuseVoltage = 230
st.SiteFusePhases = 3
st.SiteFuseSafetyA = 1
st.Mode = ModeIdle
st.SlewEnabled = false
st.MinDispatchIntervalS = 0
st.DriverLimits = map[string]PowerLimits{"review_a": {MaxChargeW: 5000, MaxDischargeW: 5000}, "review_b": {MaxChargeW: 5000, MaxDischargeW: 5000}}
st.clock = func() time.Time { return now.Add(-time.Hour) }
st.SetEVCommanded([]loadpoint.State{{ID: "review_lp", DriverName: "review_ev", PluggedIn: true, CommandedKnown: true, CommandedW: 10350, CommandedPhases: 3}})
st.clock = func() time.Time { return now }
st.SetEVFuseDemands([]loadpoint.FuseDemand{{DriverName: "review_ev", PowerW: 10350, PhaseMode: "3p"}})
st.evOffers = commandLog{}.add(now.Add(-time.Hour), 10350)
st.batteryCommands = map[string]commandLog{"review_a": commandLog{}.add(now.Add(-time.Hour), a)}
pending := 0.0
if kind == "pending_battery_without_target" {
st.batteryCommands["review_b"] = commandLog{}.add(now.Add(-time.Hour), 0).add(now.Add(-2*time.Second), 1000)
pending = 1000
}
if kind == "battery_at_soc_floor" {
st.batteryCommands["review_b"] = commandLog{}.add(now.Add(-time.Hour), b)
}
return store, st, caps, pending
}
func TestEVBatteryPendingImportAcrossTargets(t *testing.T) {
for _, kind := range []string{"single_control", "pending_battery_without_target", "battery_at_soc_floor"} {
for _, path := range []string{"allocation", "idle", "manual"} {
t.Run(kind+"/"+path, func(t *testing.T) {
store, st, caps, pending := newEVBatteryTransitionTestState(t, kind)
targets := []DispatchTarget{{Driver: "review_a", TargetW: 0}}
initial := -2000.0
if kind == "battery_at_soc_floor" {
targets = append(targets, DispatchTarget{Driver: "review_b", TargetW: 0})
initial = -3000
}
var out []DispatchTarget
if path == "allocation" {
out = clampEVBatteryHeadroom(targets, store, st, 17250)
} else {
if path == "manual" {
st.SetBatteryManualHold(BatteryManualHold{PowerW: 0, ExpiresAt: st.now().Add(time.Hour)})
}
out = ComputeDispatch(store, st, caps, 17250)
}
total := 0.0
for _, v := range out {
total += v.TargetW
}
projected := 22.3 + (pending+total-initial)/690
t.Logf("review result target_w=%.3f projected_a=%.6f", total, projected)
if len(out) != len(targets) {
t.Fatalf("missing battery targets")
}
if projected > 24.001 || math.Abs(projected-24) > 0.01 {
t.Fatalf("unsafe or excessive relief: projected phase %.6f A; target %.3f W", projected, total)
}
if kind == "battery_at_soc_floor" {
for _, v := range out {
if v.Driver == "review_a" && v.TargetW < 0 {
t.Fatalf("battery below floor was retained")
}
}
}
})
}
}
}
Loading
Loading