Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 4 additions & 2 deletions go/test/site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ make siterig-judge DIR=/tmp/ftw-site-123 # judge a recorded run again
| `FTW_SITE_DEVSIM_SRC` | a device-simulator checkout at the pinned commit; without it the test clones the pin into the user cache |
| `FTW_SITE_FTW_BIN`, `FTW_SITE_DEVSIM_BIN` | prebuilt binaries |
| `FTW_SITE_CATALOG` | the fault catalog, for `TestCatalogCoverage` |
| `FTW_SITE_JUDGE` | a results directory: `TestJudge` judges its runs again with the criteria as they are now, and rewrites their `result.json` and the summary |
| `FTW_SITE_JUDGE` | a results directory: `TestJudge` judges its runs again with the criteria as they are now, random seeds included (from each run's `plan.json`), and rewrites their `result.json` and the summary |

Each run directory keeps `house/plant.jsonl` (the house once a second, with
every event and mark), `house/ocpp-<charger>.jsonl` (every OCPP frame the
Expand Down Expand Up @@ -79,7 +79,9 @@ its sources (`provenance` in [catalog_test.go](catalog_test.go)).

"Over the limit" is the worst phase above the fuse rating, measured through
the fuse. A device's response time counts as 2 s, the meter and control
tick as 2 s more.
tick as 2 s more. The random cases, `r3` and `r5a` judge a phase between
the fuse and 1.2 × the fuse as Core does: it counts only once its 60 s heat
(I² filter) is above the fuse too.

| Scenario | House and steps | Criteria | Short |
|---|---|---|---|
Expand Down
74 changes: 73 additions & 1 deletion go/test/site/evaluate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ type plantDevice struct {
EMSCmd float64 `json:"ems_cmd"` // 170 charge, 187 discharge, 204 stop
EMSSetW float64 `json:"ems_setpoint_w"`
Connected bool `json:"connected"`
Running *bool `json:"running"` // an inverter that answers; nil when not recorded
}

type ftwStatus struct {
Drivers map[string]ftwDriver `json:"drivers"`
Dispatch []ftwDispatch `json:"dispatch"` // Core's battery orders this tick
LoadQuality *string `json:"load_quality"`
LoadReason *string `json:"load_reason"`
LoadW *float64 `json:"load_w"`
Expand All @@ -61,6 +63,12 @@ type ftwStatus struct {
} `json:"energy"`
}

// ftwDispatch is one battery order Core sent: + charge, - discharge.
type ftwDispatch struct {
Driver string `json:"driver"`
TargetW float64 `json:"target_w"`
}

type ftwDriver struct {
Status string `json:"status"`
EVSessionWh *float64 `json:"ev_session_wh"` // a charger's session energy
Expand Down Expand Up @@ -107,6 +115,7 @@ type sample struct {
Bat2 plantDevice // the second, if any
Status *ftwStatus
LPs []ftwLoadpoint
HeatA [3]float64 // each phase's 60 s heat, filled in by warm
}

type frame struct {
Expand All @@ -130,6 +139,7 @@ type runData struct {
events []houseEventLine
frames []frame // garage's charger
meta map[string]any
warmed bool // every sample's HeatA is filled in
}

type markLine struct {
Expand Down Expand Up @@ -390,10 +400,72 @@ func (d *runData) runsOver(t0, t1, limit float64) (longest, total float64) {
return round(longest, 1), round(total, 1)
}

// fuseHeatTauS is the time constant of Core's fuse heat model
// (control/fuse_heat.go).
const fuseHeatTauS = 60.0

// warm fills in each sample's HeatA: per phase, the 60 s I² filter of Core's
// fuse heat model, on the current through the fuse in either direction,
// from the record's first sample on.
func (d *runData) warm() {
if d.warmed {
return
}
d.warmed = true
var sq [3]float64
for i, r := range d.samples {
for k, a := range r.PhaseA {
if i == 0 {
sq[k] = a * a
} else {
sq[k] += (1 - math.Exp(-(r.T-d.samples[i-1].T)/fuseHeatTauS)) * (a*a - sq[k])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Integrate the previous sample's current into fuse heat

At a current change, this calculation applies the new reading retroactively to the preceding interval. Core's fuseHeat.step in go/internal/control/fuse_heat.go instead integrates its stored previous currents before accepting the new reading. Consequently, the new judge advances heating and cooling transitions by a sample and can change boundary verdicts: starting at 25 A heat, four one-second samples at 25.1 A followed by 24 A produce a four-second judged overload here, whereas Core's model produces three seconds, flipping R5a's three-second criterion. Use the previous sample's phase current for the elapsed interval so the promised Core-equivalent filter has the same timing.

Useful? React with 👍 / 👎.

}
r.HeatA[k] = math.Sqrt(sq[k])
}
}
}

// heatOver is whether a phase is over the fuse as Core's heat model judges
// it (owner rule, 27 Oct 2026): above the fuse while its 60 s heat is above
// the fuse too, or above fuseInstantFactor x the fuse. Between the fuse and
// that factor the heat is the budget. Call warm first.
func heatOver(r *sample, fuse float64) bool {
for k, a := range r.PhaseA {
a = math.Abs(a)
if a > fuse && (r.HeatA[k] > fuse || a > fuseInstantFactor*fuse) {
return true
}
}
return false
}

// heatRunsOver is the longest stretch in [t0, t1] that heatOver holds,
// timed like runsOver.
func (d *runData) heatRunsOver(t0, t1, fuse float64) float64 {
d.warm()
longest := 0.0
var from *float64
var last float64
for _, r := range d.window(t0, t1) {
t := r.T
switch {
case heatOver(r, fuse) && from == nil:
from = &t
case !heatOver(r, fuse) && from != nil:
longest, from = math.Max(longest, t-*from), nil
}
last = t
}
if from != nil {
longest = math.Max(longest, last-*from)
}
return round(longest, 1)
}

// i2Over is the seconds the worst phase's I² filter (tau 60 s) is above
// limit, and the filter's peak.
func (d *runData) i2Over(t0, t1, limit float64) (over, peak float64) {
const tau = 60.0
const tau = fuseHeatTauS
var y, prev float64
started := false
for _, r := range d.window(t0, t1) {
Expand Down
Loading