|
| 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