Skip to content
Closed
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.
79 changes: 70 additions & 9 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
// reading takes part, and a battery's own reading only bounds how much of a
// discharge we ordered it may keep. The EV still needs its demand less the
// offers the meter surely shows (evShownW).
// 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()
// The shared command log bounds delivery credit; an offer alone does
// not establish EV load already in the site meter.
offeredW := state.evShownW(now)
var demandW float64
phases := 3.0 // No phase placement promise for 1p or auto chargers.
Expand All @@ -25,10 +28,9 @@ 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 {
return floorPositiveEVBatteryTargets(targets)
Expand All @@ -37,6 +39,8 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st
if !finiteFuseValue(gridW) || !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))
lowest := make(map[string]float64, len(targets))
targeted := make(map[string]bool, len(targets))
Expand All @@ -49,7 +53,7 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st
latest[target.Driver] = last
// Only a discharge we ordered can be kept; a battery we have
// not ordered keeps nothing.
if r := store.Get(target.Driver, telemetry.DerBattery); r != nil {
if r := store.Get(target.Driver, telemetry.DerBattery); eligibleEVBatteryRelief(store, state, target.Driver, r, now) {
lowest[target.Driver], _ = fuseTargetBounds(r, state.DriverLimits[target.Driver])
}
} else {
Expand All @@ -60,7 +64,7 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st
// Battery charge stays under the level the EV may rise to. Ending a
// discharge needs room past our own raises the meter may not show, the
// EV's included, not past what the car still wants.
riseW := math.Max(0, state.EVCommandedW-offeredW)
riseW := math.Max(0, state.EVCommandedW-math.Min(offeredW, creditedW))
ceilingW := state.importRiseCeilingW(fuseMaxW) - gridW
spare, releaseSpare := ceilingW-needW, ceilingW-riseW
// During measured export overflow, the existing fuse pipeline may
Expand All @@ -75,7 +79,11 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st
releaseSpare = math.Min(releaseSpare, phaseSpare)
}
}
out := capBatteryCharge(targets, latest, lowest, state.pendingBatteryCommandsW(now), spare, releaseSpare)
// New charge still uses the full pending-command window. For ending
// discharge, aligned fresh battery delivery may prove that rise arrived.
pendingW := state.pendingBatteryCommandsW(now)
releaseSpare += pendingW - pendingEVBatteryImportW(store, state, meter, latest)
out := capBatteryCharge(targets, latest, lowest, pendingW, spare, releaseSpare)
// 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 Down Expand Up @@ -184,3 +192,56 @@ 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
}

// Only a fresh, healthy battery above its floor may retain an ordered
// discharge; fuseTargetBounds applies its configured and device limits.
func eligibleEVBatteryRelief(store *telemetry.Store, state *State, driver string, r *telemetry.DerReading, now time.Time) bool {
h := store.DriverHealth(driver)
return r != nil && h != nil && h.TelemetryLive() && !h.DeviceFault && !r.UpdatedAt.After(now) && now.Sub(r.UpdatedAt) <= 15*time.Second && r.SoC != nil && finiteFuseValue(*r.SoC) && *r.SoC >= 0.05 && *r.SoC <= 1
}

// 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 @@ -169,28 +171,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.evLimit.offers = map[string]chargerOffers{"review_lp": {log: commandLog{}.add(now.Add(-time.Hour), 10350), settle: evSettle}}
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