Skip to content

Commit bc30ceb

Browse files
frahlgsegran2claude
authored
test(control): port #1816's battery relief reproductions to master (#1918)
* test(control): port #1816's battery relief reproductions to master Two reproductions from #1816, adapted to master's capBatteryCharge and clampEVBatteryHeadroom: - Pending rise: 25 A site, 1 A margin, phases 22.3/12.3/12.3 A. The battery measures -2000 W and our newer -1000 W order may not show yet. Ending the relief must keep -827 W so L1 lands on 24 A, not 25.2 A. - Shared relief: two batteries end -1000 W and -2000 W of relief. When the first cannot keep its share (SoC floor or a 300 W bound), the second keeps the missing 1827 W instead of the old 1218 W. Master's keepEndingDischarge (#1847) passes both. Dropping it, ignoring pending rises, or sharing before bounds makes them fail. Import only: master leaves the export direction to the fuse pipeline. Co-authored-by: segran2 <268038031+segran2@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(control): say which relief gaps master leaves open The header said master leaves the export case to the fuse pipeline. Nothing on master holds it. Name both no-car gaps instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: segran2 <268038031+segran2@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7a60827 commit bc30ceb

1 file changed

Lines changed: 156 additions & 0 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package control
2+
3+
import (
4+
"math"
5+
"testing"
6+
)
7+
8+
// Reproductions ported from srcfl/ftw#1816 (segran2) to master's
9+
// capBatteryCharge and clampEVBatteryHeadroom. Master's keepEndingDischarge
10+
// (#1847) holds them, but only while a car charges, and only on import.
11+
// Nothing on master holds the same case with no car, on import or export,
12+
// and these tests do not cover those gaps.
13+
//
14+
// Site: 25 A, three phases, 230 V, 1 A margin. Phases read 22.3/12.3/12.3 A,
15+
// which leaves 1.7 A on L1, or 1,173 W of balanced battery change.
16+
const reliefRoomW = (24 - 22.3) * 690
17+
18+
func reliefFixturePhases() []float64 { return []float64{22.3, 12.3, 12.3} }
19+
20+
// l1AfterA is L1 once the batteries move from what they measure to their
21+
// targets, all else unchanged.
22+
func l1AfterA(out []DispatchTarget, measured map[string]float64) float64 {
23+
a := 22.3
24+
for _, target := range out {
25+
a += (target.TargetW - measured[target.Driver]) / 690
26+
}
27+
return a
28+
}
29+
30+
// Ending a discharge is our raise. The battery measures -2,000 W and our
31+
// newer -1,000 W order may not show yet, so 1,000 W of the raise is already
32+
// on its way. Ending the rest at once spends the same room twice: L1 would
33+
// reach 25.2 A. The battery keeps -827 W and L1 lands on 24 A.
34+
func TestEndingReliefReservesPendingRise(t *testing.T) {
35+
store, state := evSplitFixture(t, 10787, reliefFixturePhases(), 4140, "3p")
36+
sent(state, "ev", 4140, 4140)
37+
sent(state, "first", -2000, -1000)
38+
batteryReading(store, "first", -2000, .5)
39+
out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 0}}, store, state, 17250)
40+
got := targetW(out, "first")
41+
if math.Abs(got-(-2000+reliefRoomW)) > .01 {
42+
t.Fatalf("battery %.1f W, want %.1f W", got, -2000+reliefRoomW)
43+
}
44+
if l1 := l1AfterA(out, map[string]float64{"first": -2000}); l1 > 24.001 {
45+
t.Fatalf("L1 %.3f A, over the 24 A line", l1)
46+
}
47+
}
48+
49+
// The relief kept never goes past the room the phase lacks, the battery's
50+
// latest order or its command bounds.
51+
func TestEndingReliefPendingRiseBounds(t *testing.T) {
52+
for _, tc := range []struct {
53+
name string
54+
pending, room, last, lowest float64
55+
want float64
56+
}{
57+
{"pending", 1000, 1173, -1000, -5000, -827},
58+
{"settled", 0, 1173, -1000, -5000, 0},
59+
{"room for both", 1000, 2500, -1000, -5000, 0},
60+
{"discharge blocked", 1000, 1173, -1000, 0, 0},
61+
{"bounds limit the relief", 1000, 1173, -1000, -500, -500},
62+
{"no relief to keep", 2000, 1173, 0, -5000, 0},
63+
} {
64+
t.Run(tc.name, func(t *testing.T) {
65+
out := capBatteryCharge([]DispatchTarget{{Driver: "first", TargetW: 0}},
66+
map[string]float64{"first": tc.last}, map[string]float64{"first": tc.lowest},
67+
tc.pending, tc.room, tc.room)
68+
if got := out[0].TargetW; math.Abs(got-tc.want) > 1e-6 {
69+
t.Fatalf("battery %.3f W, want %.3f W", got, tc.want)
70+
}
71+
})
72+
}
73+
}
74+
75+
// Two batteries end their relief: first at -1,000 W, second at -2,000 W.
76+
// The phase takes 1,173 W of the 3,000 W raise, so 1,827 W must stay. A
77+
// battery that cannot keep its share must not take it from one that can:
78+
// in #1816's old head, first got 609 W it could not keep and second kept
79+
// 1,218 W, so L1 reached 24.88 A.
80+
func TestEndingReliefSharesOnlyAvailableSupply(t *testing.T) {
81+
for _, tc := range []struct {
82+
name string
83+
firstSoC float64
84+
firstMaxDisW float64
85+
wantFirst float64 // NaN: only the total and bounds are checked.
86+
wantSecond float64
87+
}{
88+
{"first at its SoC floor", .01, 5000, 0, -1827},
89+
{"first bounded to 300 W", .5, 300, math.NaN(), math.NaN()},
90+
} {
91+
for _, reverse := range []bool{false, true} {
92+
name := tc.name
93+
if reverse {
94+
name += " reversed"
95+
}
96+
t.Run(name, func(t *testing.T) {
97+
store, state := evSplitFixture(t, 10787, reliefFixturePhases(), 4140, "3p")
98+
state.DriverLimits["first"] = PowerLimits{MaxChargeW: 10000, MaxDischargeW: tc.firstMaxDisW}
99+
sent(state, "ev", 4140, 4140)
100+
sent(state, "first", -1000)
101+
sent(state, "second", -2000)
102+
batteryReading(store, "first", -1000, tc.firstSoC)
103+
batteryReading(store, "second", -2000, .5)
104+
targets := []DispatchTarget{{Driver: "first", TargetW: 0}, {Driver: "second", TargetW: 0}}
105+
if reverse {
106+
targets[0], targets[1] = targets[1], targets[0]
107+
}
108+
out := clampEVBatteryHeadroom(targets, store, state, 17250)
109+
first, second := targetW(out, "first"), targetW(out, "second")
110+
if !math.IsNaN(tc.wantFirst) && (math.Abs(first-tc.wantFirst) > .01 || math.Abs(second-tc.wantSecond) > .01) {
111+
t.Fatalf("first %.1f W, second %.1f W; want %.1f W, %.1f W", first, second, tc.wantFirst, tc.wantSecond)
112+
}
113+
if first > 0 || first < -tc.firstMaxDisW-1e-6 || second > 0 || second < -2000-1e-6 {
114+
t.Fatalf("relief past what each battery can keep: first %.1f W, second %.1f W", first, second)
115+
}
116+
if l1 := l1AfterA(out, map[string]float64{"first": -1000, "second": -2000}); math.Abs(l1-24) > .001 {
117+
t.Fatalf("L1 %.3f A, want 24 A", l1)
118+
}
119+
})
120+
}
121+
}
122+
}
123+
124+
// The same sharing at capBatteryCharge, with bounds as lowest orders.
125+
func TestEndingReliefSharingBounds(t *testing.T) {
126+
for _, tc := range []struct {
127+
name string
128+
lastFirst, lowestFirst, lowestSecond, pending float64
129+
wantFirst, wantSecond float64
130+
}{
131+
{"first blocked", -1000, 0, -5000, 0, 0, -1827},
132+
{"too little supply", -1000, -100, -700, 0, -100, -700},
133+
{"first had no relief", 0, -5000, -5000, 0, 0, -827},
134+
{"pending past supply", -1000, 0, -5000, 1000, 0, -2000},
135+
} {
136+
for _, reverse := range []bool{false, true} {
137+
name := tc.name
138+
if reverse {
139+
name += " reversed"
140+
}
141+
t.Run(name, func(t *testing.T) {
142+
targets := []DispatchTarget{{Driver: "first", TargetW: 0}, {Driver: "second", TargetW: 0}}
143+
if reverse {
144+
targets[0], targets[1] = targets[1], targets[0]
145+
}
146+
out := capBatteryCharge(targets,
147+
map[string]float64{"first": tc.lastFirst, "second": -2000},
148+
map[string]float64{"first": tc.lowestFirst, "second": tc.lowestSecond},
149+
tc.pending, reliefRoomW, reliefRoomW)
150+
if first, second := targetW(out, "first"), targetW(out, "second"); math.Abs(first-tc.wantFirst) > 1e-6 || math.Abs(second-tc.wantSecond) > 1e-6 {
151+
t.Fatalf("first %.3f W, second %.3f W; want %.3f W, %.3f W", first, second, tc.wantFirst, tc.wantSecond)
152+
}
153+
})
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)