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

Keep pending battery import and export increases reserved when ending existing fuse relief, so successive commands cannot spend the same phase headroom before the meter observes their effect.
88 changes: 88 additions & 0 deletions go/internal/control/battery_pending_relief_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package control

import (
"encoding/json"
"math"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/telemetry"
)

// Ending discharge raises import; ending charge raises export. A prior rise
// still absent from the meter must consume room before either can rise again.
func TestPendingBatteryReliefDoesNotSpendPhaseRoomTwice(t *testing.T) {
for _, direction := range []float64{1, -1} {
for _, layer := range []string{"allocation", "manual", "idle"} {
name := "import"
if direction < 0 {
name = "export"
}
t.Run(name+"/"+layer, func(t *testing.T) {
now := time.Now().UTC()
store := telemetry.NewStore()
data, _ := json.Marshal(map[string]float64{
"l1_a": 22.3 * direction, "l2_a": 12.3 * direction, "l3_a": 12.3 * direction,
"l1_w": 22.3 * 230 * direction, "l2_w": 12.3 * 230 * direction, "l3_w": 12.3 * 230 * direction,
})
store.Update("meter", telemetry.DerMeter, 10787*direction, nil, data)
store.RecordDriverSuccess("meter")
soc := .5
actual := -2000 * direction
store.Update("battery", telemetry.DerBattery, actual, &soc, nil)
store.RecordDriverSuccess("battery")
store.Get("meter", telemetry.DerMeter).UpdatedAt = now
store.Get("battery", telemetry.DerBattery).UpdatedAt = now
st := phaseState(25, 1)
st.clock = func() time.Time { return now }
st.DriverLimits = map[string]PowerLimits{"battery": {MaxChargeW: 10000, MaxDischargeW: 5000}}
st.batteryCommands = map[string]commandLog{"battery": commandLog{}.add(now.Add(-time.Hour), actual).add(now.Add(-2*time.Second), -1000*direction)}
var out []DispatchTarget
if layer == "allocation" {
out = fitBatteryRisesToFuse([]DispatchTarget{{Driver: "battery", TargetW: 0}}, store, st)
} else {
st.SlewEnabled = false
st.MinDispatchIntervalS = 0
if layer == "manual" {
st.SetBatteryManualHold(BatteryManualHold{PowerW: 0, ExpiresAt: now.Add(time.Minute)})
} else {
st.Mode = ModeIdle
}
out = ComputeDispatch(store, st, map[string]float64{"battery": 20000}, 17250)
}
if len(out) != 1 {
t.Fatalf("missing battery target: %+v", out)
}
projected := 22.3 + direction*(out[0].TargetW-actual)/690
if !finiteFuseValue(out[0].TargetW) || projected > 24.01 {
t.Fatalf("pending rise spent twice: target %.1f W projects %.3f A", out[0].TargetW, projected)
}
if math.Abs(out[0].TargetW+827*direction) > 1e-6 {
t.Fatalf("target %.3f W, want %.3f W", out[0].TargetW, -827*direction)
}
})
}
}
}

func TestPendingBatteryReliefRespectsAvailableRoomAndBounds(t *testing.T) {
cases := []struct {
name string
pending, room, last, keep, want float64
}{
{"pending", 1000, 1173, -1000, 1000, -827},
{"settled", 0, 1173, -1000, 1000, 0},
{"room_for_both", 1000, 2500, -1000, 1000, 0},
{"direction_blocked", 1000, 1173, -1000, 0, 0},
{"battery_floor_bounds_relief", 1000, 1173, -1000, 500, -500},
{"no_existing_relief", 2000, 1173, 0, 1000, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
out := capBatteryCharge([]DispatchTarget{{Driver: "battery", TargetW: 0}}, map[string]float64{"battery": c.last}, c.pending, c.room, map[string]float64{"battery": c.keep})
if got := out[0].TargetW; math.Abs(got-c.want) > 1e-6 {
t.Fatalf("target %.3f want %.3f", got, c.want)
}
})
}
}
5 changes: 4 additions & 1 deletion go/internal/control/fuse_phase.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,10 @@ func capBatteryCharge(targets []DispatchTarget, latest map[string]float64, pendi
out[i].TargetW, out[i].Clamped = capped, true
}
}
if short := -spare - holdW; short > 0 && lostW > 0 {
// Pending rises consume room when ending relief too, not only when
// adding positive charge. Otherwise two successive reductions of
// discharge (or charge on the export pass) can spend the same room.
if short := pendingW - spare - holdW; short > 0 && lostW > 0 {
k := math.Min(1, short/lostW)
for i := range out {
if keep := math.Min(lost[i]*k, keepW[out[i].Driver]); keep > 0 {
Expand Down