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..3f1b8bd00 --- /dev/null +++ b/go/internal/control/sitesim_field_matrix_test.go @@ -0,0 +1,145 @@ +//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) + switch { + case len(problems) > 0 && writeKnown: + t.Log(strings.Join(problems, "\n")) + case len(problems) > 0: + 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..bf2ef5fa4 --- /dev/null +++ b/go/internal/control/sitesim_field_test.go @@ -0,0 +1,842 @@ +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 + 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 +// 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) + + 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 { + 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) + 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 +} + +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 + 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. +} + +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"}) + } + } + } + } + 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 ( + 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 + fixedW float64 // > 0: every slot asks this, as Wh only + 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 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 { + p.stepW = w + break + } + } + p.slotWh = math.Min(left, p.stepW*p.slotEnd().Sub(start).Hours()) + } + } + 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: +// 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 + 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 +// 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") { + // 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 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) + } + 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, baseW float64) 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) + 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 { + 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 && 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() + 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.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 + } + 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 + 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} + 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, 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) }, + 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} + 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 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. + 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)) + } + 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 })}) + } + } + // 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 })}) + } + 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) { + // 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 + }) + 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 || 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) + } + } + 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..66976ea78 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 { + 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 new file mode 100644 index 000000000..a1ac6b390 --- /dev/null +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -0,0 +1,422 @@ +# 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. +# 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 +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/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 +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/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 +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 +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 +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)