Skip to content
Merged
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
2 changes: 1 addition & 1 deletion go/test/site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ tick as 2 s more.
|---|---|---|---|
| `s0-25` Idle charger | 25 A, 3 A per phase, a charger with no car, heartbeat 60 s, 300 s | Home use `measured` in 95 % of samples; no OCPP disconnect | yes |
| `s0b-25` | as s0, Core's heartbeat | as s0 | |
| `s1-16`, `s1-25` Charge now | 3 A per phase, Charge now 16 A for 180 s | the room the fuse gives (25 A: 11 040 W) within 60 s; held 95 %; fuse holds; on 25 A no `fuse_limit` | yes |
| `s1-16`, `s1-25` Charge now | 3 A per phase, Charge now 16 A for 180 s | the room the fuse gives (16 A: 8 970 W; 25 A: 11 040 W) within 60 s; held 95 %; fuse holds; on 25 A no `fuse_limit` | yes |
| `s2-16` Charge now during export | 16 A, 1 A house, 7 kW PV, full battery: 9.1 A export per phase | car starts within 60 s (**known**: export counted as import); fuse holds | |
| `s3-16`, `s3-25` Load step | car and a 3 kW battery hold charging, then 13 A (16 A) or 21 A (25 A) per phase for 180 s | fuse holds; over the limit at most 7 s after the step | yes |
| `s4-25`, `s4b-25` Saved unlock | `surplus_unlock_bat_soc` 0.9 saved, not armed; Charge now 3p, and bare (s4b) | no stops; no offer drops to 0 W; full power over the last minute | |
Expand Down
7 changes: 4 additions & 3 deletions go/test/site/random_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,9 +367,10 @@ func judgeInvariants(d *runData) (metrics, []criterion) {
for _, hw := range holds {
run4 = 0
for i, r := range d.window(hw.from+grace, hw.to) {
// Room below fuse - 1 A, the car's line in Core.
roomA := fuse - 1 - (maxAbsI(r) - carW(r, 0)/(3*volt))
bad := carW(r, 0) < 0.5*1380 && roomA >= 7 && !inFault(r.T) && !nearRestart(r.T)
// Room below the fuse, the car's line in Core; 6 A is the car's
// minimum on three phases.
roomA := fuse - (maxAbsI(r) - carW(r, 0)/(3*volt))
bad := carW(r, 0) < 0.5*1380 && roomA >= 6 && !inFault(r.T) && !nearRestart(r.T)
if bad && i > 0 {
run4++
idle = math.Max(idle, run4)
Expand Down
77 changes: 77 additions & 0 deletions go/test/site/rig_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,80 @@ func TestInvariantsJudgeAtTheFuse(t *testing.T) {
}
}
}

// S1 expects the room the fuse rating gives: on 16 A with a 3 A house that
// is 13 A per phase, 8 970 W. The old line at fuse - 1 A (8 280 W) falls
// short of it.
func TestJudgeS1ExpectsTheRoomAtTheFuse(t *testing.T) {
const name = "target within 60 s"
for _, c := range []struct {
carW float64
want bool
}{{8970, true}, {8280, false}} {
d := synth(181, map[string]float64{"charge_now": 0, "end": 180}, map[string]any{"fuse": 16.0, "house_a": 3},
func(i int, r *sample) {
if i >= 10 {
r.CarW = []float64{c.carW}
}
})
m, cs := judgeS1(d)
if m["target_w"] != 8970.0 {
t.Fatalf("target_w = %v, want 8970", m["target_w"])
}
if got := verdictsOf(cs)[name]; got != c.want {
t.Errorf("car at %v W: %q = %v, want %v", c.carW, name, got, c.want)
}
}
}

// S12 counts a cut as one on room alone while every phase is under the fuse
// by more than 0.1 A, and records fuse_limit while the worst phase has 6 A.
func TestJudgeS12MeasuresRoomAtTheFuse(t *testing.T) {
const fuse = 20.0
for _, c := range []struct {
name string
a float64 // the worst phase when the order drops
cuts, limit int
}{
{"at the fuse", fuse, 0, 0},
{"0.1 A under", fuse - 0.1, 0, 0},
{"0.5 A under", fuse - 0.5, 1, 0},
{"5.5 A under", fuse - 5.5, 1, 0},
{"6.5 A under", fuse - 6.5, 1, 1},
} {
d := synth(121, map[string]float64{"plan": 0, "ready": 100, "end": 120}, map[string]any{"fuse": fuse, "plan_wh": 6600.0},
func(i int, r *sample) {
r.PhaseA = [3]float64{c.a, c.a - 1, c.a - 2}
if i == 30 {
r.LPs = []ftwLoadpoint{{CommandedReason: "fuse_limit"}}
}
})
d.frames = []frame{profileAt(10, 16), profileAt(20, 10)}
m, _ := judgeS12(d)
if m["cuts_with_room"] != c.cuts || m["fuse_limit_with_room_s"] != c.limit {
t.Errorf("%s: cuts %v, fuse_limit %v; want %d, %d", c.name, m["cuts_with_room"], m["fuse_limit_with_room_s"], c.cuts, c.limit)
}
}
}

// During Charge now an idle car is a fault once the house leaves the car's
// 6 A minimum under the fuse; with less room it is not.
func TestInvariantsChargeNowRoomAtTheFuse(t *testing.T) {
const fuse = 25.0
const name = "Charge now charges when there is room"
for _, c := range []struct {
houseA float64
want bool
}{{fuse - 6, false}, {fuse - 5.5, true}} {
d := synth(201, map[string]float64{"start": 0, "end": 200}, map[string]any{"fuse": fuse},
func(_ int, r *sample) {
r.PhaseA = [3]float64{c.houseA, c.houseA, c.houseA}
r.Bat.SoCPct = 50
})
d.allMarks = []markLine{{T: 1, Label: "ftw:charge_now"}, {T: 200, Label: "end"}}
_, cs := judgeInvariants(d)
if got := verdictsOf(cs)[name]; got != c.want {
t.Errorf("house %v A: %q = %v, want %v", c.houseA, name, got, c.want)
}
}
}
8 changes: 4 additions & 4 deletions go/test/site/scenarios_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -712,10 +712,10 @@ func judgeS12(d *runData) (metrics, []criterion) {
if p.Limit > 0 && p.phasesOr3() == 1 {
ones++
}
// A lower order while every phase has room is a cut on room alone,
// unless the plan itself asked for less (a pause between slots).
// A lower order while every phase is under the fuse is a cut on room
// alone, unless the plan itself asked for less (a pause between slots).
if i > 0 && p.offerW() < prs[i-1].offerW()-1 {
if r := d.at(t0 + p.T); r != nil && maxAbsI(r) < fuse-1-0.5 {
if r := d.at(t0 + p.T); r != nil && maxAbsI(r) < fuse-0.1 {
if after := d.at(t0 + p.T + 2); after == nil || firstLP(after).CommandedReason != "plan" {
cuts++
}
Expand All @@ -726,7 +726,7 @@ func judgeS12(d *runData) (metrics, []criterion) {
// recorded, not judged, since a slow charger's pending order may hold it.
limited := 0
for _, r := range w {
if r.LPs != nil && firstLP(r).CommandedReason == "fuse_limit" && maxAbsI(r) < fuse-1-6 {
if r.LPs != nil && firstLP(r).CommandedReason == "fuse_limit" && maxAbsI(r) < fuse-6 {
limited++
}
}
Expand Down
4 changes: 3 additions & 1 deletion go/test/site/stack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,9 @@ func (st *stack) ftwConfig() (string, error) {
cfg := map[string]any{
"site": map[string]any{"name": "rig", "control_interval_s": 2, "grid_target_w": 0,
"grid_tolerance_w": 42, "watchdog_timeout_s": 60},
"fuse": map[string]any{"max_amps": st.spec.fuseA, "phases": 3, "voltage": 230},
// safety_margin_a pinned at 0 so the rig judges Core at the rating
// whatever the default.
"fuse": map[string]any{"max_amps": st.spec.fuseA, "phases": 3, "voltage": 230, "safety_margin_a": 0},
"drivers": []map[string]any{
{"name": "meter", "lua": "drivers/sdm630.lua", "is_site_meter": true, "capabilities": modbus("meter")},
{"name": "sungrow", "lua": "drivers/sungrow.lua", "battery_capacity_wh": 10000, "capabilities": modbus("inverter")},
Expand Down
7 changes: 5 additions & 2 deletions go/test/site/verdicts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ func judgeS1(d *runData) (metrics, []criterion) {
fuse := d.metaF("fuse")
target := maxW
if fuse < 25 {
// Room on a 16 A fuse: fuse - 1 A margin - house, in 1 A steps.
target = math.Min(maxW, math.Floor(fuse-1-d.metaF("house_a"))*volt*3)
// Room on a 16 A fuse: fuse - house, in 1 A steps. Core works to
// the rating; safety_margin_a is a site's opt-in and the rig sets 0.
// The target is a floor, not the line: the battery (SoC 50) covers
// the house, so a Core that stops at fuse - 1 A also passes.
target = math.Min(maxW, math.Floor(fuse-d.metaF("house_a"))*volt*3)
}
m := d.carSummary(t0, t1, target)
m["target_w"] = target
Expand Down