From 558592cb276896d4b56a4bbe9eee6164aebcc48d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 08:47:46 +0200 Subject: [PATCH 1/3] test(control): field matrix for EV charging and fuse protection v0.155.0-beta.1 passed our tests and failed on field sites the same night. We tested our defaults: fixed 3p, no step table, planned charging stubbed, and chargers that applied an order at once or exactly when they declared. Easee Cloud applies in 30-60 s and declares 180 s. TestSiteSimFieldMatrix runs Core's EV charging and fuse protection in the closed loop against 314 cases: the field reports (the help report of 22 Oct, the site in srcfl/ftw#1763, the home box) with every charger timing, a sweep over fuse, phase mode, step table, car phases and house load, and a timing sweep over every request. Planned charging now runs through the real controller: a minimal planner sends the slot's Wh and on-power, from the steps main.go gives the planner. Each case runs in a synctest bubble, so command evidence, the API's verdict and live Home use run on the plant's clock. Invariants, every second: the fuse holds; FTW's limit as before; no fuse cut while every phase is under the limit; no second cut while an unapplied cut covers the overage; a planned charge delivers 90 % of what the fuse allows; planned charging and Charge now never put a three-phase charger on one phase; a charger drawing against its order reads not following; Home use stays measured while every device reads. testdata/sitesim_field_known.txt lists the rows that fail on master and the fix that turns each green. The sim gains the Easee driver's auto split and phase hold, and outside steps no longer count as our raise. The matrix skips -race builds: it finds nothing there and runs 15 times slower. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/sitesim_field_api_test.go | 36 + .../control/sitesim_field_matrix_test.go | 142 ++++ go/internal/control/sitesim_field_test.go | 704 ++++++++++++++++++ go/internal/control/sitesim_test.go | 33 +- .../control/testdata/sitesim_field_known.txt | 386 ++++++++++ go/internal/sitesim/sitesim.go | 33 +- 6 files changed, 1322 insertions(+), 12 deletions(-) create mode 100644 go/internal/control/sitesim_field_api_test.go create mode 100644 go/internal/control/sitesim_field_matrix_test.go create mode 100644 go/internal/control/sitesim_field_test.go create mode 100644 go/internal/control/testdata/sitesim_field_known.txt diff --git a/go/internal/control/sitesim_field_api_test.go b/go/internal/control/sitesim_field_api_test.go new file mode 100644 index 000000000..83dd886c1 --- /dev/null +++ b/go/internal/control/sitesim_field_api_test.go @@ -0,0 +1,36 @@ +//go:build !race + +package control_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/srcfl/ftw/go/internal/api" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// The field matrix reads the EV verdict as the app does, from GET +// /api/loadpoints. The api package imports control, so only this external +// test package can build the server. +func init() { + control.FieldVerdict = func(tel *telemetry.Store, lps *loadpoint.Manager, ctrl *loadpoint.Controller) func() (string, string) { + srv := api.New(&api.Deps{Tel: tel, Loadpoints: lps, LoadpointCtrl: ctrl}) + return func() (string, string) { + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil)) + var body struct { + Loadpoints []struct { + Feedback *struct{ Status, Reason string } `json:"control_feedback"` + } `json:"loadpoints"` + } + if json.Unmarshal(rr.Body.Bytes(), &body) != nil || len(body.Loadpoints) == 0 || body.Loadpoints[0].Feedback == nil { + return "none", "no control_feedback" + } + return body.Loadpoints[0].Feedback.Status, body.Loadpoints[0].Feedback.Reason + } + } +} diff --git a/go/internal/control/sitesim_field_matrix_test.go b/go/internal/control/sitesim_field_matrix_test.go new file mode 100644 index 000000000..a195d5d25 --- /dev/null +++ b/go/internal/control/sitesim_field_matrix_test.go @@ -0,0 +1,142 @@ +//go:build !race + +// The race detector makes the matrix 15 times slower (826 s of CPU) and finds +// nothing in it: each case runs on one goroutine. TestSiteSimScenarios still +// runs under -race. + +package control + +import ( + "fmt" + "maps" + "os" + "slices" + "sort" + "strings" + "sync" + "testing" + "testing/synctest" +) + +// fieldKnownFile lists the rows that fail on master: case, kind and the fix +// that turns it green. A listed kind that passes fails the test until its +// line goes, and so does a failure the file does not list. +// FTW_FIELD_KNOWN=write rewrites it from a run and keeps each line's label. +const fieldKnownFile = "testdata/sitesim_field_known.txt" + +type fieldKnown struct { + header []string + fixes map[string]map[string]string // case, kind: fix +} + +func readFieldKnown(t *testing.T) fieldKnown { + t.Helper() + data, err := os.ReadFile(fieldKnownFile) + if err != nil { + t.Fatal(err) + } + k := fieldKnown{fixes: map[string]map[string]string{}} + for i, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if line == "" || strings.HasPrefix(line, "#") { + k.header = append(k.header, line) + continue + } + f := strings.Fields(line) + if len(f) != 3 { + t.Fatalf("%s:%d: want case, kind and fix: %q", fieldKnownFile, i+1, line) + } + if k.fixes[f[0]] == nil { + k.fixes[f[0]] = map[string]string{} + } + k.fixes[f[0]][f[1]] = f[2] + } + return k +} + +func (k fieldKnown) write(t *testing.T, results []fieldResult) { + t.Helper() + lines := slices.Clone(k.header) + for _, r := range results { + for _, kind := range r.failed { + fix := k.fixes[r.name][kind] + if fix == "" { + fix = "new" + } + lines = append(lines, r.name+" "+kind+" "+fix) + } + } + if err := os.WriteFile(fieldKnownFile, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +// fieldResult is one case's broken invariants. +type fieldResult struct { + name string + failed []string +} + +func TestSiteSimFieldMatrix(t *testing.T) { + cases, known := fieldCases(), readFieldKnown(t) + writeKnown := os.Getenv("FTW_FIELD_KNOWN") == "write" + names := map[string]bool{} + for _, c := range cases { + names[c.name()] = true + } + for name := range known.fixes { + if !names[name] { + t.Errorf("%s names %s, which the matrix no longer has", fieldKnownFile, name) + } + } + var mu sync.Mutex + var results []fieldResult + t.Cleanup(func() { + sort.Slice(results, func(i, j int) bool { return results[i].name < results[j].name }) + byKind, failing := map[string]int{}, 0 + for _, r := range results { + if len(r.failed) > 0 { + failing++ + } + for _, k := range r.failed { + byKind[k]++ + } + } + t.Logf("field matrix: %d cases, %d fail an invariant; by kind %v", len(results), failing, byKind) + if writeKnown && len(results) == len(cases) { + known.write(t, results) + } + }) + for _, c := range cases { + t.Run(c.name(), func(t *testing.T) { + t.Parallel() + synctest.Test(t, func(t *testing.T) { + var trace func(string, ...any) + if os.Getenv("FTW_FIELD_TRACE") == c.name() { + trace = t.Logf + } + s, f := runFieldCase(c, trace) + failed, fixes := slices.Sorted(maps.Keys(f.failures)), known.fixes[c.name()] + var problems []string + for _, k := range failed { + if fix, ok := fixes[k]; ok { + t.Logf("fails as known (%s): %s", fix, f.failures[k]) + } else { + problems = append(problems, f.failures[k]) + } + } + for _, k := range slices.Sorted(maps.Keys(fixes)) { + if _, ok := f.failures[k]; !ok { + problems = append(problems, fmt.Sprintf("%s passes now: delete its known line (%s)", k, fixes[k])) + } + } + mu.Lock() + results = append(results, fieldResult{name: c.name(), failed: failed}) + mu.Unlock() + t.Logf("peak phase %.1f A, delivered %.1f kWh, car idle with room %v", s.peakA, f.deliveredWh/1000, s.idleWithRoom) + if len(problems) > 0 && !writeKnown { + t.Fatal(strings.Join(problems, "\n")) + } + }) + }) + } +} diff --git a/go/internal/control/sitesim_field_test.go b/go/internal/control/sitesim_field_test.go new file mode 100644 index 000000000..2d4e5b122 --- /dev/null +++ b/go/internal/control/sitesim_field_test.go @@ -0,0 +1,704 @@ +package control + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "maps" + "math" + "slices" + "strings" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/sitesim" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// The field matrix runs Core's EV charging and fuse protection in the closed +// loop against the settings sites run: each field report's fuse, phase mode, +// step table, charger, car, house and battery, and the axes around them. A +// beta must pass it, not only our defaults (fixed 3p, no step table, a +// charger that answers at once or exactly when it says). +// +// Each case runs in a synctest bubble, so the telemetry store's wall clock is +// the plant's clock: command evidence, the API's verdict and live Home use +// are timed as on a box. + +// fieldTiming is a charger's response: how long an order takes to reach the +// car and the power_max_age_s its driver declares, zero for none. Easee Cloud +// applies an order in 30-60 s and declares 180 s. +type fieldTiming struct { + name string + delay time.Duration + declared time.Duration + cloud bool // easee_cloud.lua: splits auto at the fuse, holds a phase count 90 s, reports 10 s late +} + +var ( + timingFast = fieldTiming{"fast2s", 2 * time.Second, 0, false} + timingEasee = fieldTiming{"easee40s", 40 * time.Second, 3 * time.Minute, true} + timingSlowCloud = fieldTiming{"easee120s", 2 * time.Minute, 3 * time.Minute, true} + timingUndeclared = fieldTiming{"cloud60s", time.Minute, 0, true} + fieldTimings = []fieldTiming{timingFast, timingEasee, timingSlowCloud, timingUndeclared} +) + +// fieldUse is what the household asked for. +type fieldUse string + +const ( + usePlanned fieldUse = "planned" // a ready-by goal; the planner's directive per slot + useNow fieldUse = "chargenow" // Charge now from the app: 3p, or 1p on a 1p charger + useSurplus fieldUse = "surplus" // surplus_only while export steps up and down + usePause fieldUse = "pause" // Charge now, Pause for 25 s, then Start + useRestart fieldUse = "restart" // a planned charge across a 20 s Core restart +) + +// fieldCase is one site and one request. The name is stable: the known rows +// refer to it. +type fieldCase struct { + report string // the configuration it comes from + fuseA float64 // per phase, three phases at 230 V + mode string // loadpoint phase_mode + steps string // step table: none, mixed (1p and 3p levels), 3p, 1p, or a report's own + timing fieldTiming + carPhases int + use fieldUse + house [3]float64 // A per phase + houseName string + battery bool // the plan charges the home battery meanwhile + every time.Duration // control_interval_s; zero: 2 s + lp func(*loadpoint.Config) + car func(*sitesim.Car) +} + +func (c fieldCase) key() string { + mode := c.mode + if mode == "" { + mode = "empty" + } + name := fmt.Sprintf("%gA/%s/%s/%s/car%dp/%s/%s", c.fuseA, mode, c.steps, c.timing.name, c.carPhases, c.houseName, c.use) + if c.battery { + name += "+battery" + } + return name +} + +func (c fieldCase) name() string { return c.report + "/" + c.key() } + +// Step tables as sites have them. mixed is Easee's whole amps on one phase, +// then on three (srcfl/ftw#1774's tests, the rig's s5s). +func stepTable(name string) (steps []float64, minW, maxW float64) { + amps := func(phases float64, from, to float64) []float64 { + var out []float64 + for a := from; a <= to; a++ { + out = append(out, a*230*phases) + } + return out + } + switch name { + case "mixed": + return slices.Concat([]float64{0}, amps(1, 6, 16), amps(3, 6, 16)), 1380, 11040 + case "3p": + return slices.Concat([]float64{0}, amps(3, 6, 16)), 4140, 11040 + case "1p": + return slices.Concat([]float64{0}, amps(1, 6, 16)), 1380, 3680 + } + return nil, 4140, 11000 // What the settings page writes for a new charger. +} + +func fieldLoadpoint(c fieldCase) loadpoint.Config { + steps, minW, maxW := stepTable(c.steps) + if c.mode == "1p" && c.steps == "none" { + minW, maxW = 1380, 3680 + } + cfg := loadpoint.Config{ID: "garage", DriverName: "evse", PhaseMode: c.mode, AllowedStepsW: steps, + MinChargeW: minW, MaxChargeW: maxW, VehicleCapacityWh: 60000, SurplusOnly: c.use == useSurplus} + if c.lp != nil { + c.lp(&cfg) + } + return cfg +} + +var ( + houseBalanced = [3]float64{3, 3, 3} + houseL1 = [3]float64{7, 1, 1} // most of the house on one phase, the phase a 1p car uses +) + +// Field reports. Settings only: no names, hosts or credentials. + +// helpReport is the help report of 22 Oct 2026, v0.155.0-beta.1: 3 × 20 A, +// Easee Cloud, phase_mode auto, a step table with one-phase levels +// 1,600-3,800 W and three-phase 4,800-11,100 W, a deadline goal, +// control_interval_s 5. Pixii is the site meter; its battery sat at its floor +// all night, so it is left out. The house drew about 2 A a phase; the car +// took 3.1-3.4 kW on one phase and was cut to 0 W for the fuse every few +// minutes. +func helpReport(use fieldUse, tm fieldTiming) fieldCase { + return fieldCase{report: "help1022", fuseA: 20, mode: "auto", steps: "own", timing: tm, carPhases: 3, use: use, + house: [3]float64{2, 2, 2}, houseName: "2A", every: 5 * time.Second, + lp: func(c *loadpoint.Config) { + c.AllowedStepsW = []float64{0, 1600, 1900, 2100, 2400, 2600, 2800, 3100, 3300, 3500, 3800, + 4800, 5500, 6200, 6900, 7600, 8300, 9000, 9700, 10400, 11100} + c.MinChargeW, c.MaxChargeW, c.VehicleCapacityWh = 1600, 11100, 84000 + }} +} + +// pr1763Site is the site in srcfl/ftw#1763's comments: 3 × 25 A, Easee Cloud +// and Pixii, the battery charging from the plan while the car charges, +// offers to 15 A of which the car takes about 12 A, pause and restart. +func pr1763Site(use fieldUse, tm fieldTiming) fieldCase { + return fieldCase{report: "pr1763", fuseA: 25, mode: "3p", steps: "3p-15A", timing: tm, carPhases: 3, use: use, + house: [3]float64{6, 2, 2}, houseName: "6-2-2", battery: true, + lp: func(c *loadpoint.Config) { + steps, minW, _ := stepTable("3p") + c.AllowedStepsW, c.MinChargeW, c.MaxChargeW = steps[:len(steps)-1], minW, 10350 + }, + car: func(car *sitesim.Car) { car.MaxW = 8300 }} +} + +// homeBox is the home box: 3 × 16 A, Easee Cloud, phase_mode unset, no step +// table, 4,140-11,000 W. Its own goal is 80 % at 07:00; like every planned +// case here it plans the matrix's need instead. +func homeBox(use fieldUse, tm fieldTiming) fieldCase { + return fieldCase{report: "homebox", fuseA: 16, mode: "", steps: "none", timing: tm, carPhases: 3, use: use, + house: houseBalanced, houseName: "balanced", + lp: func(c *loadpoint.Config) { c.VehicleCapacityWh = 76000 }} +} + +// fieldCases crosses the axes. The field reports run with every charger +// timing. The sweep crosses fuse, phase mode, step table, car and house for +// planned charging and Charge now with an Easee Cloud charger; the timing +// sweep runs every request with every timing on fewer sites. Pruned: a 1p +// phase mode is a one-phase charger, so the car's phases and a 3p table do +// not apply; 20 A runs as the help report's site, between 16 and 25 A. +func fieldCases() []fieldCase { + var out []fieldCase + seen := map[string]bool{} + add := func(c fieldCase) { + if !seen[c.key()] { + seen[c.key()] = true + out = append(out, c) + } + } + for _, use := range []fieldUse{usePlanned, useNow, usePause, useRestart} { + for _, tm := range fieldTimings { + add(helpReport(use, tm)) + add(pr1763Site(use, tm)) + add(homeBox(use, tm)) + } + } + type modeSteps struct{ mode, steps string } + all := []modeSteps{{"", "none"}, {"", "mixed"}, {"", "3p"}, {"auto", "none"}, {"auto", "mixed"}, {"auto", "3p"}, + {"3p", "none"}, {"3p", "mixed"}, {"3p", "3p"}, {"1p", "none"}, {"1p", "1p"}} + houses := []struct { + name string + a [3]float64 + }{{"balanced", houseBalanced}, {"L1", houseL1}} + for _, use := range []fieldUse{usePlanned, useNow} { + for _, fuse := range []float64{16, 25} { + for _, h := range houses { + for _, ms := range all { + for _, car := range []int{3, 1} { + if ms.mode == "1p" && car == 1 { + continue + } + add(fieldCase{report: "sweep", fuseA: fuse, mode: ms.mode, steps: ms.steps, timing: timingEasee, + carPhases: car, use: use, house: h.a, houseName: h.name}) + } + } + } + } + } + for _, use := range []fieldUse{usePlanned, useNow, useSurplus, usePause, useRestart} { + for _, fuse := range []float64{16, 25} { + for _, ms := range []modeSteps{{"", "none"}, {"auto", "mixed"}, {"3p", "3p"}} { + for _, tm := range fieldTimings { + add(fieldCase{report: "timing", fuseA: fuse, mode: ms.mode, steps: ms.steps, timing: tm, + carPhases: 3, use: use, house: houseL1, houseName: "L1"}) + } + } + } + } + return out +} + +// Failure kinds: the invariants. The sitesim adapter's own checks give the +// first five. +const ( + failFuse = "fuse" // the main fuse blew + failLimit = "limit" // over Core's limit after an outside change, longer than the fastest lever + failOwnRaise = "own-raise" // a raise we sent kept a phase over the limit more than 3 s + failIdle = "idle" // the car idle with room for it and a request + failStale = "stale" // a raise on a stale meter + failCut = "cut" // the car's order cut for the fuse with every phase under the limit + failOverCut = "over-cut" // cut again for an overage that a cut the car has not applied yet covers + failEnergy = "energy" // a planned charge under 90 % of its energy by the deadline the fuse allowed + failOnePhase = "one-phase" // planned charging or Charge now put a three-phase charger on one phase + failVerdict = "verdict" // a charger drawing against its order not reported as not following + failHomeUse = "home-use" // Home use not measured while every device reads +) + +func failureKind(msg string) string { + for _, k := range []struct{ text, kind string }{ + {"the main fuse blew", failFuse}, {"a raise we sent kept", failOwnRaise}, + {"over Core's limit", failLimit}, {"the car is idle", failIdle}, {"on a meter", failStale}, + } { + if strings.Contains(msg, k.text) { + return k.kind + } + } + return "other" +} + +// fieldPlan is the planner's side of a deadline goal, the way the directive +// reaches the charger: at each 15 min slot the remaining energy at the +// smallest planning step that reaches the goal by the deadline, as the slot's +// Wh and its on-power. It replans from what the car has taken, as a real +// replan does from Core's session energy. +type fieldPlan struct { + id string + deadline time.Time + needWh float64 + planning func() []float64 // the steps main.go gives the planner + slotStart time.Time + stepW, slotWh float64 + slotDelivered0 float64 + run *fieldRun +} + +const fieldSlot = 15 * time.Minute + +func (p *fieldPlan) slotEnd() time.Time { + end := p.slotStart.Add(fieldSlot) + if end.After(p.deadline) { + return p.deadline + } + return end +} + +func (p *fieldPlan) directive(now time.Time) (loadpoint.Directive, bool) { + if !now.Before(p.deadline) { + return loadpoint.Directive{}, false + } + if start := now.Truncate(fieldSlot); !start.Equal(p.slotStart) { + p.slotStart, p.slotDelivered0 = start, p.run.deliveredWh + p.stepW, p.slotWh = 0, 0 + left, hours := p.needWh-p.run.deliveredWh, p.deadline.Sub(start).Hours() + if steps := p.planning(); left > 0 && hours > 0 && len(steps) > 0 { + p.stepW = slices.Max(steps) + for _, w := range steps { + if w > 0 && w >= left/hours { + p.stepW = w + break + } + } + p.slotWh = math.Min(left, p.stepW*p.slotEnd().Sub(start).Hours()) + } + } + return loadpoint.Directive{SlotStart: p.slotStart, SlotEnd: p.slotEnd(), + LoadpointEnergyWh: map[string]float64{p.id: p.slotWh}, LoadpointMaxPowerW: map[string]float64{p.id: p.stepW}}, true +} + +// fieldPlanningSteps is what main.go gives the planner for the session: +// planningStepsForLoadpoint's steps, held to the car by LimitPower. +func fieldPlanningSteps(s *simSite) []float64 { + st, _ := s.mgr.State(s.lp.ID) + cfg := loadpoint.Config{MinChargeW: st.MinChargeW, MaxChargeW: st.MaxChargeW, AllowedStepsW: st.AllowedStepsW, PhaseMode: s.lp.PhaseMode} + _, _, steps := loadpoint.LimitPower(st, loadpoint.PlanningSteps(cfg, loadpoint.SiteFuse{MaxAmps: s.p.Fuse.RatingA, Voltage: s.p.Voltage, PhaseCnt: 3})) + return steps +} + +// asks reports whether the plan wants the car charging now: its slot has an +// on-power and more energy left than the charger's response time takes. +func (p *fieldPlan) asks(now time.Time, response time.Duration) bool { + if p.stepW <= 0 || !now.Before(p.deadline) { + return false + } + return p.slotWh-(p.run.deliveredWh-p.slotDelivered0) > p.stepW*response.Hours() +} + +// fieldRun holds a case's request and the checks the adapter does not do. +type fieldRun struct { + c fieldCase + holdPhase string + plan *fieldPlan + verdict func() (status, reason string) + paused bool + failures map[string]string // the first failure of each kind + + deliveredWh float64 + orderW float64 // the order last sent, and since when + orderAt time.Time + mismatchSince time.Time // the car drawing against an order older than its response time + drawW float64 // the car's draw, and when it last changed + drawChangedAt time.Time + heatRoomSince time.Time // the phases' heat has left room to start since + nextLook time.Time + trace func(format string, args ...any) // FTW_FIELD_TRACE names the case +} + +// FieldVerdict returns a reader of the EV verdict the API shows for a store +// and loadpoints: status and reason. The api package imports control, so +// sitesim_field_api_test.go in package control_test sets it. +var FieldVerdict func(tel *telemetry.Store, lps *loadpoint.Manager, ctrl *loadpoint.Controller) func() (status, reason string) + +func (s *simSite) coreEvery() time.Duration { + if s.every > 0 { + return s.every + } + return simCoreEvery +} + +func (s *simSite) declaredMaxAge() time.Duration { + if s.field == nil { + return 0 + } + return s.field.c.timing.declared +} + +// evData is what a charger driver adds to its reading. +func (s *simSite) evData() map[string]any { + if s.field == nil { + return nil + } + d := map[string]any{"connected": s.ev.Plugged, "control_power_confirmed": true} + if age := s.field.c.timing.declared; age > 0 { + d["power_max_age_s"] = age.Seconds() + } + return d +} + +func (f *fieldRun) fail(s *simSite, kind, format string, args ...any) { + s.fail(format, args...) + f.harvest(s, kind) +} + +// harvest files the adapter's failure under its kind and clears it, so a +// case runs to its end and reports every kind it breaks. +func (f *fieldRun) harvest(s *simSite, kind string) { + if s.failure == "" { + return + } + if kind == "" { + kind = failureKind(s.failure) + } + if _, ok := f.failures[kind]; !ok { + f.failures[kind] = s.failure + } + if kind != failFuse { + s.failure = "" + } +} + +func (f *fieldRun) limitA(s *simSite) float64 { return s.p.Fuse.RatingA - evPhaseMarginA } + +// send records the command as the driver registry does and checks a cut. +func (f *fieldRun) send(ctx context.Context, s *simSite, driver string, payload []byte) error { + var cmd struct { + Action string `json:"action"` + PowerW float64 `json:"power_w"` + } + if err := json.Unmarshal(payload, &cmd); err == nil && cmd.Action == "ev_set_current" && driver == s.lp.DriverName && + cmd.PowerW < s.offerW-1 && !s.p.Offline("evse") { + if st, _ := s.mgr.State(s.lp.ID); st.CommandedReason == "fuse_limit" { + f.checkCut(s, cmd.PowerW) + } + } + if f.trace != nil && cmd.Action == "ev_set_current" && cmd.PowerW != s.offerW { + st, _ := s.mgr.State(s.lp.ID) + e := s.p.EVSE + f.trace("%s order %.0f W (%s); phases %.1f A, offer %.0f A on %d, car %.0f W on %d", + s.p.Now.Format("15:04:05"), cmd.PowerW, st.CommandedReason, s.p.A, e.OfferA, e.OfferPhases, e.DrawW, e.DrawPhases) + } + evidence := s.store.BeginCommand(driver, payload, s.p.Now) + err := s.send(ctx, driver, payload) + result := "accepted" + if err != nil { + result = "failed" + } + s.store.CompleteCommand(evidence, result) + if s.offerW != f.orderW { + f.orderW, f.orderAt = s.offerW, s.p.Now + } + return err +} + +// checkCut judges a cut for the fuse against the meter Core read. With +// every phase under the limit nothing measured asks for it: only orders +// that cannot have shown yet, which may hold back a raise and never cut. +// Over the limit, a cut the car has not applied yet that covers the +// overage already answers it; a second one takes the car down twice. +func (f *fieldRun) checkCut(s *simSite, w float64) { + limit, e, over := f.limitA(s), s.p.EVSE, false + for i, a := range s.meter.PhaseA { + over = over || math.Abs(a) >= limit || s.heat[i] >= limit*limit + } + if !over { + f.fail(s, failCut, "cut the car %.0f -> %.0f W for the fuse with every phase under %.0f A (meter %.1f A)", + s.offerW, w, limit, s.meter.PhaseA) + return + } + queuedA, queuedPhases, queued := e.Queued() + if !queued || e.DrawW <= 0 { + return + } + drawA := e.DrawW / s.p.Voltage / float64(e.DrawPhases) + carA := s.p.Car.MaxW / s.p.Voltage / float64(min(max(queuedPhases, 1), e.Phases, s.p.Car.Phases)) + covered := true + for i, a := range s.meter.PhaseA { + after := 0.0 + if i < min(max(queuedPhases, 1), e.Phases, s.p.Car.Phases) && queuedA >= e.MinA { + after = math.Min(queuedA, carA) + } + before := 0.0 + if i < e.DrawPhases { + before = drawA + } + if overA := math.Abs(a) - limit; overA > 0 && before-after < overA { + covered = false + } + } + if covered { + f.fail(s, failOverCut, "cut the car %.0f -> %.0f W while a cut to %.0f A it has not applied covers the overage (meter %.1f A, limit %.0f A)", + s.offerW, w, queuedA, s.meter.PhaseA, limit) + } +} + +// wantsCharge adds the request to the adapter's room test: a planned charge +// asks only while its slot has energy left, and a pause asks for nothing. +// After an overage Core waits for the importing phases' heat (I² over 60 s, +// the fuse's own memory) to leave room for the car's minimum and a step, for +// evRestartDwell, as its start rule does. +func (f *fieldRun) wantsCharge(s *simSite) bool { + p, limit := s.p, f.limitA(s) + room := true + for i := range min(p.EVSE.Phases, p.Car.Phases) { + level := p.A[i] // A phase that exports has room for import, whatever its heat. + if level > 0 { + level = math.Max(level, math.Sqrt(s.heat[i])) + } + room = room && level+p.EVSE.MinA+evPhaseMarginA <= limit + } + switch { + case !room: + f.heatRoomSince = time.Time{} + case f.heatRoomSince.IsZero(): + f.heatRoomSince = p.Now + } + switch { + case f.paused, !room, p.Now.Sub(f.heatRoomSince) < evRestartDwell && p.EVSE.DrawW <= 0: + return false + case f.plan != nil: + return f.plan.asks(p.Now, p.EVSE.Delay+time.Minute) + } + return true +} + +// response is how long Core may wait for the charger's power to show an +// order: what its driver declares, or Easee's 180 s when it declares nothing. +func (f *fieldRun) response() time.Duration { + if d := f.c.timing.declared; d > 0 { + return max(d, f.c.timing.delay) + } + return max(evUnknownSettle, f.c.timing.delay) +} + +func (f *fieldRun) check(s *simSite) { + p, now := s.p, s.p.Now + f.harvest(s, "") + f.deliveredWh += p.EVSE.DrawW * sitesim.Step.Hours() + if p.EVSE.DrawW != f.drawW { + f.drawW, f.drawChangedAt = p.EVSE.DrawW, now + } + if f.c.use != useSurplus && p.EVSE.Phases == 3 && p.EVSE.OfferPhases == 1 && p.EVSE.OfferA > 0 { + f.fail(s, failOnePhase, "%s put the three-phase charger on one phase at %.0f A", f.c.use, p.EVSE.OfferA) + } + up := s.downUntil.IsZero() + if up && p.Car.Plugged && now.Sub(f.orderAt) >= f.response() && math.Abs(p.EVSE.DrawW-f.orderW) > telemetry.ControlToleranceW(f.orderW) { + if f.mismatchSince.IsZero() { + f.mismatchSince = now + } + } else { + f.mismatchSince = time.Time{} + } + if !up || now.Before(f.nextLook) { + return + } + f.nextLook = now.Add(10 * time.Second) + // A charger that reports late leaves the balance negative until its + // reading catches up with the meter; that is not a failed read. + // The plant's PV has no driver of its own, so Home use cannot be + // measured while it produces. + if s.meterAt.Equal(now) && p.PVW == 0 && now.Sub(f.drawChangedAt) > p.EVSE.ReadbackLag+2*sitesim.Step { + if live := s.store.LiveMeasurementNow("meter", telemetry.ForecastOptions{}); live.LoadQuality != telemetry.LoadMeasured { + f.fail(s, failHomeUse, "Home use is %s (%s) while every device reads", live.LoadQuality, live.LoadReason) + } + } + // Core waits its response time for an order to take hold, then two + // minutes of measured disagreement (srcfl/ftw#1795). A one-phase draw + // may read limited instead (srcfl/ftw#1796). + if f.verdict != nil && !f.mismatchSince.IsZero() && now.Sub(f.mismatchSince) >= 2*time.Minute+30*time.Second { + if status, reason := f.verdict(); status != "not_following" && reason != "one_phase" { + f.fail(s, failVerdict, "the car draws %.0f W against a %.0f W order (%v), the verdict says %s/%s", + p.EVSE.DrawW, f.orderW, now.Sub(f.orderAt).Round(time.Second), status, reason) + } + } +} + +// fieldRoomW is the most the car can draw under Core's limit with the +// house's steady load: what a deadline goal can be held to. +func fieldRoomW(c fieldCase, e *sitesim.EVSE, car *sitesim.Car) float64 { + phases := min(e.Phases, car.Phases) + worst := 0.0 + for i := range phases { + worst = math.Max(worst, c.house[i]) + } + roomA := math.Min(c.fuseA-evPhaseMarginA-worst, e.MaxA) + if roomA < e.MinA { + return 0 + } + return math.Min(car.MaxW, roomA*230*float64(phases)) +} + +type fieldAction struct { + at time.Duration + do func(*simSite) +} + +// runFieldCase builds the site and runs the request; the run holds the +// failures by kind. It runs inside a synctest bubble. +func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) { + start := time.Date(2026, 10, 21, 22, 0, 0, 0, time.Local) + time.Sleep(start.Sub(time.Now())) // The bubble's clock becomes the plant's. + p := sitesim.New(start, c.fuseA) + phases := 3 + if c.mode == "1p" { + phases = 1 + } + p.EVSE = &sitesim.EVSE{Phases: phases, MinA: 6, MaxA: 16, Delay: c.timing.delay} + if c.timing.cloud { + p.EVSE.ReadbackLag, p.EVSE.SplitAtA, p.EVSE.PhaseHold = 10*time.Second, c.fuseA, 90*time.Second + } + p.Car = &sitesim.Car{Phases: c.carPhases, MaxW: 11040, Plugged: true} + if c.carPhases == 1 { + p.Car.MaxW = 3680 + } + if c.car != nil { + c.car(p.Car) + } + p.HouseW = houseA(c.house[0], c.house[1], c.house[2]) + if c.battery { + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 10000, MaxDischargeW: 10000, CapacityWh: 20000, SoC: .3, Latency: 3 * time.Second} + } + lp := fieldLoadpoint(c) + f := &fieldRun{c: c, failures: map[string]string{}, holdPhase: "3p", trace: trace} + if c.mode == "1p" { + f.holdPhase = "1p" // The app sends the loadpoint's phases (web/app.js). + } + s := &simSite{p: p, lp: lp, surplus: c.use == useSurplus, every: c.every, field: f} + + run, kettle := 20*time.Minute, []time.Duration{8 * time.Minute} + outside := func(do func(*sitesim.Plant)) func(*sitesim.Plant) { + return func(p *sitesim.Plant) { do(p); s.outsideAt = p.Now } + } + var actions []fieldAction + switch c.use { + case usePlanned, useRestart: + run, kettle = 3*time.Hour, []time.Duration{50 * time.Minute, 140 * time.Minute} + if c.use == useRestart { + run, kettle = 90*time.Minute, []time.Duration{50 * time.Minute} + p.Events = append(p.Events, sitesim.Event{At: 30 * time.Minute, Kind: sitesim.CoreRestart, For: 20 * time.Second}) + } + roomW := fieldRoomW(c, p.EVSE, p.Car) + f.plan = &fieldPlan{id: lp.ID, deadline: start.Add(run), run: f, planning: func() []float64 { return fieldPlanningSteps(s) }, + needWh: math.Min(3300*run.Hours(), .8*roomW*run.Hours())} + s.plan = f.plan.directive + case usePause: + run, kettle = 15*time.Minute, []time.Duration{11 * time.Minute} + actions = []fieldAction{ + {5 * time.Minute, func(s *simSite) { + f.paused = true + s.ctrl.SetManualHold(lp.ID, loadpoint.ManualHold{PowerW: 0, PhaseMode: f.holdPhase, Persistent: true, StartedAt: s.p.Now, UpdatedAt: s.p.Now}) + }}, + {5*time.Minute + 25*time.Second, func(s *simSite) { + f.paused = false + s.ctrl.SetManualHold(lp.ID, loadpoint.ManualHold{PowerW: lp.MaxChargeW, PhaseMode: f.holdPhase, Persistent: true, StartedAt: s.p.Now, UpdatedAt: s.p.Now}) + }}, + } + case useSurplus: + run, kettle = time.Hour, nil + // Export steps up and down, never past the fuse with the car off. + houseW, maxPVW := p.HouseW[0]+p.HouseW[1]+p.HouseW[2], math.Inf(1) + for _, a := range c.house { + maxPVW = math.Min(maxPVW, 3*230*(c.fuseA-2*evPhaseMarginA+a)) + } + for i, exportW := range []float64{2500, 6000, 9000, 3000} { + pvW := -math.Min(houseW+exportW, maxPVW) + p.Events = append(p.Events, sitesim.Event{At: time.Duration(i) * 15 * time.Minute, Do: outside(func(p *sitesim.Plant) { p.PVW = pvW })}) + } + } + // A kettle on L1 for three minutes: an outside step Core must make room for. + for _, at := range kettle { + p.Events = append(p.Events, + sitesim.Event{At: at, Do: outside(func(p *sitesim.Plant) { p.HouseW[0] += 6 * 230 })}, + sitesim.Event{At: at + 3*time.Minute, Do: outside(func(p *sitesim.Plant) { p.HouseW[0] -= 6 * 230 })}) + } + slices.SortStableFunc(p.Events, func(a, b sitesim.Event) int { return cmp.Compare(a.At, b.At) }) + + s.setup = func(s *simSite) { + // The periodic order goes through the outcome sender, as main.go + // wires it to the driver registry, which records command evidence. + s.ctrl.SetOutcomeSender(func(ctx context.Context, driver string, payload []byte, outcome func(error)) error { + err := f.send(ctx, s, driver, payload) + if outcome != nil { + outcome(err) + } + return err + }) + if c.use == useNow || c.use == usePause { + // Charge now from the app, which names the loadpoint's phases (web/app.js). + s.ctrl.SetManualHold(lp.ID, loadpoint.ManualHold{PowerW: lp.MaxChargeW, PhaseMode: f.holdPhase, Persistent: true, StartedAt: s.p.Now, UpdatedAt: s.p.Now}) + } + if f.plan != nil { + deadline := f.plan.deadline.Local() + s.mgr.SetSchedule(lp.ID, loadpoint.NewSchedule(1, deadline.Hour()*60+deadline.Minute(), false, 0, false)) + } + if c.battery { + fieldBatteryPlan(s, 6000) + } + if FieldVerdict != nil { + f.verdict = FieldVerdict(s.store, s.mgr, s.ctrl) + } + } + s.boot() + for end := start.Add(run); s.p.Now.Before(end) && f.failures[failFuse] == ""; { + for len(actions) > 0 && s.p.Now.Sub(start) >= actions[0].at && s.downUntil.IsZero() { + actions[0].do(s) + actions = actions[1:] + } + time.Sleep(sitesim.Step) + s.step() + } + if f.plan != nil && f.plan.needWh > 0 && f.deliveredWh < .9*f.plan.needWh { + f.fail(s, failEnergy, "the planned charge delivered %.1f of %.1f kWh by the deadline", f.deliveredWh/1000, f.plan.needWh/1000) + } + return s, f +} + +// fieldBatteryPlan has the plan charge the home battery at w in every slot, +// with the car's slot in the same directive as the planner sends it. +func fieldBatteryPlan(s *simSite, w float64) { + s.state.Mode, s.state.UseEnergyDispatch = ModePlannerArbitrage, true + s.state.SlotDirective = func(now time.Time) (SlotDirective, bool) { + start := now.Truncate(fieldSlot) + d := SlotDirective{SlotStart: start, SlotEnd: start.Add(fieldSlot), BatteryEnergyWh: w * fieldSlot.Hours(), Strategy: "arbitrage"} + if s.plan != nil { + if ev, ok := s.plan(now); ok { + d.LoadpointEnergyWh, d.LoadpointMaxPowerW = maps.Clone(ev.LoadpointEnergyWh), maps.Clone(ev.LoadpointMaxPowerW) + } + } + return d, true + } +} diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index d94c99bb1..9f227bb05 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -27,8 +27,9 @@ const simCoreEvery = 2 * time.Second type simSite struct { p *sitesim.Plant lp loadpoint.Config - surplus bool // the car charges from surplus; otherwise Charge now while plugged - setup func(*simSite) // after each boot + surplus bool // the car charges from surplus; otherwise Charge now while plugged + plan loadpoint.PlanFunc // nil: no plan, and the car charges on Charge now + setup func(*simSite) // after each boot db memConfigStore // state.db: survives a restart store *telemetry.Store @@ -36,6 +37,9 @@ type simSite struct { mgr *loadpoint.Manager ctrl *loadpoint.Controller + every time.Duration // control_interval_s; zero: simCoreEvery + field *fieldRun // the field matrix's own checks; nil outside it + downUntil, nextTick time.Time meter sitesim.MeterReading meterAt, evAt time.Time // last reading @@ -43,6 +47,7 @@ type simSite struct { seen map[string]time.Time // each driver's last reading offerW, batteryCmdW float64 // our last commands raisedAt time.Time // our last command that raised import + outsideAt time.Time // the last outside step a scenario marked defaulted bool commands []simCommand @@ -87,7 +92,11 @@ func (s *simSite) boot() { s.db = memConfigStore{} } s.mgr.SetSessionStore(s.db) - s.ctrl = loadpoint.NewController(s.mgr, func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false }, s.evSample, s.send) + plan := s.plan + if plan == nil { + plan = func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false } + } + s.ctrl = loadpoint.NewController(s.mgr, plan, s.evSample, s.send) s.ctrl.SetDriverOnline(func(d string) bool { h := s.store.DriverHealth(d); return h != nil && h.IsOnline() }) s.ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: s.p.Fuse.RatingA, Voltage: s.p.Voltage, PhaseCnt: 3}) s.ctrl.SetFuseEVMax(func() (float64, bool) { @@ -132,7 +141,7 @@ func (s *simSite) evSample(string) (loadpoint.EVSample, bool) { if s.evAt.IsZero() || s.p.Now.Sub(s.evAt) > time.Minute { return loadpoint.EVSample{}, false } - return loadpoint.EVSample{Connected: s.ev.Plugged, RequestActive: true, PowerW: s.ev.W, PowerAt: s.evAt, + return loadpoint.EVSample{Connected: s.ev.Plugged, RequestActive: true, PowerW: s.ev.W, PowerAt: s.evAt, PowerMaxAge: s.declaredMaxAge(), DeviceID: "easee:SIM1", SessionWh: s.ev.SessionWh, EnergyAt: s.evAt}, true } @@ -201,10 +210,10 @@ func (s *simSite) step() { } if e := tick.EV; e != nil { s.ev, s.evAt = *e, now - s.emit(s.lp.DriverName, telemetry.DerEV, e.W, nil, nil) + s.emit(s.lp.DriverName, telemetry.DerEV, e.W, nil, s.evData()) } if !now.Before(s.nextTick) { - s.nextTick = now.Add(simCoreEvery) + s.nextTick = now.Add(s.coreEvery()) s.control() } } @@ -225,7 +234,7 @@ func (s *simSite) control() { h.SetOffline() // The watchdog. } } - if _, held := s.ctrl.GetManualHold(s.lp.ID, now); !s.surplus && !held && s.ev.Plugged { + if _, held := s.ctrl.GetManualHold(s.lp.ID, now); !s.surplus && s.plan == nil && !held && s.ev.Plugged { s.ctrl.SetManualHold(s.lp.ID, loadpoint.ManualHold{PowerW: s.lp.MaxChargeW, PhaseMode: s.lp.PhaseMode, Persistent: true, StartedAt: now, UpdatedAt: now}) } allowed := s.meterFresh() @@ -285,7 +294,7 @@ func (s *simSite) responseTime(slowest bool) time.Duration { d = max(s.p.EVSE.Delay, b.Latency) } } - return d + max(s.p.Meter.Every, sitesim.Step) + simCoreEvery + return d + max(s.p.Meter.Every, sitesim.Step) + s.coreEvery() } // check holds the invariants for this second. @@ -321,7 +330,7 @@ func (s *simSite) check() { if overLimit { s.overLimit += sitesim.Step if s.limitSince.IsZero() { - s.limitSince, s.ownRaise = now, !s.raisedAt.IsZero() && now.Sub(s.raisedAt) <= s.responseTime(true) + s.limitSince, s.ownRaise = now, !s.raisedAt.IsZero() && now.Sub(s.raisedAt) <= s.responseTime(true) && !s.outsideAt.After(s.raisedAt) } } else { s.limitSince = time.Time{} @@ -350,6 +359,9 @@ func (s *simSite) check() { s.fail("the car is idle after %v with room for it", wait) } } + if s.field != nil { + s.field.check(s) + } } // wantsCharge reports whether the car should charge now: it is plugged @@ -378,6 +390,9 @@ func (s *simSite) wantsCharge() bool { return false } } + if s.field != nil && !s.field.wantsCharge(s) { + return false + } return !s.surplus || -baseW >= minW } diff --git a/go/internal/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt new file mode 100644 index 000000000..7e8836c5c --- /dev/null +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -0,0 +1,386 @@ +# Field matrix rows that fail on master: case, invariant and the fix that +# turns the row green. TestSiteSimFieldMatrix fails when a listed row passes +# and when a row fails that is not listed. FTW_FIELD_KNOWN=write rewrites +# this file from a run, keeps each label and marks unlisted failures "new". +# +# A srcfl/ftw#1799: room limits rises only; the split never cuts a +# running order. +# B srcfl/ftw#1799: planned charging and Charge now run on three +# phases; the planner gets steps a three-phase order can deliver. +# #1795 srcfl/ftw#1795: a charger drawing against its order reads not +# following. +# netting no fix yet: a cut counts every order older than evSettle (20 s) +# as shown, but a cloud charger applies it after 30-120 s, so Core +# cuts twice, or too little while a raise is on its way +# (ev_fuse_budget.go stepEVMeterLimit). +# D after beta.2: plan a one-phase car at the rate it takes +# (srcfl/ftw#1796, srcfl/ftw#1792). +# export srcfl/ftw#1782, draft: an exporting phase counts as import, so +# surplus never finds room to start. +# readback no fix yet: surplus raises again before the charger applied the +# last raise, then pauses when both land (surplus_reserve.go:128). +# relief srcfl/ftw#1789, draft: the battery ends its relief before the +# car's cut shows. +help1022/20A/auto/own/cloud60s/car3p/2A/chargenow over-cut netting +help1022/20A/auto/own/cloud60s/car3p/2A/pause over-cut netting +help1022/20A/auto/own/cloud60s/car3p/2A/planned cut A +help1022/20A/auto/own/cloud60s/car3p/2A/planned one-phase B +help1022/20A/auto/own/cloud60s/car3p/2A/planned over-cut B +help1022/20A/auto/own/cloud60s/car3p/2A/restart cut A +help1022/20A/auto/own/cloud60s/car3p/2A/restart energy A +help1022/20A/auto/own/cloud60s/car3p/2A/restart one-phase B +help1022/20A/auto/own/cloud60s/car3p/2A/restart over-cut B +help1022/20A/auto/own/easee120s/car3p/2A/chargenow over-cut netting +help1022/20A/auto/own/easee120s/car3p/2A/pause over-cut netting +help1022/20A/auto/own/easee120s/car3p/2A/planned cut A +help1022/20A/auto/own/easee120s/car3p/2A/planned energy A +help1022/20A/auto/own/easee120s/car3p/2A/planned one-phase B +help1022/20A/auto/own/easee120s/car3p/2A/planned over-cut B +help1022/20A/auto/own/easee120s/car3p/2A/restart cut A +help1022/20A/auto/own/easee120s/car3p/2A/restart energy A +help1022/20A/auto/own/easee120s/car3p/2A/restart one-phase B +help1022/20A/auto/own/easee120s/car3p/2A/restart over-cut B +help1022/20A/auto/own/easee40s/car3p/2A/chargenow over-cut netting +help1022/20A/auto/own/easee40s/car3p/2A/pause over-cut netting +help1022/20A/auto/own/easee40s/car3p/2A/planned one-phase B +help1022/20A/auto/own/easee40s/car3p/2A/planned over-cut B +help1022/20A/auto/own/easee40s/car3p/2A/restart cut A +help1022/20A/auto/own/easee40s/car3p/2A/restart one-phase B +help1022/20A/auto/own/easee40s/car3p/2A/restart over-cut B +help1022/20A/auto/own/fast2s/car3p/2A/planned one-phase B +help1022/20A/auto/own/fast2s/car3p/2A/restart one-phase B +homebox/16A/empty/none/cloud60s/car3p/balanced/chargenow over-cut netting +homebox/16A/empty/none/cloud60s/car3p/balanced/pause limit netting +homebox/16A/empty/none/cloud60s/car3p/balanced/planned energy B +homebox/16A/empty/none/cloud60s/car3p/balanced/planned idle B +homebox/16A/empty/none/cloud60s/car3p/balanced/restart energy B +homebox/16A/empty/none/cloud60s/car3p/balanced/restart idle B +homebox/16A/empty/none/easee120s/car3p/balanced/chargenow over-cut netting +homebox/16A/empty/none/easee120s/car3p/balanced/planned energy B +homebox/16A/empty/none/easee120s/car3p/balanced/planned idle B +homebox/16A/empty/none/easee120s/car3p/balanced/restart energy B +homebox/16A/empty/none/easee120s/car3p/balanced/restart idle B +homebox/16A/empty/none/easee40s/car3p/balanced/chargenow over-cut netting +homebox/16A/empty/none/easee40s/car3p/balanced/planned energy B +homebox/16A/empty/none/easee40s/car3p/balanced/planned idle B +homebox/16A/empty/none/easee40s/car3p/balanced/restart energy B +homebox/16A/empty/none/easee40s/car3p/balanced/restart idle B +homebox/16A/empty/none/fast2s/car3p/balanced/planned energy B +homebox/16A/empty/none/fast2s/car3p/balanced/planned idle B +homebox/16A/empty/none/fast2s/car3p/balanced/restart energy B +homebox/16A/empty/none/fast2s/car3p/balanced/restart idle B +pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/chargenow+battery over-cut netting +pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/chargenow+battery own-raise relief +pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/pause+battery own-raise relief +pr1763/25A/3p/3p-15A/easee120s/car3p/6-2-2/chargenow+battery over-cut netting +pr1763/25A/3p/3p-15A/easee120s/car3p/6-2-2/chargenow+battery own-raise relief +pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/chargenow+battery over-cut netting +pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/chargenow+battery own-raise relief +pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/pause+battery own-raise relief +pr1763/25A/3p/3p-15A/fast2s/car3p/6-2-2/pause+battery own-raise relief +sweep/16A/1p/1p/easee40s/car3p/balanced/chargenow over-cut netting +sweep/16A/1p/1p/easee40s/car3p/balanced/planned over-cut netting +sweep/16A/1p/none/easee40s/car3p/balanced/chargenow over-cut netting +sweep/16A/1p/none/easee40s/car3p/balanced/planned over-cut netting +sweep/16A/3p/3p/easee40s/car1p/L1/chargenow verdict #1795 +sweep/16A/3p/3p/easee40s/car1p/L1/planned energy D +sweep/16A/3p/3p/easee40s/car1p/balanced/chargenow over-cut netting +sweep/16A/3p/3p/easee40s/car1p/balanced/chargenow verdict #1795 +sweep/16A/3p/3p/easee40s/car1p/balanced/planned energy D +sweep/16A/3p/3p/easee40s/car1p/balanced/planned over-cut netting +sweep/16A/3p/3p/easee40s/car1p/balanced/planned verdict #1795 +sweep/16A/3p/3p/easee40s/car3p/balanced/chargenow over-cut netting +sweep/16A/3p/mixed/easee40s/car1p/L1/chargenow verdict #1795 +sweep/16A/3p/mixed/easee40s/car1p/L1/planned energy D +sweep/16A/3p/mixed/easee40s/car1p/L1/planned idle B +sweep/16A/3p/mixed/easee40s/car1p/L1/planned verdict #1795 +sweep/16A/3p/mixed/easee40s/car1p/balanced/chargenow over-cut netting +sweep/16A/3p/mixed/easee40s/car1p/balanced/chargenow verdict #1795 +sweep/16A/3p/mixed/easee40s/car1p/balanced/planned energy D +sweep/16A/3p/mixed/easee40s/car1p/balanced/planned idle B +sweep/16A/3p/mixed/easee40s/car1p/balanced/planned over-cut netting +sweep/16A/3p/mixed/easee40s/car1p/balanced/planned verdict #1795 +sweep/16A/3p/mixed/easee40s/car3p/L1/planned idle B +sweep/16A/3p/mixed/easee40s/car3p/balanced/chargenow over-cut netting +sweep/16A/3p/mixed/easee40s/car3p/balanced/planned cut B +sweep/16A/3p/mixed/easee40s/car3p/balanced/planned idle B +sweep/16A/3p/none/easee40s/car1p/L1/chargenow verdict #1795 +sweep/16A/3p/none/easee40s/car1p/L1/planned energy D +sweep/16A/3p/none/easee40s/car1p/balanced/chargenow over-cut netting +sweep/16A/3p/none/easee40s/car1p/balanced/chargenow verdict #1795 +sweep/16A/3p/none/easee40s/car1p/balanced/planned energy D +sweep/16A/3p/none/easee40s/car1p/balanced/planned over-cut netting +sweep/16A/3p/none/easee40s/car1p/balanced/planned verdict #1795 +sweep/16A/3p/none/easee40s/car3p/balanced/chargenow over-cut netting +sweep/16A/auto/3p/easee40s/car1p/L1/chargenow verdict #1795 +sweep/16A/auto/3p/easee40s/car1p/L1/planned energy D +sweep/16A/auto/3p/easee40s/car1p/L1/planned idle B +sweep/16A/auto/3p/easee40s/car1p/balanced/chargenow over-cut netting +sweep/16A/auto/3p/easee40s/car1p/balanced/chargenow verdict #1795 +sweep/16A/auto/3p/easee40s/car1p/balanced/planned energy D +sweep/16A/auto/3p/easee40s/car1p/balanced/planned idle B +sweep/16A/auto/3p/easee40s/car3p/L1/planned energy B +sweep/16A/auto/3p/easee40s/car3p/L1/planned idle B +sweep/16A/auto/3p/easee40s/car3p/balanced/chargenow over-cut netting +sweep/16A/auto/3p/easee40s/car3p/balanced/planned energy B +sweep/16A/auto/3p/easee40s/car3p/balanced/planned idle B +sweep/16A/auto/mixed/easee40s/car1p/L1/chargenow verdict #1795 +sweep/16A/auto/mixed/easee40s/car1p/L1/planned one-phase B +sweep/16A/auto/mixed/easee40s/car1p/balanced/chargenow over-cut netting +sweep/16A/auto/mixed/easee40s/car1p/balanced/chargenow verdict #1795 +sweep/16A/auto/mixed/easee40s/car1p/balanced/planned one-phase B +sweep/16A/auto/mixed/easee40s/car1p/balanced/planned over-cut netting +sweep/16A/auto/mixed/easee40s/car3p/L1/planned energy B +sweep/16A/auto/mixed/easee40s/car3p/L1/planned one-phase B +sweep/16A/auto/mixed/easee40s/car3p/balanced/chargenow over-cut netting +sweep/16A/auto/mixed/easee40s/car3p/balanced/planned energy B +sweep/16A/auto/mixed/easee40s/car3p/balanced/planned one-phase B +sweep/16A/auto/mixed/easee40s/car3p/balanced/planned over-cut B +sweep/16A/auto/none/easee40s/car1p/L1/chargenow verdict #1795 +sweep/16A/auto/none/easee40s/car1p/L1/planned energy D +sweep/16A/auto/none/easee40s/car1p/L1/planned idle B +sweep/16A/auto/none/easee40s/car1p/balanced/chargenow over-cut netting +sweep/16A/auto/none/easee40s/car1p/balanced/chargenow verdict #1795 +sweep/16A/auto/none/easee40s/car1p/balanced/planned energy D +sweep/16A/auto/none/easee40s/car1p/balanced/planned idle B +sweep/16A/auto/none/easee40s/car3p/L1/planned energy B +sweep/16A/auto/none/easee40s/car3p/L1/planned idle B +sweep/16A/auto/none/easee40s/car3p/balanced/chargenow over-cut netting +sweep/16A/auto/none/easee40s/car3p/balanced/planned energy B +sweep/16A/auto/none/easee40s/car3p/balanced/planned idle B +sweep/16A/empty/3p/easee40s/car1p/L1/chargenow verdict #1795 +sweep/16A/empty/3p/easee40s/car1p/L1/planned energy D +sweep/16A/empty/3p/easee40s/car1p/L1/planned idle B +sweep/16A/empty/3p/easee40s/car1p/balanced/chargenow over-cut netting +sweep/16A/empty/3p/easee40s/car1p/balanced/chargenow verdict #1795 +sweep/16A/empty/3p/easee40s/car1p/balanced/planned energy D +sweep/16A/empty/3p/easee40s/car1p/balanced/planned idle B +sweep/16A/empty/3p/easee40s/car3p/L1/planned energy B +sweep/16A/empty/3p/easee40s/car3p/L1/planned idle B +sweep/16A/empty/3p/easee40s/car3p/balanced/chargenow over-cut netting +sweep/16A/empty/3p/easee40s/car3p/balanced/planned energy B +sweep/16A/empty/3p/easee40s/car3p/balanced/planned idle B +sweep/16A/empty/mixed/easee40s/car1p/L1/chargenow verdict #1795 +sweep/16A/empty/mixed/easee40s/car1p/L1/planned one-phase B +sweep/16A/empty/mixed/easee40s/car1p/balanced/chargenow over-cut netting +sweep/16A/empty/mixed/easee40s/car1p/balanced/chargenow verdict #1795 +sweep/16A/empty/mixed/easee40s/car1p/balanced/planned one-phase B +sweep/16A/empty/mixed/easee40s/car1p/balanced/planned over-cut netting +sweep/16A/empty/mixed/easee40s/car3p/L1/planned energy B +sweep/16A/empty/mixed/easee40s/car3p/L1/planned one-phase B +sweep/16A/empty/mixed/easee40s/car3p/balanced/chargenow over-cut netting +sweep/16A/empty/mixed/easee40s/car3p/balanced/planned energy B +sweep/16A/empty/mixed/easee40s/car3p/balanced/planned one-phase B +sweep/16A/empty/mixed/easee40s/car3p/balanced/planned over-cut B +sweep/16A/empty/none/easee40s/car1p/L1/chargenow verdict #1795 +sweep/16A/empty/none/easee40s/car1p/L1/planned energy D +sweep/16A/empty/none/easee40s/car1p/L1/planned idle B +sweep/16A/empty/none/easee40s/car1p/balanced/chargenow over-cut netting +sweep/16A/empty/none/easee40s/car1p/balanced/chargenow verdict #1795 +sweep/16A/empty/none/easee40s/car1p/balanced/planned energy D +sweep/16A/empty/none/easee40s/car1p/balanced/planned idle B +sweep/16A/empty/none/easee40s/car3p/L1/planned energy B +sweep/16A/empty/none/easee40s/car3p/L1/planned idle B +sweep/25A/1p/1p/easee40s/car3p/L1/chargenow over-cut netting +sweep/25A/1p/1p/easee40s/car3p/L1/planned over-cut netting +sweep/25A/1p/1p/easee40s/car3p/balanced/chargenow over-cut netting +sweep/25A/1p/none/easee40s/car3p/L1/chargenow over-cut netting +sweep/25A/1p/none/easee40s/car3p/L1/planned over-cut netting +sweep/25A/1p/none/easee40s/car3p/balanced/chargenow over-cut netting +sweep/25A/3p/3p/easee40s/car1p/L1/chargenow over-cut netting +sweep/25A/3p/3p/easee40s/car1p/L1/planned energy D +sweep/25A/3p/3p/easee40s/car1p/L1/planned verdict #1795 +sweep/25A/3p/3p/easee40s/car1p/balanced/chargenow over-cut netting +sweep/25A/3p/3p/easee40s/car1p/balanced/planned energy D +sweep/25A/3p/3p/easee40s/car3p/L1/chargenow over-cut netting +sweep/25A/3p/3p/easee40s/car3p/balanced/chargenow over-cut netting +sweep/25A/3p/mixed/easee40s/car1p/L1/chargenow over-cut netting +sweep/25A/3p/mixed/easee40s/car1p/L1/planned energy D +sweep/25A/3p/mixed/easee40s/car1p/L1/planned idle B +sweep/25A/3p/mixed/easee40s/car1p/L1/planned over-cut B +sweep/25A/3p/mixed/easee40s/car1p/L1/planned verdict #1795 +sweep/25A/3p/mixed/easee40s/car1p/balanced/chargenow over-cut netting +sweep/25A/3p/mixed/easee40s/car1p/balanced/planned energy D +sweep/25A/3p/mixed/easee40s/car1p/balanced/planned idle B +sweep/25A/3p/mixed/easee40s/car3p/L1/chargenow over-cut netting +sweep/25A/3p/mixed/easee40s/car3p/L1/planned cut B +sweep/25A/3p/mixed/easee40s/car3p/L1/planned idle B +sweep/25A/3p/mixed/easee40s/car3p/balanced/chargenow over-cut netting +sweep/25A/3p/mixed/easee40s/car3p/balanced/planned cut B +sweep/25A/3p/mixed/easee40s/car3p/balanced/planned idle B +sweep/25A/3p/none/easee40s/car1p/L1/chargenow over-cut netting +sweep/25A/3p/none/easee40s/car1p/L1/planned energy D +sweep/25A/3p/none/easee40s/car1p/L1/planned verdict #1795 +sweep/25A/3p/none/easee40s/car1p/balanced/chargenow over-cut netting +sweep/25A/3p/none/easee40s/car1p/balanced/planned energy D +sweep/25A/3p/none/easee40s/car3p/L1/chargenow over-cut netting +sweep/25A/3p/none/easee40s/car3p/balanced/chargenow over-cut netting +sweep/25A/auto/3p/easee40s/car1p/L1/chargenow over-cut netting +sweep/25A/auto/3p/easee40s/car1p/L1/planned energy D +sweep/25A/auto/3p/easee40s/car1p/L1/planned idle B +sweep/25A/auto/3p/easee40s/car1p/balanced/chargenow over-cut netting +sweep/25A/auto/3p/easee40s/car1p/balanced/planned idle B +sweep/25A/auto/3p/easee40s/car1p/balanced/planned one-phase B +sweep/25A/auto/3p/easee40s/car3p/L1/chargenow over-cut netting +sweep/25A/auto/3p/easee40s/car3p/L1/planned energy B +sweep/25A/auto/3p/easee40s/car3p/L1/planned idle B +sweep/25A/auto/3p/easee40s/car3p/balanced/chargenow over-cut netting +sweep/25A/auto/3p/easee40s/car3p/balanced/planned idle B +sweep/25A/auto/3p/easee40s/car3p/balanced/planned one-phase B +sweep/25A/auto/mixed/easee40s/car1p/L1/chargenow over-cut netting +sweep/25A/auto/mixed/easee40s/car1p/L1/planned one-phase B +sweep/25A/auto/mixed/easee40s/car1p/L1/planned over-cut B +sweep/25A/auto/mixed/easee40s/car1p/balanced/chargenow over-cut netting +sweep/25A/auto/mixed/easee40s/car1p/balanced/planned one-phase B +sweep/25A/auto/mixed/easee40s/car3p/L1/chargenow over-cut netting +sweep/25A/auto/mixed/easee40s/car3p/L1/planned cut A +sweep/25A/auto/mixed/easee40s/car3p/L1/planned one-phase B +sweep/25A/auto/mixed/easee40s/car3p/L1/planned over-cut B +sweep/25A/auto/mixed/easee40s/car3p/balanced/chargenow over-cut netting +sweep/25A/auto/mixed/easee40s/car3p/balanced/planned one-phase B +sweep/25A/auto/none/easee40s/car1p/L1/chargenow over-cut netting +sweep/25A/auto/none/easee40s/car1p/L1/planned energy D +sweep/25A/auto/none/easee40s/car1p/L1/planned idle B +sweep/25A/auto/none/easee40s/car1p/balanced/chargenow over-cut netting +sweep/25A/auto/none/easee40s/car1p/balanced/planned idle B +sweep/25A/auto/none/easee40s/car1p/balanced/planned limit B +sweep/25A/auto/none/easee40s/car1p/balanced/planned one-phase B +sweep/25A/auto/none/easee40s/car3p/L1/chargenow over-cut netting +sweep/25A/auto/none/easee40s/car3p/L1/planned energy B +sweep/25A/auto/none/easee40s/car3p/L1/planned idle B +sweep/25A/auto/none/easee40s/car3p/balanced/chargenow over-cut netting +sweep/25A/auto/none/easee40s/car3p/balanced/planned idle B +sweep/25A/auto/none/easee40s/car3p/balanced/planned limit B +sweep/25A/auto/none/easee40s/car3p/balanced/planned one-phase B +sweep/25A/empty/3p/easee40s/car1p/L1/chargenow over-cut netting +sweep/25A/empty/3p/easee40s/car1p/L1/planned energy D +sweep/25A/empty/3p/easee40s/car1p/L1/planned idle B +sweep/25A/empty/3p/easee40s/car1p/balanced/chargenow over-cut netting +sweep/25A/empty/3p/easee40s/car1p/balanced/planned idle B +sweep/25A/empty/3p/easee40s/car1p/balanced/planned one-phase B +sweep/25A/empty/3p/easee40s/car3p/L1/chargenow over-cut netting +sweep/25A/empty/3p/easee40s/car3p/L1/planned energy B +sweep/25A/empty/3p/easee40s/car3p/L1/planned idle B +sweep/25A/empty/3p/easee40s/car3p/balanced/chargenow over-cut netting +sweep/25A/empty/3p/easee40s/car3p/balanced/planned idle B +sweep/25A/empty/3p/easee40s/car3p/balanced/planned one-phase B +sweep/25A/empty/mixed/easee40s/car1p/L1/chargenow over-cut netting +sweep/25A/empty/mixed/easee40s/car1p/L1/planned one-phase B +sweep/25A/empty/mixed/easee40s/car1p/L1/planned over-cut B +sweep/25A/empty/mixed/easee40s/car1p/balanced/chargenow over-cut netting +sweep/25A/empty/mixed/easee40s/car1p/balanced/planned one-phase B +sweep/25A/empty/mixed/easee40s/car3p/L1/chargenow over-cut netting +sweep/25A/empty/mixed/easee40s/car3p/L1/planned cut A +sweep/25A/empty/mixed/easee40s/car3p/L1/planned one-phase B +sweep/25A/empty/mixed/easee40s/car3p/L1/planned over-cut B +sweep/25A/empty/mixed/easee40s/car3p/balanced/chargenow over-cut netting +sweep/25A/empty/mixed/easee40s/car3p/balanced/planned one-phase B +sweep/25A/empty/none/easee40s/car1p/L1/chargenow over-cut netting +sweep/25A/empty/none/easee40s/car1p/L1/planned energy D +sweep/25A/empty/none/easee40s/car1p/L1/planned idle B +sweep/25A/empty/none/easee40s/car1p/balanced/chargenow over-cut netting +sweep/25A/empty/none/easee40s/car1p/balanced/planned idle B +sweep/25A/empty/none/easee40s/car1p/balanced/planned limit B +sweep/25A/empty/none/easee40s/car1p/balanced/planned one-phase B +sweep/25A/empty/none/easee40s/car3p/L1/chargenow over-cut netting +sweep/25A/empty/none/easee40s/car3p/L1/planned energy B +sweep/25A/empty/none/easee40s/car3p/L1/planned idle B +sweep/25A/empty/none/easee40s/car3p/balanced/chargenow over-cut netting +sweep/25A/empty/none/easee40s/car3p/balanced/planned idle B +sweep/25A/empty/none/easee40s/car3p/balanced/planned limit B +sweep/25A/empty/none/easee40s/car3p/balanced/planned one-phase B +timing/16A/3p/3p/cloud60s/car3p/L1/surplus idle export +timing/16A/3p/3p/easee120s/car3p/L1/surplus idle export +timing/16A/3p/3p/easee40s/car3p/L1/surplus idle export +timing/16A/3p/3p/fast2s/car3p/L1/surplus idle export +timing/16A/auto/mixed/cloud60s/car3p/L1/planned energy B +timing/16A/auto/mixed/cloud60s/car3p/L1/planned one-phase B +timing/16A/auto/mixed/cloud60s/car3p/L1/restart energy B +timing/16A/auto/mixed/cloud60s/car3p/L1/restart one-phase B +timing/16A/auto/mixed/cloud60s/car3p/L1/surplus idle export +timing/16A/auto/mixed/easee120s/car3p/L1/planned energy B +timing/16A/auto/mixed/easee120s/car3p/L1/planned one-phase B +timing/16A/auto/mixed/easee120s/car3p/L1/restart energy B +timing/16A/auto/mixed/easee120s/car3p/L1/restart one-phase B +timing/16A/auto/mixed/easee120s/car3p/L1/surplus idle export +timing/16A/auto/mixed/easee40s/car3p/L1/restart energy B +timing/16A/auto/mixed/easee40s/car3p/L1/restart one-phase B +timing/16A/auto/mixed/easee40s/car3p/L1/surplus idle export +timing/16A/auto/mixed/fast2s/car3p/L1/planned energy B +timing/16A/auto/mixed/fast2s/car3p/L1/planned one-phase B +timing/16A/auto/mixed/fast2s/car3p/L1/restart energy B +timing/16A/auto/mixed/fast2s/car3p/L1/restart one-phase B +timing/16A/auto/mixed/fast2s/car3p/L1/surplus idle export +timing/16A/empty/none/cloud60s/car3p/L1/planned energy B +timing/16A/empty/none/cloud60s/car3p/L1/planned idle B +timing/16A/empty/none/cloud60s/car3p/L1/restart energy B +timing/16A/empty/none/cloud60s/car3p/L1/restart idle B +timing/16A/empty/none/cloud60s/car3p/L1/surplus idle export +timing/16A/empty/none/easee120s/car3p/L1/planned energy B +timing/16A/empty/none/easee120s/car3p/L1/planned idle B +timing/16A/empty/none/easee120s/car3p/L1/restart energy B +timing/16A/empty/none/easee120s/car3p/L1/restart idle B +timing/16A/empty/none/easee120s/car3p/L1/surplus idle export +timing/16A/empty/none/easee40s/car3p/L1/restart energy B +timing/16A/empty/none/easee40s/car3p/L1/restart idle B +timing/16A/empty/none/easee40s/car3p/L1/surplus idle export +timing/16A/empty/none/fast2s/car3p/L1/planned energy B +timing/16A/empty/none/fast2s/car3p/L1/planned idle B +timing/16A/empty/none/fast2s/car3p/L1/restart energy B +timing/16A/empty/none/fast2s/car3p/L1/restart idle B +timing/16A/empty/none/fast2s/car3p/L1/surplus idle export +timing/25A/3p/3p/cloud60s/car3p/L1/chargenow over-cut netting +timing/25A/3p/3p/cloud60s/car3p/L1/pause over-cut netting +timing/25A/3p/3p/cloud60s/car3p/L1/surplus idle readback +timing/25A/3p/3p/easee120s/car3p/L1/chargenow over-cut netting +timing/25A/3p/3p/easee120s/car3p/L1/pause over-cut netting +timing/25A/3p/3p/easee120s/car3p/L1/surplus idle readback +timing/25A/3p/3p/easee40s/car3p/L1/pause over-cut netting +timing/25A/3p/3p/easee40s/car3p/L1/surplus idle readback +timing/25A/auto/mixed/cloud60s/car3p/L1/chargenow over-cut netting +timing/25A/auto/mixed/cloud60s/car3p/L1/pause over-cut netting +timing/25A/auto/mixed/cloud60s/car3p/L1/planned cut A +timing/25A/auto/mixed/cloud60s/car3p/L1/planned one-phase B +timing/25A/auto/mixed/cloud60s/car3p/L1/planned over-cut B +timing/25A/auto/mixed/cloud60s/car3p/L1/restart cut A +timing/25A/auto/mixed/cloud60s/car3p/L1/restart one-phase B +timing/25A/auto/mixed/cloud60s/car3p/L1/restart over-cut B +timing/25A/auto/mixed/cloud60s/car3p/L1/surplus idle readback +timing/25A/auto/mixed/easee120s/car3p/L1/chargenow over-cut netting +timing/25A/auto/mixed/easee120s/car3p/L1/pause over-cut netting +timing/25A/auto/mixed/easee120s/car3p/L1/planned cut A +timing/25A/auto/mixed/easee120s/car3p/L1/planned one-phase B +timing/25A/auto/mixed/easee120s/car3p/L1/planned over-cut B +timing/25A/auto/mixed/easee120s/car3p/L1/restart cut A +timing/25A/auto/mixed/easee120s/car3p/L1/restart one-phase B +timing/25A/auto/mixed/easee120s/car3p/L1/restart over-cut B +timing/25A/auto/mixed/easee120s/car3p/L1/surplus idle readback +timing/25A/auto/mixed/easee40s/car3p/L1/pause over-cut netting +timing/25A/auto/mixed/easee40s/car3p/L1/restart one-phase B +timing/25A/auto/mixed/easee40s/car3p/L1/restart over-cut B +timing/25A/auto/mixed/easee40s/car3p/L1/surplus idle readback +timing/25A/auto/mixed/fast2s/car3p/L1/planned one-phase B +timing/25A/auto/mixed/fast2s/car3p/L1/restart one-phase B +timing/25A/empty/none/cloud60s/car3p/L1/chargenow over-cut netting +timing/25A/empty/none/cloud60s/car3p/L1/pause over-cut netting +timing/25A/empty/none/cloud60s/car3p/L1/planned energy B +timing/25A/empty/none/cloud60s/car3p/L1/planned idle B +timing/25A/empty/none/cloud60s/car3p/L1/restart energy B +timing/25A/empty/none/cloud60s/car3p/L1/restart idle B +timing/25A/empty/none/cloud60s/car3p/L1/surplus idle readback +timing/25A/empty/none/easee120s/car3p/L1/chargenow over-cut netting +timing/25A/empty/none/easee120s/car3p/L1/pause over-cut netting +timing/25A/empty/none/easee120s/car3p/L1/planned energy B +timing/25A/empty/none/easee120s/car3p/L1/planned idle B +timing/25A/empty/none/easee120s/car3p/L1/restart energy B +timing/25A/empty/none/easee120s/car3p/L1/restart idle B +timing/25A/empty/none/easee120s/car3p/L1/surplus idle readback +timing/25A/empty/none/easee40s/car3p/L1/pause over-cut netting +timing/25A/empty/none/easee40s/car3p/L1/restart energy B +timing/25A/empty/none/easee40s/car3p/L1/restart idle B +timing/25A/empty/none/easee40s/car3p/L1/surplus idle readback +timing/25A/empty/none/fast2s/car3p/L1/planned energy B +timing/25A/empty/none/fast2s/car3p/L1/planned idle B +timing/25A/empty/none/fast2s/car3p/L1/restart energy B +timing/25A/empty/none/fast2s/car3p/L1/restart idle B +timing/25A/empty/none/fast2s/car3p/L1/surplus idle export diff --git a/go/internal/sitesim/sitesim.go b/go/internal/sitesim/sitesim.go index 647db565b..632d0503c 100644 --- a/go/internal/sitesim/sitesim.go +++ b/go/internal/sitesim/sitesim.go @@ -210,8 +210,16 @@ type EVSE struct { DrawW float64 // the car's draw now DrawPhases int SessionWh float64 // since the car plugged in - queue []evCommand - draws []timedW + + // The Easee driver's own choice for "auto" (easee_cloud.lua pick_phases): + // without a phase_split_w it splits at the fuse's current on one phase, + // and it keeps a phase count PhaseHold before it changes it. + SplitAtA float64 // > 0: auto with no split splits at voltage × SplitAtA + PhaseHold time.Duration // > 0: an auto phase count stays this long + phasesSent int + phasesAt time.Time + queue []evCommand + draws []timedW } type evCommand struct { @@ -221,13 +229,22 @@ type evCommand struct { } // Command sends powerW in phaseMode ("1p", "3p", "auto" or empty). Auto -// picks one phase below splitW, as the Easee driver does without its +// picks one phase below splitW, as the Easee driver does; PhaseHold adds its // hysteresis. Below MinA the charger pauses. func (e *EVSE) Command(now time.Time, powerW float64, phaseMode string, splitW, voltage float64) { phases := e.Phases + if phaseMode == "auto" && splitW <= 0 && e.SplitAtA > 0 { + splitW = voltage * e.SplitAtA + } if phaseMode == "1p" || (phaseMode == "auto" && powerW < math.Max(splitW, 3*voltage*e.MinA)) { phases = 1 } + if phaseMode == "auto" && e.PhaseHold > 0 && e.phasesSent > 0 && phases != e.phasesSent && now.Sub(e.phasesAt) < e.PhaseHold { + phases = e.phasesSent + } + if phases != e.phasesSent { + e.phasesSent, e.phasesAt = phases, now + } a := math.Min(math.Floor(powerW/voltage/float64(phases)+.5), e.MaxA) if a < e.MinA { a = 0 @@ -264,6 +281,16 @@ func (e *EVSE) step(now time.Time, car *Car, houseA [3]float64, voltage float64) return w } +// Queued is the last order the charger has not applied yet, as current per +// phase and phases; false when every order is in force. +func (e *EVSE) Queued() (a float64, phases int, ok bool) { + if len(e.queue) == 0 { + return 0, 0, false + } + c := e.queue[len(e.queue)-1] + return c.a, c.phases, true +} + // readback is the draw as it was ReadbackLag ago. func (e *EVSE) readback(now time.Time) float64 { seen := now.Add(-e.ReadbackLag) From e522408a1be6c179f27417cbf3d30b7daa031dd4 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 08:55:25 +0200 Subject: [PATCH 2/3] test(control): the write mode logs what it would fail --- go/internal/control/sitesim_field_matrix_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/go/internal/control/sitesim_field_matrix_test.go b/go/internal/control/sitesim_field_matrix_test.go index a195d5d25..3f1b8bd00 100644 --- a/go/internal/control/sitesim_field_matrix_test.go +++ b/go/internal/control/sitesim_field_matrix_test.go @@ -133,7 +133,10 @@ func TestSiteSimFieldMatrix(t *testing.T) { results = append(results, fieldResult{name: c.name(), failed: failed}) mu.Unlock() t.Logf("peak phase %.1f A, delivered %.1f kWh, car idle with room %v", s.peakA, f.deliveredWh/1000, s.idleWithRoom) - if len(problems) > 0 && !writeKnown { + switch { + case len(problems) > 0 && writeKnown: + t.Log(strings.Join(problems, "\n")) + case len(problems) > 0: t.Fatal(strings.Join(problems, "\n")) } }) From 992e33a5867e258ec64b3dde4e34f142e5ea6399 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 09:09:58 +0200 Subject: [PATCH 3/3] test(control): probe rows from the three-phase review Twenty-four rows from srcfl/ftw#1799's review and fix D: a charger whose table holds only one-phase levels (max 5,520 W, auto), phase mode unset with a goal and a 2,300 W plan slot, surplus-only with a goal and 2.5 kW of export, a held 1p order raised to 3p beside a phase at 20 A, the help report's site with a one-phase car, and a long L1 step under a three-phase car. Surplus cases get the planner's PV forecast as pickSurplusSteps reads it, so the one-phase rule is judged as Core makes it: one phase only when the next 30 min stay below three phases at 6 A. Surplus cuts follow the export and are no longer judged as fuse cuts. The trace also logs a change of reason. New label hold3p: the app sends Charge now as 3p for an auto loadpoint, and a charger whose table stops below 4,140 W gets 0 W. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/sitesim_field_test.go | 170 ++++++++++++++++-- go/internal/control/sitesim_test.go | 4 +- .../control/testdata/sitesim_field_known.txt | 36 ++++ 3 files changed, 192 insertions(+), 18 deletions(-) diff --git a/go/internal/control/sitesim_field_test.go b/go/internal/control/sitesim_field_test.go index 2d4e5b122..bf2ef5fa4 100644 --- a/go/internal/control/sitesim_field_test.go +++ b/go/internal/control/sitesim_field_test.go @@ -53,6 +53,7 @@ const ( useSurplus fieldUse = "surplus" // surplus_only while export steps up and down usePause fieldUse = "pause" // Charge now, Pause for 25 s, then Start useRestart fieldUse = "restart" // a planned charge across a 20 s Core restart + usePhaseUp fieldUse = "1p-to-3p" // a held 1p order changed to 3p after 5 min ) // fieldCase is one site and one request. The name is stable: the known rows @@ -71,6 +72,12 @@ type fieldCase struct { every time.Duration // control_interval_s; zero: 2 s lp func(*loadpoint.Config) car func(*sitesim.Car) + + charger string // the charger itself when not what the phase mode says: "1p32A" + planW float64 // > 0: the plan asks this many W each slot, as Wh only + goal bool // surplus-only with a ready-by goal + exportW []float64 // surplus: export each quarter hour + houseStep time.Duration // > 0: L1 +8 A for 10 min from this time into the run } func (c fieldCase) key() string { @@ -79,8 +86,20 @@ func (c fieldCase) key() string { mode = "empty" } name := fmt.Sprintf("%gA/%s/%s/%s/car%dp/%s/%s", c.fuseA, mode, c.steps, c.timing.name, c.carPhases, c.houseName, c.use) - if c.battery { - name += "+battery" + export := "" + if len(c.exportW) > 0 { + export = fmt.Sprintf("export%gW", c.exportW[0]) + } + for _, v := range []struct { + on bool + text string + }{ + {c.battery, "battery"}, {c.charger != "", "charger" + c.charger}, {c.planW > 0, fmt.Sprintf("plan%gW", c.planW)}, + {c.goal, "goal"}, {export != "", export}, {c.houseStep > 0, "L1step"}, + } { + if v.on { + name += "+" + v.text + } } return name } @@ -104,6 +123,8 @@ func stepTable(name string) (steps []float64, minW, maxW float64) { return slices.Concat([]float64{0}, amps(3, 6, 16)), 4140, 11040 case "1p": return slices.Concat([]float64{0}, amps(1, 6, 16)), 1380, 3680 + case "1p-3": // three one-phase levels and a maximum above them + return []float64{0, 1380, 2300, 3680}, 1380, 5520 } return nil, 4140, 11000 // What the settings page writes for a new charger. } @@ -221,9 +242,53 @@ func fieldCases() []fieldCase { } } } + for _, c := range probeCases() { + add(c) + } return out } +// probeCases are the edges srcfl/ftw#1799's review named, and the one-phase +// car that fix D is for. +func probeCases() []fieldCase { + var out []fieldCase + two := []fieldTiming{timingFast, timingEasee} + for _, tm := range two { + for _, use := range []fieldUse{usePlanned, useNow} { + // A table of one-phase levels with a maximum above it, on auto. + out = append(out, fieldCase{report: "probe", fuseA: 25, mode: "auto", steps: "1p-3", timing: tm, carPhases: 3, use: use, + house: houseBalanced, houseName: "balanced", charger: "1p32A"}) + } + for _, fuse := range []float64{16, 25} { + // Phase mode unset, a goal, and a plan slot of 2,300 W. + out = append(out, fieldCase{report: "probe", fuseA: fuse, mode: "", steps: "none", timing: tm, carPhases: 3, use: usePlanned, + house: houseBalanced, houseName: "balanced", planW: 2300}) + } + // Surplus-only with a goal and 2.5 kW of export: one phase is allowed. + out = append(out, fieldCase{report: "probe", fuseA: 25, mode: "auto", steps: "mixed", timing: tm, carPhases: 3, use: useSurplus, + house: houseBalanced, houseName: "balanced", goal: true, exportW: []float64{2500, 2500, 3000, 2500}}) + // A house step on one phase under a three-phase car is not a one-phase car. + out = append(out, fieldCase{report: "probe", fuseA: 25, mode: "3p", steps: "3p", timing: tm, carPhases: 3, use: usePlanned, + house: houseBalanced, houseName: "balanced", houseStep: 30 * time.Minute}) + } + for _, tm := range []fieldTiming{timingFast, timingEasee, timingSlowCloud} { + // A held 1p order raised to 3p beside a phase at 20 A. + out = append(out, fieldCase{report: "probe", fuseA: 25, mode: "auto", steps: "mixed", timing: tm, carPhases: 3, use: usePhaseUp, + house: [3]float64{2, 20, 2}, houseName: "2-20-2"}) + } + for _, tm := range fieldTimings { + for _, use := range []fieldUse{usePlanned, useNow} { + // The help report's site with a one-phase car. + c := helpReport(use, tm) + c.carPhases = 1 + out = append(out, c) + } + } + c := helpReport(usePlanned, timingEasee) + c.houseStep = 30 * time.Minute + return append(out, c) +} + // Failure kinds: the invariants. The sitesim adapter's own checks give the // first five. const ( @@ -262,6 +327,7 @@ type fieldPlan struct { deadline time.Time needWh float64 planning func() []float64 // the steps main.go gives the planner + fixedW float64 // > 0: every slot asks this, as Wh only slotStart time.Time stepW, slotWh float64 slotDelivered0 float64 @@ -286,7 +352,9 @@ func (p *fieldPlan) directive(now time.Time) (loadpoint.Directive, bool) { p.slotStart, p.slotDelivered0 = start, p.run.deliveredWh p.stepW, p.slotWh = 0, 0 left, hours := p.needWh-p.run.deliveredWh, p.deadline.Sub(start).Hours() - if steps := p.planning(); left > 0 && hours > 0 && len(steps) > 0 { + if p.fixedW > 0 { + p.stepW, p.slotWh = p.fixedW, p.fixedW*p.slotEnd().Sub(start).Hours() + } else if steps := p.planning(); left > 0 && hours > 0 && len(steps) > 0 { p.stepW = slices.Max(steps) for _, w := range steps { if w > 0 && w >= left/hours { @@ -297,8 +365,11 @@ func (p *fieldPlan) directive(now time.Time) (loadpoint.Directive, bool) { p.slotWh = math.Min(left, p.stepW*p.slotEnd().Sub(start).Hours()) } } - return loadpoint.Directive{SlotStart: p.slotStart, SlotEnd: p.slotEnd(), - LoadpointEnergyWh: map[string]float64{p.id: p.slotWh}, LoadpointMaxPowerW: map[string]float64{p.id: p.stepW}}, true + d := loadpoint.Directive{SlotStart: p.slotStart, SlotEnd: p.slotEnd(), LoadpointEnergyWh: map[string]float64{p.id: p.slotWh}} + if p.fixedW == 0 { + d.LoadpointMaxPowerW = map[string]float64{p.id: p.stepW} + } + return d, true } // fieldPlanningSteps is what main.go gives the planner for the session: @@ -337,6 +408,8 @@ type fieldRun struct { heatRoomSince time.Time // the phases' heat has left room to start since nextLook time.Time trace func(format string, args ...any) // FTW_FIELD_TRACE names the case + tracedReason string + exportPeak func(window time.Duration) (float64, bool) // surplus: the forecast from now } // FieldVerdict returns a reader of the EV verdict the API shows for a store @@ -402,12 +475,13 @@ func (f *fieldRun) send(ctx context.Context, s *simSite, driver string, payload } if err := json.Unmarshal(payload, &cmd); err == nil && cmd.Action == "ev_set_current" && driver == s.lp.DriverName && cmd.PowerW < s.offerW-1 && !s.p.Offline("evse") { - if st, _ := s.mgr.State(s.lp.ID); st.CommandedReason == "fuse_limit" { + // Surplus follows the export, an outside step; its cuts are not judged. + if st, _ := s.mgr.State(s.lp.ID); st.CommandedReason == "fuse_limit" && !s.surplus { f.checkCut(s, cmd.PowerW) } } - if f.trace != nil && cmd.Action == "ev_set_current" && cmd.PowerW != s.offerW { - st, _ := s.mgr.State(s.lp.ID) + if st, _ := s.mgr.State(s.lp.ID); f.trace != nil && cmd.Action == "ev_set_current" && (cmd.PowerW != s.offerW || st.CommandedReason != f.tracedReason) { + f.tracedReason = st.CommandedReason e := s.p.EVSE f.trace("%s order %.0f W (%s); phases %.1f A, offer %.0f A on %d, car %.0f W on %d", s.p.Now.Format("15:04:05"), cmd.PowerW, st.CommandedReason, s.p.A, e.OfferA, e.OfferPhases, e.DrawW, e.DrawPhases) @@ -471,7 +545,7 @@ func (f *fieldRun) checkCut(s *simSite, w float64) { // After an overage Core waits for the importing phases' heat (I² over 60 s, // the fuse's own memory) to leave room for the car's minimum and a step, for // evRestartDwell, as its start rule does. -func (f *fieldRun) wantsCharge(s *simSite) bool { +func (f *fieldRun) wantsCharge(s *simSite, baseW float64) bool { p, limit := s.p, f.limitA(s) room := true for i := range min(p.EVSE.Phases, p.Car.Phases) { @@ -492,10 +566,30 @@ func (f *fieldRun) wantsCharge(s *simSite) bool { return false case f.plan != nil: return f.plan.asks(p.Now, p.EVSE.Delay+time.Minute) + case s.surplus: + return -baseW >= f.surplusMinW(s) } return true } +// surplusMinW is the least export surplus charging can use: one phase at +// 6 A where the loadpoint may run on one phase and the next 30 min of +// forecast stay below three phases at 6 A, as pickSurplusSteps decides; +// else the car's phases. +func (f *fieldRun) surplusMinW(s *simSite) float64 { + e, phases := s.p.EVSE, min(s.p.EVSE.Phases, s.p.Car.Phases) + min3 := 3 * s.p.Voltage * e.MinA + onePhase := s.lp.PhaseMode != "3p" && slices.ContainsFunc(s.lp.AllowedStepsW, func(w float64) bool { return w > 0 && w < min3 }) + near, ok := 0.0, false + if f.exportPeak != nil { + near, ok = f.exportPeak(30 * time.Minute) + } + if onePhase && (!ok || near < min3) || s.lp.PhaseMode == "1p" { + phases = 1 + } + return e.MinA * s.p.Voltage * float64(phases) +} + // response is how long Core may wait for the charger's power to show an // order: what its driver declares, or Easee's 180 s when it declares nothing. func (f *fieldRun) response() time.Duration { @@ -512,7 +606,7 @@ func (f *fieldRun) check(s *simSite) { if p.EVSE.DrawW != f.drawW { f.drawW, f.drawChangedAt = p.EVSE.DrawW, now } - if f.c.use != useSurplus && p.EVSE.Phases == 3 && p.EVSE.OfferPhases == 1 && p.EVSE.OfferA > 0 { + if f.c.use != useSurplus && f.c.use != usePhaseUp && p.EVSE.Phases == 3 && p.EVSE.OfferPhases == 1 && p.EVSE.OfferA > 0 { f.fail(s, failOnePhase, "%s put the three-phase charger on one phase at %.0f A", f.c.use, p.EVSE.OfferA) } up := s.downUntil.IsZero() @@ -578,6 +672,9 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) phases = 1 } p.EVSE = &sitesim.EVSE{Phases: phases, MinA: 6, MaxA: 16, Delay: c.timing.delay} + if c.charger == "1p32A" { + p.EVSE.Phases, p.EVSE.MaxA = 1, 32 + } if c.timing.cloud { p.EVSE.ReadbackLag, p.EVSE.SplitAtA, p.EVSE.PhaseHold = 10*time.Second, c.fuseA, 90*time.Second } @@ -604,6 +701,7 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) return func(p *sitesim.Plant) { do(p); s.outsideAt = p.Now } } var actions []fieldAction + var exportPlan []float64 // surplus: each quarter hour's export without the car switch c.use { case usePlanned, useRestart: run, kettle = 3*time.Hour, []time.Duration{50 * time.Minute, 140 * time.Minute} @@ -611,9 +709,12 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) run, kettle = 90*time.Minute, []time.Duration{50 * time.Minute} p.Events = append(p.Events, sitesim.Event{At: 30 * time.Minute, Kind: sitesim.CoreRestart, For: 20 * time.Second}) } - roomW := fieldRoomW(c, p.EVSE, p.Car) + roomW, askW := fieldRoomW(c, p.EVSE, p.Car), 3300.0 + if c.planW > 0 { + askW = c.planW + } f.plan = &fieldPlan{id: lp.ID, deadline: start.Add(run), run: f, planning: func() []float64 { return fieldPlanningSteps(s) }, - needWh: math.Min(3300*run.Hours(), .8*roomW*run.Hours())} + fixedW: c.planW, needWh: math.Min(askW*run.Hours(), .8*roomW*run.Hours())} s.plan = f.plan.directive case usePause: run, kettle = 15*time.Minute, []time.Duration{11 * time.Minute} @@ -627,6 +728,11 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) s.ctrl.SetManualHold(lp.ID, loadpoint.ManualHold{PowerW: lp.MaxChargeW, PhaseMode: f.holdPhase, Persistent: true, StartedAt: s.p.Now, UpdatedAt: s.p.Now}) }}, } + case usePhaseUp: + run, kettle = 15*time.Minute, nil + actions = []fieldAction{{5 * time.Minute, func(s *simSite) { + s.ctrl.SetManualHold(lp.ID, loadpoint.ManualHold{PowerW: lp.MaxChargeW, PhaseMode: "3p", Persistent: true, StartedAt: s.p.Now, UpdatedAt: s.p.Now}) + }}} case useSurplus: run, kettle = time.Hour, nil // Export steps up and down, never past the fuse with the car off. @@ -634,8 +740,13 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) for _, a := range c.house { maxPVW = math.Min(maxPVW, 3*230*(c.fuseA-2*evPhaseMarginA+a)) } - for i, exportW := range []float64{2500, 6000, 9000, 3000} { + exports := []float64{2500, 6000, 9000, 3000} + if len(c.exportW) > 0 { + exports = c.exportW + } + for i, exportW := range exports { pvW := -math.Min(houseW+exportW, maxPVW) + exportPlan = append(exportPlan, -pvW-houseW) p.Events = append(p.Events, sitesim.Event{At: time.Duration(i) * 15 * time.Minute, Do: outside(func(p *sitesim.Plant) { p.PVW = pvW })}) } } @@ -645,6 +756,11 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) sitesim.Event{At: at, Do: outside(func(p *sitesim.Plant) { p.HouseW[0] += 6 * 230 })}, sitesim.Event{At: at + 3*time.Minute, Do: outside(func(p *sitesim.Plant) { p.HouseW[0] -= 6 * 230 })}) } + if c.houseStep > 0 { + p.Events = append(p.Events, + sitesim.Event{At: c.houseStep, Do: outside(func(p *sitesim.Plant) { p.HouseW[0] += 8 * 230 })}, + sitesim.Event{At: c.houseStep + 10*time.Minute, Do: outside(func(p *sitesim.Plant) { p.HouseW[0] -= 8 * 230 })}) + } slices.SortStableFunc(p.Events, func(a, b sitesim.Event) int { return cmp.Compare(a.At, b.At) }) s.setup = func(s *simSite) { @@ -657,17 +773,39 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) } return err }) - if c.use == useNow || c.use == usePause { + switch c.use { + case useNow, usePause: // Charge now from the app, which names the loadpoint's phases (web/app.js). s.ctrl.SetManualHold(lp.ID, loadpoint.ManualHold{PowerW: lp.MaxChargeW, PhaseMode: f.holdPhase, Persistent: true, StartedAt: s.p.Now, UpdatedAt: s.p.Now}) + case usePhaseUp: + s.ctrl.SetManualHold(lp.ID, loadpoint.ManualHold{PowerW: 3680, PhaseMode: "1p", Persistent: true, StartedAt: s.p.Now, UpdatedAt: s.p.Now}) } - if f.plan != nil { - deadline := f.plan.deadline.Local() + if f.plan != nil || c.goal { + deadline := start.Add(run).Local() s.mgr.SetSchedule(lp.ID, loadpoint.NewSchedule(1, deadline.Hour()*60+deadline.Minute(), false, 0, false)) } if c.battery { fieldBatteryPlan(s, 6000) } + if exportPlan != nil { + // The planner's PV forecast, which decides when surplus may use + // one phase: here it knows the export to come. + peak := func(from, to time.Duration) (float64, bool) { + w, ok := 0.0, false + for i, e := range exportPlan { + at := time.Duration(i) * 15 * time.Minute + if at+15*time.Minute > from && at <= to { + w, ok = math.Max(w, e), true + } + } + return w, ok + } + f.exportPeak = func(window time.Duration) (float64, bool) { + return peak(s.p.Now.Sub(start), s.p.Now.Sub(start)+window) + } + s.ctrl.SetNearTermPeakSurplusW(f.exportPeak) + s.ctrl.SetPeakRemainingSurplusW(func() (float64, bool) { return peak(s.p.Now.Sub(start), run) }) + } if FieldVerdict != nil { f.verdict = FieldVerdict(s.store, s.mgr, s.ctrl) } diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 9f227bb05..66976ea78 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -390,8 +390,8 @@ func (s *simSite) wantsCharge() bool { return false } } - if s.field != nil && !s.field.wantsCharge(s) { - return false + if s.field != nil { + return s.field.wantsCharge(s, baseW) } return !s.surplus || -baseW >= minW } diff --git a/go/internal/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt index 7e8836c5c..a1ac6b390 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -21,6 +21,13 @@ # last raise, then pauses when both land (surplus_reserve.go:128). # relief srcfl/ftw#1789, draft: the battery ends its relief before the # car's cut shows. +# hold3p no fix yet: the app sends Charge now as 3p for an auto +# loadpoint, and on a charger whose table stops below 4,140 W +# Core floors the order to 0 W (fuse_limit). +help1022/20A/auto/own/cloud60s/car1p/2A/chargenow over-cut netting +help1022/20A/auto/own/cloud60s/car1p/2A/chargenow verdict #1795 +help1022/20A/auto/own/cloud60s/car1p/2A/planned one-phase B +help1022/20A/auto/own/cloud60s/car1p/2A/planned over-cut B help1022/20A/auto/own/cloud60s/car3p/2A/chargenow over-cut netting help1022/20A/auto/own/cloud60s/car3p/2A/pause over-cut netting help1022/20A/auto/own/cloud60s/car3p/2A/planned cut A @@ -30,6 +37,12 @@ help1022/20A/auto/own/cloud60s/car3p/2A/restart cut A help1022/20A/auto/own/cloud60s/car3p/2A/restart energy A help1022/20A/auto/own/cloud60s/car3p/2A/restart one-phase B help1022/20A/auto/own/cloud60s/car3p/2A/restart over-cut B +help1022/20A/auto/own/easee120s/car1p/2A/chargenow over-cut netting +help1022/20A/auto/own/easee120s/car1p/2A/chargenow verdict #1795 +help1022/20A/auto/own/easee120s/car1p/2A/planned cut A +help1022/20A/auto/own/easee120s/car1p/2A/planned energy A +help1022/20A/auto/own/easee120s/car1p/2A/planned one-phase B +help1022/20A/auto/own/easee120s/car1p/2A/planned over-cut B help1022/20A/auto/own/easee120s/car3p/2A/chargenow over-cut netting help1022/20A/auto/own/easee120s/car3p/2A/pause over-cut netting help1022/20A/auto/own/easee120s/car3p/2A/planned cut A @@ -40,13 +53,23 @@ help1022/20A/auto/own/easee120s/car3p/2A/restart cut A help1022/20A/auto/own/easee120s/car3p/2A/restart energy A help1022/20A/auto/own/easee120s/car3p/2A/restart one-phase B help1022/20A/auto/own/easee120s/car3p/2A/restart over-cut B +help1022/20A/auto/own/easee40s/car1p/2A/chargenow over-cut netting +help1022/20A/auto/own/easee40s/car1p/2A/chargenow verdict #1795 +help1022/20A/auto/own/easee40s/car1p/2A/planned one-phase B +help1022/20A/auto/own/easee40s/car1p/2A/planned over-cut B help1022/20A/auto/own/easee40s/car3p/2A/chargenow over-cut netting help1022/20A/auto/own/easee40s/car3p/2A/pause over-cut netting help1022/20A/auto/own/easee40s/car3p/2A/planned one-phase B help1022/20A/auto/own/easee40s/car3p/2A/planned over-cut B +help1022/20A/auto/own/easee40s/car3p/2A/planned+L1step cut A +help1022/20A/auto/own/easee40s/car3p/2A/planned+L1step energy A +help1022/20A/auto/own/easee40s/car3p/2A/planned+L1step one-phase B +help1022/20A/auto/own/easee40s/car3p/2A/planned+L1step over-cut B help1022/20A/auto/own/easee40s/car3p/2A/restart cut A help1022/20A/auto/own/easee40s/car3p/2A/restart one-phase B help1022/20A/auto/own/easee40s/car3p/2A/restart over-cut B +help1022/20A/auto/own/fast2s/car1p/2A/chargenow verdict #1795 +help1022/20A/auto/own/fast2s/car1p/2A/planned one-phase B help1022/20A/auto/own/fast2s/car3p/2A/planned one-phase B help1022/20A/auto/own/fast2s/car3p/2A/restart one-phase B homebox/16A/empty/none/cloud60s/car3p/balanced/chargenow over-cut netting @@ -78,6 +101,19 @@ pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/chargenow+battery over-cut netting pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/chargenow+battery own-raise relief pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/pause+battery own-raise relief pr1763/25A/3p/3p-15A/fast2s/car3p/6-2-2/pause+battery own-raise relief +probe/16A/empty/none/easee40s/car3p/balanced/planned+plan2300W energy B +probe/16A/empty/none/easee40s/car3p/balanced/planned+plan2300W idle B +probe/16A/empty/none/fast2s/car3p/balanced/planned+plan2300W energy B +probe/16A/empty/none/fast2s/car3p/balanced/planned+plan2300W idle B +probe/25A/auto/1p-3/easee40s/car3p/balanced/chargenow+charger1p32A idle hold3p +probe/25A/auto/1p-3/easee40s/car3p/balanced/planned+charger1p32A cut A +probe/25A/auto/1p-3/fast2s/car3p/balanced/chargenow+charger1p32A idle hold3p +probe/25A/auto/1p-3/fast2s/car3p/balanced/planned+charger1p32A cut A +probe/25A/auto/1p-3/fast2s/car3p/balanced/planned+charger1p32A energy A +probe/25A/auto/mixed/easee40s/car3p/balanced/surplus+goal+export2500W idle readback +probe/25A/empty/none/easee40s/car3p/balanced/planned+plan2300W idle B +probe/25A/empty/none/easee40s/car3p/balanced/planned+plan2300W limit B +probe/25A/empty/none/easee40s/car3p/balanced/planned+plan2300W one-phase B sweep/16A/1p/1p/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/1p/1p/easee40s/car3p/balanced/planned over-cut netting sweep/16A/1p/none/easee40s/car3p/balanced/chargenow over-cut netting