diff --git a/.changeset/b-ev-fuse-line-at-rating.md b/.changeset/b-ev-fuse-line-at-rating.md index 71934e1cb..35c6f7961 100644 --- a/.changeset/b-ev-fuse-line-at-rating.md +++ b/.changeset/b-ev-fuse-line-at-rating.md @@ -2,4 +2,4 @@ "ftw": patch --- -The car now charges up to the fuse rating less `fuse.safety_margin_a`, not a fixed 1 A below it. A charging car rises one step once that step fits under the line, instead of needing two steps of room and keeping one free; an idle car starts with all the room it has. Home battery charge beside a car uses the same line, with no 1 A floor. On a three-phase site with the default 0.5 A margin, a car near the fuse gets 0.5 A to 1.5 A more on each phase. Cuts above the line still wait for the fuse's heat budget, and the hour's import limit keeps its own margins. A car never rises or starts into battery discharge that the fuse guard added. When a battery goes offline, its share of that discharge counts for 20 s, the time Core gives the battery's default mode to reach the meter, and then stops holding the car back, whether or not another battery is online. +The car now charges up to the fuse rating less `fuse.safety_margin_a`, not a fixed 1 A below it. A charging car rises one step once that step fits under the line, instead of needing two steps of room and keeping one free; an idle car starts with all the room it has. Home battery charge beside a car uses the same line, with no 1 A floor. On a three-phase site with a 0.5 A margin, a car near the fuse gets 0.5 A to 1.5 A more on each phase. Cuts above the line still wait for the fuse's heat budget, and the hour's import limit keeps its own margins. A car never rises or starts into battery discharge that the fuse guard added. When a battery goes offline, its share of that discharge counts for 20 s, the time Core gives the battery's default mode to reach the meter, and then stops holding the car back, whether or not another battery is online. diff --git a/.changeset/d-fuse-margin-default-0.md b/.changeset/d-fuse-margin-default-0.md new file mode 100644 index 000000000..4231621c6 --- /dev/null +++ b/.changeset/d-fuse-margin-default-0.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +`fuse.safety_margin_a` now defaults to 0. On a site with no saved margin, FTW works to the fuse rating: the car, the home battery's charge and fuse relief, and the fuse's total import and export ceilings each move up 0.5 A per phase, to the rating. A margin is opt-in, for an inverter whose own phase limiter trips at the rating. FTW keeps any margin you have saved. If yours is exactly 0.5 A, FTW's old default, the log says so once at startup; set 0 to drop it. diff --git a/.changeset/p1-car-before-battery-charge.md b/.changeset/p1-car-before-battery-charge.md new file mode 100644 index 000000000..ed9f223d8 --- /dev/null +++ b/.changeset/p1-car-before-battery-charge.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The car comes before home battery charging, also when the battery does not cover the car. The battery now charges only from the solar surplus the car leaves. Before, with battery cover off, FTW took the car out of the meter reading, so the battery charged from solar the car was using while the site imported up to 10 kW. Battery cover off still means the battery never discharges into the car. A planned grid purchase keeps its charge. In manual peak shaving, a battery charge that was running when the car started now comes off while the site imports, instead of holding under the peak limit. diff --git a/.changeset/s4-ev-base-after-restart.md b/.changeset/s4-ev-base-after-restart.md new file mode 100644 index 000000000..b9eb43572 --- /dev/null +++ b/.changeset/s4-ev-base-after-restart.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +After a restart, the home battery no longer stays in discharge for a car the meter already shows charging. Until the charger's first order of the new run can have landed, the battery counts the car's measured draw as part of the meter reading, never above FTW's latest order to the car and never above the consumption the site meter shows. FTW does not know the charger's offer in force then: one from before the restart, under an older max_charge_w, or the charger's own default. So the battery still keeps room for the car to rise from its draw to the larger of the charger's max_charge_w and the site fuse's rating. On a site whose fuse allows more than the car's draw plus FTW's latest order, that leaves no credit; a charger with no max_charge_w gets none either. The draw counts only when the sample reached FTW at least 1 s, and at most one control interval more, before the site reading did, and the charger's last poll succeeded. FTW judges a sample by when it arrived, not by the charger's own stamp, so a charger clock running behind FTW's, as OCPP chargers stamp their own samples, does not matter. A sample already there when FTW started does not count. The lowest sample since then counts, so a rise the meter does not show yet never does. A charger that samples every 30 s or more rarely gets the credit. Before, the 0 A orders FTW sent while the meter was still starting counted as the car's draw, so a lower order looked like a rise and the battery kept discharging toward its floor. diff --git a/go/cmd/ftw/control_state.go b/go/cmd/ftw/control_state.go index 5e4cd3b02..7dc634de4 100644 --- a/go/cmd/ftw/control_state.go +++ b/go/cmd/ftw/control_state.go @@ -32,9 +32,8 @@ func newControlStateFromConfig(cfg *config.Config) *control.State { ctrl.SiteFuseAmps = cfg.Fuse.MaxAmps ctrl.SiteFuseVoltage = cfg.Fuse.Voltage ctrl.SiteFusePhases = cfg.Fuse.Phases - // EffectiveSafetyMarginA distinguishes nil ("unset, use default") - // from explicit 0 ("operator chose to disable"). The earlier - // `<= 0 -> default` shortcut clobbered the disable case. + // EffectiveSafetyMarginA resolves nil to the default (0: work to the + // fuse rating) and keeps any explicit value, 0 included. ctrl.SiteFuseSafetyA = cfg.Fuse.EffectiveSafetyMarginA() // PV surplus absorber underlay (opt-in). cap == 0 keeps it off. ctrl.PVSurplusAbsorbSoCCap = cfg.Site.PVSurplusAbsorbSoCCap diff --git a/go/cmd/ftw/control_state_test.go b/go/cmd/ftw/control_state_test.go index 8c516be71..5f4fad63d 100644 --- a/go/cmd/ftw/control_state_test.go +++ b/go/cmd/ftw/control_state_test.go @@ -167,6 +167,29 @@ func TestBatteryLimitConfigBothZeroUsesDefault(t *testing.T) { } } +// A config with no fuse.safety_margin_a reaches Core at the fuse rating, and +// an explicit value still wins. +func TestUnsetFuseMarginReachesCoreAsZero(t *testing.T) { + cfg := parseBatteryLimitConfig(t, "", " max_charge_w: 10000\n") + ctrl := newControlStateFromConfig(cfg) + if ctrl.SiteFuseSafetyA != 0 { + t.Fatalf("unset margin reached Core as %v A, want 0", ctrl.SiteFuseSafetyA) + } + // 63 A × 230 V × 3 = 43470 W is the ceiling with no margin. + if err := ctrl.SetPeakLimit(43470); err != nil { + t.Fatalf("peak limit at the fuse: %v", err) + } + if err := ctrl.SetPeakLimit(43471); err == nil { + t.Fatal("a peak limit 1 W over the fuse was accepted") + } + + half := 0.5 + cfg.Fuse.SafetyMarginA = &half + if got := newControlStateFromConfig(cfg).SiteFuseSafetyA; got != 0.5 { + t.Fatalf("explicit 0.5 A reached Core as %v A", got) + } +} + // A "NaN" saved from an MQTT command must not come back as the PI setpoint // on every boot. func TestRestoredGridTargetIgnoresNonFinite(t *testing.T) { diff --git a/go/internal/config/config.go b/go/internal/config/config.go index e75a2380d..b5fb17a08 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -54,8 +54,9 @@ type Config struct { LoadWarnings []string `yaml:"-" json:"-"` LoadpointWarnings []string `yaml:"-" json:"-"` // Retired names settings of removed features that loading dropped so - // they cannot steer the site. Startup warns once for each; - // DropRetiredSettings deletes them from stored settings. + // they cannot steer the site, and stored values that match an old + // default. Startup warns once for each; DropRetiredSettings deletes the + // removed settings from stored settings and leaves the values alone. Retired []string `yaml:"-" json:"-"` // Used once at startup to end an old calendar's persisted away selection. RetiredCalendarEnabled bool `yaml:"-" json:"-"` @@ -868,11 +869,16 @@ type Site struct { MaxExportW float64 `yaml:"max_export_w,omitempty" json:"max_export_w,omitempty"` } -// DefaultFuseSafetyMarginA is the fall-back per-phase amp headroom -// applied when fuse.safety_margin_a is unset (nil) in the YAML. -// Single source of truth — main.go routes through Fuse.Effective- -// SafetyMarginA() rather than re-declaring it. -const DefaultFuseSafetyMarginA = 0.5 +// DefaultFuseSafetyMarginA is the per-phase margin applied when +// fuse.safety_margin_a is unset (nil). Core works to the fuse rating by +// default; a margin is opt-in. Single source of truth: main.go routes +// through Fuse.EffectiveSafetyMarginA() rather than re-declaring it. +const DefaultFuseSafetyMarginA = 0.0 + +// oldDefaultFuseSafetyMarginA was the default until FTW worked to the fuse +// rating. A stored value equal to it may be the settings form's old prefill +// rather than a choice, so loading names it once and keeps it. +const oldDefaultFuseSafetyMarginA = 0.5 // Fuse describes the shared breaker limit used by the fuse guard. type Fuse struct { @@ -880,15 +886,11 @@ type Fuse struct { Phases int `yaml:"phases" json:"phases"` Voltage float64 `yaml:"voltage" json:"voltage"` - // SafetyMarginA reserves headroom (per-phase amps) below MaxAmps - // inside the dispatch fuse guard. Pointer so we can distinguish - // "unset" (nil → DefaultFuseSafetyMarginA) from "explicitly - // disabled" (non-nil 0.0). Inverters often have their own per- - // phase current protection that trips before the breaker; without - // a margin the dispatch can ride right up to MaxAmps and the - // inverter cuts to 0 W in one tick, then dispatch ramps back up — - // visible as a flap. 0.5 A × 230 V × 3 phases ≈ 345 W of aggregate - // headroom. + // SafetyMarginA is an opt-in per-phase margin below MaxAmps: each + // phase's working limit is MaxAmps − SafetyMarginA. Pointer so an + // explicit value, 0 included, always wins over the default (nil → + // DefaultFuseSafetyMarginA, which is 0). Set it for an inverter whose + // own per-phase limiter trips at the rating and cuts to 0 W. SafetyMarginA *float64 `yaml:"safety_margin_a,omitempty" json:"safety_margin_a,omitempty"` } @@ -897,10 +899,9 @@ func (f Fuse) MaxPowerW() float64 { return f.MaxAmps * f.Voltage * float64(f.Phases) } -// EffectiveSafetyMarginA returns the per-phase amp headroom to apply, -// resolving nil ("unset → use default") vs an explicit value (including -// 0.0 to disable the margin entirely). Single read site so the default -// can never drift across consumers. +// EffectiveSafetyMarginA returns the per-phase margin to apply: the +// explicit value when set, otherwise DefaultFuseSafetyMarginA. Single read +// site so the default can never drift across consumers. func (f Fuse) EffectiveSafetyMarginA() float64 { if f.SafetyMarginA == nil { return DefaultFuseSafetyMarginA @@ -908,6 +909,17 @@ func (f Fuse) EffectiveSafetyMarginA() float64 { return *f.SafetyMarginA } +// noteOldFuseMargin adds one notice when fuse.safety_margin_a holds FTW's +// old default. The value stays: from April to September 2026 the settings +// form saved its 0.5 A prefill on any save, but a value saved later is the +// user's choice, and the stored document cannot tell the two apart. +func (c *Config) noteOldFuseMargin() { + if m := c.Fuse.SafetyMarginA; m != nil && *m == oldDefaultFuseSafetyMarginA { + c.Retired = append(c.Retired, + "fuse.safety_margin_a is 0.5 A, FTW's old default. FTW now works to the fuse rating. Set 0 to use it.") + } +} + // Driver is one driver entry. Each driver is a Lua script loaded by // the driver host at startup (or on hot-reload via the file watcher). type Driver struct { @@ -1496,6 +1508,7 @@ func Parse(data []byte, baseDir string) (*Config, error) { var retiredControl retiredControlSettings _ = doc.Decode(&retiredControl) c.applyRetiredControl(retiredControl) + c.noteOldFuseMargin() // An omitted app_link section follows the new default. An explicit YAML // null was a valid opt-out before that default changed, so retain it as an // explicit disabled section instead of letting applyDefaults turn it on. @@ -2074,7 +2087,7 @@ func (c *Config) Validate() error { } // safety_margin_a must be in [0, max_amps) when explicitly set. // Negative would *raise* the per-phase threshold above the breaker - // rating (defeating the guard); >= max_amps zeroes the headroom + // rating (defeating the guard); >= max_amps leaves no working limit // and silently disables the per-phase clamp — both are real safety // holes if reached through a typo'd config. nil (unset) is OK and // resolves to DefaultFuseSafetyMarginA at the consumer. diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index 86bb85cb9..d628ee75a 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -1260,12 +1260,43 @@ func TestNotificationsMaskSecrets(t *testing.T) { } } +// An unset margin works to the fuse rating: the default is 0 and a margin +// is opt-in (owner rule, 27 Oct 2026). func TestFuseSafetyMarginNilUsesDefault(t *testing.T) { - // Field omitted in YAML → nil pointer → default 0.5 A. f := Fuse{MaxAmps: 16, Voltage: 230, Phases: 3} - if got := f.EffectiveSafetyMarginA(); got != DefaultFuseSafetyMarginA { - t.Errorf("nil margin: got %v, want %v (DefaultFuseSafetyMarginA)", - got, DefaultFuseSafetyMarginA) + if got := f.EffectiveSafetyMarginA(); got != 0 || DefaultFuseSafetyMarginA != 0 { + t.Errorf("nil margin: got %v (default %v), want 0", got, DefaultFuseSafetyMarginA) + } +} + +// Every explicit margin is kept. Exactly 0.5 A was the old default, which +// the settings form once saved on any save, so loading adds one notice for +// it and changes nothing. +func TestFuseSafetyMarginLoadKeepsExplicitValues(t *testing.T) { + for _, tc := range []struct { + line string + want float64 + wantNotice bool + }{ + {"", 0, false}, + {" safety_margin_a: 0\n", 0, false}, + {" safety_margin_a: 0.5\n", 0.5, true}, + {" safety_margin_a: 0.4\n", 0.4, false}, + {" safety_margin_a: 1.2\n", 1.2, false}, + } { + yaml := strings.Replace(minimalYAML, " max_amps: 16\n", " max_amps: 16\n"+tc.line, 1) + c, err := Parse([]byte(yaml), "/tmp") + if err != nil { + t.Fatalf("%q: %v", tc.line, err) + } + if got := c.Fuse.EffectiveSafetyMarginA(); got != tc.want { + t.Errorf("%q: margin %v, want %v", tc.line, got, tc.want) + } + notice := len(c.Retired) == 1 && strings.Contains(c.Retired[0], "old default") && + strings.Contains(c.Retired[0], "Set 0") + if notice != tc.wantNotice || (!tc.wantNotice && len(c.Retired) != 0) { + t.Errorf("%q: notices %q, want the old-default notice: %v", tc.line, c.Retired, tc.wantNotice) + } } } diff --git a/go/internal/config/retired_control_test.go b/go/internal/config/retired_control_test.go index 0eb9f03f8..ca0897e03 100644 --- a/go/internal/config/retired_control_test.go +++ b/go/internal/config/retired_control_test.go @@ -132,3 +132,51 @@ func TestStoredRetiredControlSettingsMigrateAndAreDeleted(t *testing.T) { t.Fatalf("second DropRetiredSettings = %v, %v; want nothing", removed, err) } } + +// A stored 0.5 A margin keeps steering the site and stays in state.db; the +// notice comes from loading it, so dropping retired settings leaves it alone. +func TestStoredOldDefaultFuseMarginIsKeptWithANotice(t *testing.T) { + dir := t.TempDir() + path, database := filepath.Join(dir, "config.yaml"), filepath.Join(dir, "state.db") + yaml := strings.Replace(minimalYAML, " max_amps: 16\n", " max_amps: 16\n safety_margin_a: 0.5\n", 1) + if err := os.WriteFile(path, []byte(yaml), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + st, err := state.Open(database) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if _, err := InitializeStorage(path, database, cfg, st); err != nil { + t.Fatal(err) + } + for range 2 { + loaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + if loaded.ConfigDatabase == "" { + t.Fatal("loaded from the YAML file, not from stored settings") + } + if got := loaded.Fuse.EffectiveSafetyMarginA(); got != 0.5 { + t.Fatalf("stored margin = %v, want 0.5", got) + } + if len(loaded.Retired) != 1 || !strings.Contains(loaded.Retired[0], "safety_margin_a is 0.5 A") { + t.Fatalf("notices = %q, want one naming the old 0.5 A default", loaded.Retired) + } + if removed, err := DropRetiredSettings(st, path, loaded); err != nil || len(removed) != 0 { + t.Fatalf("DropRetiredSettings = %v, %v; the margin is a setting, not a retired one", removed, err) + } + } + after, _, err := st.Configuration() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(after.Document), `"safety_margin_a":0.5`) { + t.Fatalf("stored settings lost the margin: %s", after.Document) + } +} diff --git a/go/internal/config/storage.go b/go/internal/config/storage.go index 5c6216825..5b002ef49 100644 --- a/go/internal/config/storage.go +++ b/go/internal/config/storage.go @@ -65,6 +65,7 @@ func decodeStored(c state.Configuration, database, baseDir string) (*Config, err } _ = json.Unmarshal(c.Document, &legacyControl) cfg.applyRetiredControl(legacyControl.Config) + cfg.noteOldFuseMargin() // This is the same typed config that was validated on save. Do not apply new // defaults during a storage-only reload: nil, false and zero stay distinct. // The only exception is the site timings, which have no meaning at zero. diff --git a/go/internal/configreload/apply.go b/go/internal/configreload/apply.go index 304b8638c..fa1d418c6 100644 --- a/go/internal/configreload/apply.go +++ b/go/internal/configreload/apply.go @@ -85,7 +85,8 @@ func Apply( ctrl.SiteFuseAmps = newCfg.Fuse.MaxAmps ctrl.SiteFuseVoltage = newCfg.Fuse.Voltage ctrl.SiteFusePhases = newCfg.Fuse.Phases - // nil → 0.5, explicit 0 → disabled. See EffectiveSafetyMarginA. + // nil → 0 (work to the fuse rating), an explicit value wins. See + // EffectiveSafetyMarginA. ctrl.SiteFuseSafetyA = newCfg.Fuse.EffectiveSafetyMarginA() ctrl.MaxExportW = newCfg.Site.MaxExportW // Site-meter swap (operator moved `is_site_meter: true` from one diff --git a/go/internal/control/control_test.go b/go/internal/control/control_test.go index dc9a400d5..6a2f97f89 100644 --- a/go/internal/control/control_test.go +++ b/go/internal/control/control_test.go @@ -4063,12 +4063,23 @@ func TestPlannerSelfIdleGateLeavesSurplusOnlyEVReserve(t *testing.T) { st.MinDispatchIntervalS = 0 st.SlotDirective = func(time.Time) (SlotDirective, bool) { return dir, true } - targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) - if len(targets) != 1 { - t.Fatalf("want 1 target, got %d", len(targets)) + // The PI may take a few ticks to get there; it must never pass it. + var last float64 + for i := 0; i < 10; i++ { + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) + if len(targets) != 1 { + t.Fatalf("tick %d: want 1 target, got %d", i, len(targets)) + } + last = targets[0].TargetW + if last > 1000+1 { + t.Fatalf("tick %d: TargetW = %f — battery took the surplus-only EV reserve", i, last) + } + store.Update("ferroamp", telemetry.DerBattery, last, ptrF64(0.5), nil) + store.Update("ferroamp", telemetry.DerMeter, -3000+last, nil, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() } - if math.Abs(targets[0].TargetW-1000) > 1 { - t.Errorf("TargetW = %f — idle gate must leave surplus-only EV reserve; want ~1000", targets[0].TargetW) + if math.Abs(last-1000) > 1 { + t.Errorf("TargetW = %f — idle gate must leave surplus-only EV reserve; want ~1000", last) } } @@ -5497,20 +5508,19 @@ func TestEnergyDispatchClampsDischargeWhenEVActuallyCharging(t *testing.T) { // charge battery, EV is on surplus_only at 2.5 kW with 11 kW max, // and 3 kW is currently exporting at the real meter. // -// dispatch.go:685 subtracts EVChargingW from gridW before the home- -// battery decision so the battery sees "what export would exist -// without the EV", which is 3000 + 2500 = 5500 W in this scenario. -// The reserve cap then carves out reserveRemaining for the EV to -// step up into. +// The car comes before battery charge, so the battery sees the +// export the car leaves: the real 3000 W, not the 5500 W there would +// be without the car. The reserve cap then carves out +// reserveRemaining for the EV to step up into. // // Pre-fix (reserve = full MaxChargeW = 11000): reserveRemaining -// = 11000 - 2500 = 8500. ceiling = 5500 - 8500 = -3000 → 0. Battery +// = 11000 - 2500 = 8500. ceiling = 3000 - 8500 → 0. Battery // idles; surplus dumps to grid. // // Post-fix (reserve = CurrentPowerW + EVRampHeadroomW = 4500): -// reserveRemaining = 4500 - 2500 = 2000. ceiling = 5500 - 2000 = -// 3500. Battery picks up the bulk of the surplus while leaving 2 kW -// of headroom for the EV to ramp up. +// reserveRemaining = 4500 - 2500 = 2000. ceiling = 3000 - 2000 = +// 1000. Battery takes the surplus beyond 2 kW of headroom for the +// EV to ramp up, and none of the car's own 2.5 kW. // // The test sets EVSurplusOnlyReserveW directly to the value // loadpoint.SurplusReserveW would produce; the helper itself is @@ -5542,12 +5552,12 @@ func TestEnergyDispatchAbsorbsSurplusBeyondEVReserveActualDraw(t *testing.T) { t.Fatalf("want 1 target, got %d", len(targets)) } got := targets[0].TargetW - // Want ≈ 3500 W. Allow ±400 W slack for time drift / formula edge. - if got < 3100 { - t.Errorf("TargetW = %.0f W — battery should absorb the surplus that the EV's adaptive reserve no longer hoards (want ≈ 3500 W, was 0 pre-fix)", got) + // Want ≈ 1000 W. Allow ±100 W slack for time drift / formula edge. + if got < 900 { + t.Errorf("TargetW = %.0f W — battery should absorb the surplus that the EV's adaptive reserve no longer hoards (want ≈ 1000 W, was 0 pre-fix)", got) } - if got > 3900 { - t.Errorf("TargetW = %.0f W — battery overshoot, must leave EVRampHeadroomW (2 kW) of headroom for EV ramp-up (want ≈ 3500 W)", got) + if got > 1100 { + t.Errorf("TargetW = %.0f W — battery overshoot, must leave the car's draw and EVRampHeadroomW (2 kW) of headroom for EV ramp-up (want ≈ 1000 W)", got) } } diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 19bc2aafe..da09c8f95 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -327,9 +327,10 @@ type State struct { SiteFuseVoltage float64 SiteFusePhases int - // SiteFuseSafetyA is amps held back from the breaker. Without it the - // inverter's own limiter trips first, output falls to 0, and dispatch - // ramps from idle. Zero disables the margin. + // SiteFuseSafetyA is an opt-in margin in amps per phase below the + // breaker; zero, the default, works to the rating. Set it for an + // inverter whose own limiter trips at the rating: output falls to 0 + // and dispatch ramps from idle. SiteFuseSafetyA float64 // Peak limit — enforced only in PeakShaving mode @@ -372,7 +373,8 @@ type State struct { EVCurtailHeadroomW float64 // BatteryCoversEV lets the battery discharge into the EV. Off, EV // load is taken out of the meter before the PI so the battery does - // not feed the car. + // not feed the car. Either way the car comes before battery charge: + // the battery charges only from the surplus the car leaves. BatteryCoversEV bool // BatteryBoostEVChargingW is the live charging draw belonging to @@ -1106,11 +1108,15 @@ func (s *State) SetPeakLimit(w float64) error { return fmt.Errorf("peak_limit_w must be ≥ 0, got %.0f W", w) } if ceiling := s.peakLimitCeilingW(); ceiling > 0 && w > ceiling { + margin := "" + if s.SiteFuseSafetyA > 0 { + margin = fmt.Sprintf(", less a %.1f A safety margin", s.SiteFuseSafetyA) + } return fmt.Errorf( "peak_limit_w %.0f W is above the site's import ceiling %.0f W "+ - "(fuse %.0f A × %.0f V × %d phases, less a %.1f A safety margin) "+ + "(fuse %.0f A × %.0f V × %d phases%s) "+ "— a peak limit above the fuse can never bind", - w, ceiling, s.SiteFuseAmps, s.SiteFuseVoltage, s.SiteFusePhases, s.SiteFuseSafetyA) + w, ceiling, s.SiteFuseAmps, s.SiteFuseVoltage, s.SiteFusePhases, margin) } s.PeakLimitW = w return nil @@ -1497,12 +1503,16 @@ func ComputeDispatch( // discharge keeps its old opt-in guard so Self does not fill the home // battery from a car by default. gridW := rawGridW - if state.Mode != ModeSelfConsumption && !state.BatteryCoversEV { - gridW -= state.uncoveredEVChargingW() - } if !state.BatteryCoversEV { gridW -= vehicleFlow.V2XDischargeW } + // The car comes before battery charge: charge comes only from what the + // meter shows left after the car. The car leaves the signal below so + // the battery never discharges for it. + chargeGridW := gridW + if state.Mode != ModeSelfConsumption && !state.BatteryCoversEV { + gridW -= state.uncoveredEVChargingW() + } // ---- Gather online batteries ---- batteries := make([]batteryInfo, 0, len(driverCapacities)) @@ -1576,6 +1586,14 @@ func ComputeDispatch( for _, b := range onlineBats { currentTotal += b.currentW } + // Without the car the meter may show export the car is using. Where + // the battery would charge, it sees the meter with the car instead: + // it charges only from the surplus after the car, and never turns to + // discharge for it. + noCarGridW := gridW + if gridW < chargeGridW { + gridW = math.Max(gridW, math.Min(chargeGridW, currentTotal)) + } surplus := newSurplusAccounting(rawGridW, gridW, currentTotal, state) // passive_arbitrage idle slot + live PV surplus: don't absorb. Forecasts @@ -1758,15 +1776,19 @@ func ComputeDispatch( // Leave PV for a surplus-only EV. A planned import of at least // 100 W keeps its charge. Discharge is left alone. - if state.EVSurplusOnlyReserveW > 0 && targetTotalW > 0 { - deliberateGridCharge := currentDirective.HasPlannedGridW && currentDirective.PlannedGridW >= 100 - if !deliberateGridCharge { - ceiling := surplus.chargeCeilingAfterEVReserveW() - if targetTotalW > ceiling { - targetTotalW = ceiling - } + deliberateGridCharge := currentDirective.HasPlannedGridW && currentDirective.PlannedGridW >= 100 + if state.EVSurplusOnlyReserveW > 0 && targetTotalW > 0 && !deliberateGridCharge { + ceiling := surplus.chargeCeilingAfterEVReserveW() + if targetTotalW > ceiling { + targetTotalW = ceiling } } + // The car before battery charge: a charge the plan makes from PV + // takes only the surplus the car leaves. + pvCharge := currentDirective.HasPlannedGridW && !deliberateGridCharge + if targetTotalW > 0 && pvCharge && gridW > noCarGridW { + targetTotalW = math.Min(targetTotalW, currentTotal-gridW) + } // Charge slots only. If PV drops, the energy budget would hold the // planned charge on grid import until the replan, about 10 minutes @@ -1774,7 +1796,11 @@ func ComputeDispatch( // Below 100 W is meter noise. if currentDirective.HasPlannedGridW && targetTotalW > 0 { const planGridDeadband = 100.0 - projectedGridW := gridW + (targetTotalW - currentTotal) + // As before the car came first, the projection leaves out the + // car the battery does not cover, while the plan's grid counts + // the car it planned. The car's draw never starts this + // back-off. + projectedGridW := noCarGridW + (targetTotalW - currentTotal) gridErr := projectedGridW - currentDirective.PlannedGridW if gridErr > planGridDeadband { adjusted := targetTotalW - gridErr @@ -1868,6 +1894,12 @@ func ComputeDispatch( errW = gridW - state.PeakLimitW } else if gridW < 0 { errW = gridW // exporting → charge with surplus + } else if chargeGridW > 0 && currentTotal > 0 && state.EVChargingW > evActiveThresholdW { + // The car before battery charge: the site imports while + // the car and the battery both charge. Take the charge + // down by the import, to 0 at most; under the peak limit + // the battery does not discharge. + errW = math.Min(chargeGridW, currentTotal) } else { errW = 0 } @@ -1998,6 +2030,11 @@ func ComputeDispatch( "mode", string(effectiveMode)) } totalCorrection = allowed - currentTotal + // The house alone has surplus, so a discharge would feed the + // car. Stop it now rather than over several PI steps. + if gridW > noCarGridW && currentTotal+totalCorrection < 0 { + totalCorrection = -currentTotal + } // Surplus-only EV reserve cap: stack on top of the meter clamp. // Mirror the energy-path cap so the battery target on the diff --git a/go/internal/control/ev_base_after_restart_test.go b/go/internal/control/ev_base_after_restart_test.go new file mode 100644 index 000000000..830eb906d --- /dev/null +++ b/go/internal/control/ev_base_after_restart_test.go @@ -0,0 +1,552 @@ +package control + +import ( + "encoding/json" + "maps" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/sitesim" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// restartFixture is seed 207532 just after Core restarted: a 16 A site with +// no margin, a battery FTW found discharging 5 kW, and a charger whose first +// logged offers of this run are 0 A, sent while the meter was stale, then +// 6,210 W (9 A on three phases) 2 s ago. The charger takes 40 s to apply an +// order, so the car still draws what it drew before the restart. Its +// max_charge_w is 11,040 W (16 A). evW is what the charger measures, +// arriving evMeterDelay before the site reading; the site meter reads +// gridW with the phase currents phaseA, and the PV inverter pvW. +func restartFixture(t *testing.T, gridW float64, phaseA [3]float64, batteryW, pvW, evW float64) (*telemetry.Store, *State) { + t.Helper() + store := telemetry.NewStore() + raw, _ := json.Marshal(map[string]any{"l1_a": phaseA[0], "l2_a": phaseA[1], "l3_a": phaseA[2]}) + store.Update("meter", telemetry.DerMeter, gridW, nil, raw) + store.RecordDriverSuccess("meter") + store.Update("sungrow", telemetry.DerBattery, batteryW, ptrF64(.3), nil) + store.RecordDriverSuccess("sungrow") + if pvW != 0 { + store.Update("pv", telemetry.DerPV, pvW, nil, nil) + store.RecordDriverSuccess("pv") + } + setChargerSample(store, evW, nil) + + state := NewState(0, 0, "meter") + now := time.Now() + state.clock = func() time.Time { return now } + state.SiteFuseAmps, state.SiteFuseVoltage, state.SiteFusePhases, state.SiteFuseSafetyA = 16, 230, 3, 0 + state.DriverLimits = map[string]PowerLimits{"sungrow": {MaxChargeW: 5000, MaxDischargeW: 5000}} + state.batteryCommands = map[string]commandLog{"sungrow": commandLog{}.add(now.Add(-2*time.Second), batteryW)} + state.evFuseDemands = []evFuseDemand{{"garage", 6210, "3p"}} + state.EVCommandedW = 6210 + found := sentCommand{at: now.Add(-6 * time.Second), w: 0, a: 0, n: 3} + state.evLimit.set = true + state.evLimit.offers = map[string]chargerOffers{"garage": { + log: commandLog{found, {at: now.Add(-4 * time.Second), w: 0, a: 0, n: 3}, {at: now.Add(-2 * time.Second), w: 6210, a: 9, n: 3}}, + settle: evUnknownSettle, found: found, driver: "charger", maxW: 11040, + watchFrom: found.at, // Core saw the sample arrive + }} + return store, state +} + +// C10 (27 Oct triage, seed 207532): after a restart, the 0 A offers sent +// while the meter was stale counted as the EV base the meter shows, while +// the meter showed the car at 8.2 kW. The new 6.2 kW order then counted as +// a 9 A rise to come, and the battery was held in discharge for the car +// with battery_covers_ev off, down toward its floor (-3,599 W here). The +// car's measured draw is in the reading: the 6.2 kW order is a cut. Until +// it lands the car may still rise to the 16 A of an offer Core does not +// know, so the battery keeps only the discharge that rise needs on L2. +func TestEVBaseAfterRestartCreditsMeasuredDraw(t *testing.T) { + store, state := restartFixture(t, 780, [3]float64{2.03, 4.97, .47}, -5000, -3000, 8222) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) + riseA := (11040 - 8222) / 690.0 + want := -(5000 - (16-4.97-riseA)*690) + if got := targetW(out, "sungrow"); math.Abs(got-want) > 1 { + t.Fatalf("battery %.0f W, want %.0f W: only the car's rise to its 16 A max needs room", got, want) + } +} + +// Review of #1926: a car below the offer in force. The car draws 8.2 kW, +// fresh and inside what the meter shows, but the charger's offer from +// before the restart, or its own default, is 16 A. Until Core's 6.2 kW +// order lands the car may rise to 11 kW (a ramp, a heater). The battery +// keeps the discharge that rise needs. +func TestEVBaseAfterRestartCarBelowOfferInForce(t *testing.T) { + houseA := 3.0 + evW, batW := 8222.0, -5000.0 + gridW := houseA*690 + evW + batW + phaseA := gridW / 690 + store, state := restartFixture(t, gridW, [3]float64{phaseA, phaseA, phaseA}, batW, 0, evW) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) + got := targetW(out, "sungrow") + if afterA := phaseA + (got-batW)/690 + (11040-evW)/690; afterA > 16+1e-6 { + t.Fatalf("battery ends discharge to %.0f W; a car rising to its offer in force puts %.2f A on each phase", got, afterA) + } +} + +// The credit stays on the safe side. A charger reading the meter cannot +// contain counts only up to the consumption the meter shows: here the +// charger claims 8.2 kW, but the site consumes 680 W, so 5,530 W of the +// 6,210 W order is still a rise to come, and the battery keeps the +// discharge that the busiest phase has no room to end. +func TestEVBaseAfterRestartCreditStaysInsideTheMeter(t *testing.T) { + store, state := restartFixture(t, 680, [3]float64{4.97, -1, -1}, 0, 0, 8222) + state.batteryCommands["sungrow"] = commandLog{}.add(state.now().Add(-time.Second), -5000) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) + riseA := (6210 - 680) / 690.0 + want := -(5000 - (16-4.97-riseA)*690) + if got := targetW(out, "sungrow"); math.Abs(got-want) > 1 { + t.Fatalf("battery %.0f W, want %.0f W: the charger's reading counts only up to the meter's 680 W", got, want) + } +} + +// chargerData is the fixture charger's data: its sample stamped on the +// charger's clock 5 s before the site reading arrived, with extra fields. +func chargerData(store *telemetry.Store, extra map[string]any) json.RawMessage { + at := store.Get("meter", telemetry.DerMeter).UpdatedAt.Add(-5 * time.Second) + d := map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano)} + maps.Copy(d, extra) + raw, _ := json.Marshal(d) + return raw +} + +// setChargerSample makes w the fixture charger's reading (chargerData), +// arriving evMeterDelay before the site reading. +func setChargerSample(store *telemetry.Store, w float64, extra map[string]any) { + store.Update("charger", telemetry.DerEV, w, nil, chargerData(store, extra)) + store.RecordDriverSuccess("charger") + store.Get("charger", telemetry.DerEV).UpdatedAt = store.Get("meter", telemetry.DerMeter).UpdatedAt.Add(-evMeterDelay) +} + +// setMaxW sets the fixture charger's max_charge_w. +func setMaxW(state *State, w float64) { + c := state.evLimit.offers["garage"] + c.maxW = w + state.evLimit.offers["garage"] = c +} + +// setRiseCapW puts both max_charge_w and the site fuse at w, the most an +// offer in force may be, so a case can test the other bounds alone. +func setRiseCapW(state *State, w float64) { + setMaxW(state, w) + state.SiteFuseAmps = w / (state.SiteFuseVoltage * float64(state.SiteFusePhases)) +} + +// The credit never exceeds the latest offer, keeps room for the car's rise +// to its max, takes the lowest of the sample, raw and smoothed draw and of +// every sample since the site reading, needs a live charger reading that +// arrived evMeterDelay before the site reading, and ends once the +// charger's first offer of this run has had its settle time: then the +// offers alone say what the meter shows. +func TestEVFoundDrawWBounds(t *testing.T) { + for _, tc := range []struct { + name string + edit func(*telemetry.Store, *State) + want float64 + }{ + {"the car may still rise to its max", func(*telemetry.Store, *State) {}, 8222 - (11040 - 6210)}, + {"no max_charge_w", func(_ *telemetry.Store, state *State) { setMaxW(state, 0) }, 0}, + {"max_charge_w below the fuse: the offer in force may still be the fuse's", func(_ *telemetry.Store, state *State) { + setMaxW(state, 7360) + }, 8222 - (11040 - 6210)}, + {"a charger sample measured after the site reading", func(store *telemetry.Store, state *State) { + store.Get("meter", telemetry.DerMeter).UpdatedAt = state.now().Add(-time.Second) + store.Get("charger", telemetry.DerEV).UpdatedAt = state.now() + store.Get("charger", telemetry.DerEV).Data = chargerData(store, map[string]any{"control_power_observed_at": state.now().Format(time.RFC3339Nano)}) + }, 0}, + // Final review of #1926: the charger's stamp does not say when the + // sample was measured on Core's clock; its arrival bounds that. + {"a sample that arrived with the site reading, however early its stamp", func(store *telemetry.Store, _ *State) { + store.Get("charger", telemetry.DerEV).UpdatedAt = store.Get("meter", telemetry.DerMeter).UpdatedAt + }, 0}, + {"only a sample older than one control interval before the reading", func(store *telemetry.Store, state *State) { + // The car may have stopped and started again since that sample; + // the current one, 1 s before the reading, may be ahead of it. + risenSince(store, state, 8222, 11040) + c := state.evLimit.offers["garage"] + c.draws[0].at = c.draws[0].at.Add(-10 * time.Second) + c.draws[0].got = c.draws[0].got.Add(-10 * time.Second) + c.draws[0].seen = c.draws[0].got + state.evLimit.offers["garage"] = c + }, 0}, + {"a sample since the site reading reads less", func(store *telemetry.Store, state *State) { + risenSince(store, state, 8222, 6000) + }, 6000 - (11040 - 6210)}, + // Review of #1926: OCPP stamps MeterValues with the charger's own + // clock. One running 1 s behind Core's stamps a rise sampled after + // the site reading as 1 s before it. The draw stays the one sampled + // before the reading. + {"a rise stamped before the reading by a charger clock behind Core's", func(store *telemetry.Store, state *State) { + risenSince(store, state, 6900, 11040) + }, 6900 - (11040 - 6210)}, + {"latest offer bounds the draw", func(_ *telemetry.Store, state *State) { setRiseCapW(state, 6210) }, 6210}, + {"measured draw below the offer", func(store *telemetry.Store, state *State) { + setRiseCapW(state, 6210) + setChargerSample(store, 4000, nil) + }, 4000}, + {"smoothed draw below the sample", func(store *telemetry.Store, state *State) { + setRiseCapW(state, 6210) + store.Get("charger", telemetry.DerEV).SmoothedW = 5000 + }, 5000}, + {"raw draw below the sample", func(store *telemetry.Store, state *State) { + setRiseCapW(state, 6210) + r := store.Get("charger", telemetry.DerEV) + r.Data = chargerData(store, map[string]any{"control_power_w": 8222}) + r.RawW = 5000 + }, 5000}, + {"control sample below raw and smoothed", func(store *telemetry.Store, state *State) { + setRiseCapW(state, 6210) + store.Get("charger", telemetry.DerEV).Data = chargerData(store, map[string]any{"control_power_w": 5000}) + }, 5000}, + {"charger offline", func(store *telemetry.Store, _ *State) { + store.DriverHealthMut("charger").SetOffline() + }, 0}, + {"no restart", func(_ *telemetry.Store, state *State) { + c := state.evLimit.offers["garage"] + c.found = sentCommand{} + state.evLimit.offers["garage"] = c + }, 0}, + {"first offer settled", func(_ *telemetry.Store, state *State) { + c := state.evLimit.offers["garage"] + c.found.at = state.now().Add(-c.settle) + state.evLimit.offers["garage"] = c + }, 0}, + {"a 40 s charger's window outlasts evSettle", func(_ *telemetry.Store, state *State) { + c := state.evLimit.offers["garage"] + c.settle, c.declared = 40*time.Second, true + c.found.at = state.now().Add(-30 * time.Second) + c.log[0].at = c.found.at + state.evLimit.offers["garage"] = c + }, 8222 - (11040 - 6210)}, + {"the shown part of the latest offer is not counted twice", func(_ *telemetry.Store, state *State) { + c := state.evLimit.offers["garage"] + c.found.at = state.now().Add(-60 * time.Second) + c.log = commandLog{c.found, {at: state.now().Add(-25 * time.Second), w: 6210, a: 9, n: 3}} + state.evLimit.offers["garage"] = c + }, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + store, state := restartFixture(t, 780, [3]float64{2.03, 4.97, .47}, -5000, -3000, 8222) + tc.edit(store, state) + shownW := state.evShownW(state.now()) + meter := store.Get("meter", telemetry.DerMeter) + if got := state.evFoundDrawW(store, meter.RawW, meter.UpdatedAt, state.now(), shownW); math.Abs(got-tc.want) > 1e-6 { + t.Fatalf("credit %.0f W beside %.0f W shown, want %.0f W", got, shownW, tc.want) + } + }) + } +} + +// risenSince keeps the fixture charger's sample of fromW, arrived +// evMeterDelay before the site reading, as noteDraw does, and makes its +// current reading toW, stamped 1 s before the site reading but arriving +// with it. +func risenSince(store *telemetry.Store, state *State, fromW, toW float64) { + setChargerSample(store, fromW, nil) + c := state.evLimit.offers["garage"] + c.noteDraw(store, state.now()) + state.evLimit.offers["garage"] = c + meterAt := store.Get("meter", telemetry.DerMeter).UpdatedAt + setChargerSample(store, toW, map[string]any{"control_power_observed_at": meterAt.Add(-time.Second).Format(time.RFC3339Nano)}) + store.Get("charger", telemetry.DerEV).UpdatedAt = meterAt + // The filter's smoothed value trails the rise; this case reads the sample. + store.Get("charger", telemetry.DerEV).SmoothedW = toW +} + +// Offers the meter already shows come off the consumption the credit may +// fill: here another charger's 5 kW is shown, so of the 8,780 W the meter +// shows as consumption only 3,780 W is left for this car. +func TestEVFoundDrawWLeavesOutShownOffers(t *testing.T) { + store, state := restartFixture(t, 780, [3]float64{2.03, 4.97, .47}, -5000, -3000, 8222) + setRiseCapW(state, 6210) + meter := store.Get("meter", telemetry.DerMeter) + if got := state.evFoundDrawW(store, meter.RawW, meter.UpdatedAt, state.now(), 5000); math.Abs(got-3780) > 1e-6 { + t.Fatalf("credit %.0f W, want 3780 W", got) + } +} + +// A charger reading the meter may not show gives no credit. A fast charger +// applied the 0 A sent while the meter was silent, so the car draws +// nothing, but its reading still shows the 8.2 kW from before: held after a +// failed poll, or a sample older than the site reading (an OCPP +// MeterValues interval). The house draws 7 kW on balanced phases, the +// battery runs the -5 kW discharge Core ordered in this run, and the meter +// shows 2.9 A per phase. The 9 A order still to come leaves room to end +// only (16 - 2.9 - 9) × 690 W of the discharge. +func TestEVBaseAfterRestartNeedsACurrentChargerSample(t *testing.T) { + for _, tc := range []struct { + name string + edit func(*telemetry.Store, *State) + }{ + {"last poll failed", func(store *telemetry.Store, _ *State) { + store.RecordDriverError("charger", "timeout") + }}, + {"sample older than the site reading", func(store *telemetry.Store, state *State) { + data, _ := json.Marshal(map[string]any{"power_observed_at": state.now().Add(-10 * time.Second).Format(time.RFC3339Nano)}) + store.Update("charger", telemetry.DerEV, 8222, nil, data) + store.Get("charger", telemetry.DerEV).UpdatedAt = state.now() + store.RecordDriverSuccess("charger") + }}, + {"site reading older than the sample", func(store *telemetry.Store, state *State) { + store.Get("meter", telemetry.DerMeter).UpdatedAt = state.now().Add(-10 * time.Second) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + store, state := restartFixture(t, 2000, [3]float64{2.9, 2.9, 2.9}, -5000, 0, 8222) + tc.edit(store, state) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) + got := targetW(out, "sungrow") + if afterA := 2.9 + (got+5000)/690 + 9; afterA > 16+1e-6 { + t.Fatalf("battery ends its discharge to %.0f W on a charger reading the meter may not show: %.2f A per phase once the car's order lands", got, afterA) + } + }) + } +} + +// The consumption bound reads the devices as the meter does. A battery +// that just ended a discharge, or PV that just dropped, still shows its old +// output in the smoothed value; counting that would raise the consumption +// the meter shows, and the credit with it, by the lag. +func TestEVBaseAfterRestartConsumptionReadsCurrentOutput(t *testing.T) { + for _, tc := range []struct { + name string + pvW float64 + driver string + kind telemetry.DerType + consumptionW float64 + }{ + {"battery ended its discharge", 0, "sungrow", telemetry.DerBattery, 680}, + {"PV dropped", -500, "pv", telemetry.DerPV, 1180}, + } { + t.Run(tc.name, func(t *testing.T) { + store, state := restartFixture(t, 680, [3]float64{4.97, -1, -1}, 0, tc.pvW, 8222) + store.Get(tc.driver, tc.kind).SmoothedW = -3000 + state.batteryCommands["sungrow"] = commandLog{}.add(state.now().Add(-time.Second), -5000) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) + riseA := (6210 - tc.consumptionW) / 690 + want := -(5000 - (16-4.97-riseA)*690) + if got := targetW(out, "sungrow"); math.Abs(got-want) > 1 { + t.Fatalf("battery %.0f W, want %.0f W: the meter shows %.0f W of consumption", got, want, tc.consumptionW) + } + }) + } +} + +// The consumption bound subtracts each output at the higher of raw and +// smoothed. A battery whose reading already shows a new 3 kW discharge, +// smoothed still at 1 kW, may be ahead of the site reading; so may PV. +func TestSiteConsumptionWTakesTheSmallerOutput(t *testing.T) { + store, _ := restartFixture(t, 680, [3]float64{4.97, -1, -1}, -3000, -2000, 8222) + store.Get("sungrow", telemetry.DerBattery).SmoothedW = -1000 + store.Get("pv", telemetry.DerPV).SmoothedW = -500 + if got := siteConsumptionW(store, 680); math.Abs(got-2180) > 1e-6 { + t.Fatalf("consumption %.0f W, want 2180 W: 680 W less -1,000 W battery and -500 W PV", got) + } +} + +// restartSim is seed 207532's shape on the plant: Core restarts beside a +// car drawing 12 A from before, on a charger that takes 40 s to apply an +// order. For its first 6 s the meter is silent and the inverter +// unreachable, holding the 5 kW discharge it had (C1), so Core logs 0 A +// offers. Then Core asks 9 A for the car. PV covers the house total, the +// plan is stale and battery_covers_ev is off. +func restartSim(house [3]float64) *simSite { + p := simPlant(simStart) + p.Fuse.RatingA, p.HouseW, p.PVW = 16, house, -6000 + p.EVSE.Delay, p.EVSE.ReadbackLag = 40*time.Second, 0 + p.EVSE.OfferA, p.EVSE.OfferPhases = 12, 3 + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 5000, MaxDischargeW: 5000, CapacityWh: 10000, SoC: .3, Latency: 2 * time.Second, W: -5000} + p.Events = []sitesim.Event{ + {Kind: sitesim.MeterSilent, For: 6 * time.Second}, + {Kind: sitesim.DriverOffline, Device: "battery", For: 6 * time.Second}, + } + s := &simSite{p: p, lp: simLoadpoint()} + s.setup = func(s *simSite) { + s.state.Mode = ModePlannerSelf + s.state.SiteFuseSafetyA = 0 + s.ctrl.SetManualHold(s.lp.ID, loadpoint.ManualHold{PowerW: 6210, PhaseMode: "3p", Persistent: true, StartedAt: s.p.Now, UpdatedAt: s.p.Now}) + } + return s +} + +// The seed on the plant: the battery ends its discharge as fast as its +// slew allows, down to what the car's rise to its 16 A max still needs on +// L2 (the 9 A of house load PV does not cover there), and no phase passes +// the fuse line on the way. +func TestSiteSimRestartEndsDischargeForACarAlreadyDrawing(t *testing.T) { + s := restartSim(houseA(4, 9, 2)) + s.boot() + s.run(14 * time.Second) + if s.offerW != 6210 || s.p.EVSE.DrawW != 8280 { + t.Fatalf("not the seed's state: offer %.0f W, car %.0f W", s.offerW, s.p.EVSE.DrawW) + } + if want := -(9*230 - 2000.0) * 3; s.batteryCmdW < want-1 { + t.Fatalf("battery held at %.0f W 8 s after the meter returned, with the car already in the reading; want %.0f W", s.batteryCmdW, want) + } + s.run(2 * time.Minute) + if s.failure != "" { + t.Fatal(s.failure) + } +} + +// The seed's charger is OCPP and samples every 10 s (MeterValues). Its +// sample counts only on the ticks just after it, so the battery holds its +// discharge between samples. It still ends it before the 9 A order counts +// as shown (evSettle after it went out, about 28 s in), and no phase +// passes the fuse line. A charger sampling every 30 s or more gets no +// credit in that time: the fix is partial for it. +func TestSiteSimRestartTenSecondSamples(t *testing.T) { + s := restartSim(houseA(4, 9, 2)) + s.evEvery = 10 * time.Second + s.boot() + s.run(20 * time.Second) + if s.offerW != 6210 || s.p.EVSE.DrawW != 8280 { + t.Fatalf("not the seed's state: offer %.0f W, car %.0f W", s.offerW, s.p.EVSE.DrawW) + } + if want := -(9*230 - 2000.0) * 3; s.batteryCmdW < want-1 { + t.Fatalf("battery held at %.0f W 14 s after the meter returned; want %.0f W", s.batteryCmdW, want) + } + s.run(2 * time.Minute) + if s.failure != "" { + t.Fatal(s.failure) + } +} + +// Review of #1926 on the plant: the charger's offer in force is 16 A, and +// the car limits itself to 12 A at the restart, then lifts that 14 s +// later, before the 40 s charger has applied Core's order. L2 carries 12 A +// of house. The battery keeps the discharge the car's rise needs: no phase +// reaches 1.2 x In, and time over In stays within the heat model. +func TestSiteSimRestartCarRisesUnderOfferInForce(t *testing.T) { + s := restartSim(houseA(4, 12, 2)) + carRisesUnderOldOffer(t, s, 8280, 14*time.Second) +} + +// carRisesUnderOldOffer runs s with the charger still on the 16 A offer it +// had before the restart, and a car that draws fromW until it lifts its own +// limit at riseAt. No phase may reach 1.2 x In, and the sim's fuse checks +// must pass. +func carRisesUnderOldOffer(t *testing.T, s *simSite, fromW float64, riseAt time.Duration) { + t.Helper() + s.p.EVSE.OfferA, s.p.Car.MaxW = 16, fromW + s.p.Events = append(s.p.Events, sitesim.Event{At: riseAt, Kind: sitesim.Change, Do: func(p *sitesim.Plant) { p.Car.MaxW = 11040 }}) + s.boot() + s.run(2 * time.Minute) + if s.failure != "" { + t.Fatal(s.failure) + } + if s.peakA >= fuseInstantFactor*16 { + t.Fatalf("a phase reached %.2f A", s.peakA) + } +} + +// Third review of #1926: a site meter that samples every 3 s. The car draws +// 10 A under the 16 A offer in force and lifts its own limit 11 s in. The +// charger reads the rise before the next meter sample, so its sample is +// newer than the site reading, which still shows the old draw. That sample +// does not count as in the reading, and the battery keeps the discharge +// the rise needs. Before: L2 reached 19.30 A. +func TestSiteSimRestartCarRisesBetweenMeterSamples(t *testing.T) { + s := restartSim(houseA(4, 12, 2)) + s.p.Meter.Every = 3 * time.Second + carRisesUnderOldOffer(t, s, 6900, 11*time.Second) +} + +// Third review of #1926: max_charge_w is not the most an offer in force +// may be. The owner lowered it to 7,360 W (10.67 A) and restarted; the +// charger still runs the 16 A Core sent before. The car draws 7 kW and may +// rise to 11,040 W before the 6.2 kW order lands. The rise reserve runs to +// the site fuse, so the battery keeps the discharge that rise needs. +func TestEVBaseAfterRestartMaxChargeLoweredAtRestart(t *testing.T) { + house := 3.0 + evW, batW := 7000.0, -5000.0 + gridW := house*690 + evW + batW + phaseA := gridW / 690 + store, state := restartFixture(t, gridW, [3]float64{phaseA, phaseA, phaseA}, batW, 0, evW) + setMaxW(state, 7360) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) + got := targetW(out, "sungrow") + if afterA := phaseA + (got-batW)/690 + (11040-evW)/690; afterA > 16+1e-6 { + t.Fatalf("battery ends discharge to %.0f W; the car rising to the old 16 A offer puts %.2f A on each phase", got, afterA) + } +} + +// The plant form: max_charge_w is now 8,280 W (12 A), and the car lifts +// its own 12 A limit 14 s in, before the 40 s charger applies Core's order. +// Before: L2 reached 19.30 A. +func TestSiteSimRestartMaxChargeLoweredAtRestart(t *testing.T) { + s := restartSim(houseA(4, 12, 2)) + s.lp.MaxChargeW, s.lp.AllowedStepsW = 8280, []float64{0} + for a := 6.0; a <= 12; a++ { + s.lp.AllowedStepsW = append(s.lp.AllowedStepsW, a*690) + } + carRisesUnderOldOffer(t, s, 8280, 14*time.Second) +} + +// Review of #1926: OCPP stamps MeterValues with the charger's own clock, +// often cut to the whole second. With the charger's clock 1-3 s behind +// Core's, the third review's case (3 s meter, the car at 10 A under a 16 A +// offer in force lifting its own limit 11 s in) stamps the rise before the +// site reading that does not show it yet. The draw stays the one sampled +// before that reading. Before: L2 reached 19.30 A. +func TestSiteSimRestartChargerClockBehind(t *testing.T) { + for _, lag := range []time.Duration{time.Second, 2 * time.Second, 3 * time.Second} { + t.Run(lag.String(), func(t *testing.T) { + s := restartSim(houseA(4, 12, 2)) + s.p.Meter.Every = 3 * time.Second + s.evClockLag = lag + carRisesUnderOldOffer(t, s, 6900, 11*time.Second) + }) + } +} + +// Final review of #1926: a sample from before the car stopped. A fast +// charger (2 s) applies the 0 A sent while the meter was silent at about +// 3 s, and the car resumes only at 11 s, after Core's 9 A order has +// landed. The charger samples every 10 s (OCPP MeterValues), at 2 s and +// 12 s. The readings from 6 s on show the car at 0 W. Core began to watch +// the charger at 7 s and does not know when the 8,280 W sample arrived, so +// it gives no credit, and the battery keeps the discharge the car's return +// needs. Judged by the charger's stamp against a 5 s allowance, that +// sample counted at 9 s, the battery ended its discharge, and L2 reached +// 21 A when the car resumed. With no credit at all, the peak is 18.10 A. +func TestSiteSimRestartOldSampleOfACarThatStopped(t *testing.T) { + s := restartSim(houseA(4, 12, 2)) + s.p.PVW = 0 + s.p.EVSE.Delay = 2 * time.Second + s.evEvery = 10 * time.Second + s.p.Events = append(s.p.Events, + sitesim.Event{At: 3 * time.Second, Kind: sitesim.Change, Do: func(p *sitesim.Plant) { p.Car.MaxW = 0 }}, + sitesim.Event{At: 11 * time.Second, Kind: sitesim.Change, Do: func(p *sitesim.Plant) { p.Car.MaxW = 11040 }}) + s.boot() + s.evAt = s.p.Now.Add(2*time.Second - s.evEvery) // samples at 2 s and 12 s + s.run(time.Minute) + if s.failure != "" { + t.Fatal(s.failure) + } + if s.peakA > 18.11 { + t.Fatalf("a phase reached %.2f A; with no credit it peaks at 18.10 A", s.peakA) + } +} + +// Review of #1926: a 22 kW charger (32 A) on a 3 x 25 A main fuse. The +// offer in force may be the charger's 32 A, so the rise reserve runs to +// max_charge_w, not to the fuse's rating: of the 14 kW the car draws, +// 22,080 - 11,000 W may still come on top of Core's latest 11 kW offer. +func TestEVFoundDrawWMaxChargeAboveFuse(t *testing.T) { + evW := 14000.0 + store, state := restartFixture(t, 9000, [3]float64{13, 13, 13}, -5000, 0, evW) + state.SiteFuseAmps = 25 + setMaxW(state, 22080) + c := state.evLimit.offers["garage"] + c.log[len(c.log)-1].w = 11000 + state.evLimit.offers["garage"] = c + meter := store.Get("meter", telemetry.DerMeter) + if got, want := state.evFoundDrawW(store, meter.RawW, meter.UpdatedAt, state.now(), 0), evW-(22080-11000); math.Abs(got-want) > 1e-6 { + t.Fatalf("credit %.0f W, want %.0f W", got, want) + } +} diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 0ea1d3201..7098a2c74 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -7,16 +7,17 @@ import ( ) // clampEVBatteryHeadroom keeps optional battery charge out of room an EV -// still needs. It reads only the site meter and our own commands; no charger -// reading takes part, and a battery's own reading only bounds how much of a -// discharge we ordered it may keep. The EV still needs its demand less the -// offers the meter surely shows (evShownW). +// still needs. It reads the site meter and our own commands. A battery's own +// reading only bounds how much of a discharge we ordered it may keep. A +// charger's reading counts only before any of its offers of this run has +// surely landed, and only up to what the meter shows (evFoundDrawW). The EV +// still needs its demand less the offers the meter surely shows (evShownW) +// and that draw. func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, state *State, fuseMaxW float64) []DispatchTarget { if store == nil || state == nil || len(targets) == 0 || fuseMaxW <= 0 { return targets } now := state.now() - offeredW := state.evShownW(now) var demandW float64 phases := 3.0 // No phase placement promise for 1p or auto chargers. for _, demand := range state.evFuseDemands { @@ -28,7 +29,6 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st if len(state.evFuseDemands) == 0 { return targets } - needW := math.Max(0, demandW-offeredW) meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) if meter == nil { return floorPositiveEVBatteryTargets(targets) @@ -37,6 +37,9 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st if !finiteFuseValue(gridW) || !finiteFuseValue(fuseMaxW) { return floorPositiveEVBatteryTargets(targets) } + offeredW := state.evShownW(now) + offeredW += state.evFoundDrawW(store, gridW, meter.UpdatedAt, now, offeredW) + needW := math.Max(0, demandW-offeredW) latest := make(map[string]float64, len(targets)) lowest := make(map[string]float64, len(targets)) targeted := make(map[string]bool, len(targets)) diff --git a/go/internal/control/ev_car_before_battery_test.go b/go/internal/control/ev_car_before_battery_test.go new file mode 100644 index 000000000..65e899c62 --- /dev/null +++ b/go/internal/control/ev_car_before_battery_test.go @@ -0,0 +1,266 @@ +package control + +import ( + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// runCarBeforeBattery drives planner_self in a charge slot against a plant +// whose meter is the site without the battery plus the battery's last +// target. siteW is house plus car minus PV, site-signed. It returns every +// target the battery received. +func runCarBeforeBattery(t *testing.T, coversEV bool, siteW, carW, startBatteryW float64) []float64 { + t.Helper() + now := time.Now() + dir := SlotDirective{ + SlotStart: now, + SlotEnd: now.Add(15 * time.Minute), + BatteryEnergyWh: 350, // a PV charge slot: 1.4 kW for 15 minutes + Strategy: "self_consumption", + } + store := seedStore(siteW+startBatteryW, []struct { + name string + currentW, soc float64 + }{ + {"ferroamp", startBatteryW, 0.5}, + }) + st := NewState(0, 50, "ferroamp") + st.Mode = ModePlannerSelf + st.UseEnergyDispatch = true + st.SlotDirective = func(time.Time) (SlotDirective, bool) { return dir, true } + st.EVChargingW = carW + st.BatteryCoversEV = coversEV + st.SlewRateW = 100000 + st.MinDispatchIntervalS = 0 + const fuseW = 3 * 25 * 230 // keep the fuse out of this policy check + batteryW := startBatteryW + var got []float64 + for i := 0; i < 20; i++ { + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), fuseW) + if len(targets) == 1 { + batteryW = targets[0].TargetW + got = append(got, batteryW) + } + store.Update("ferroamp", telemetry.DerBattery, batteryW, ptrF64(0.5), nil) + store.Update("ferroamp", telemetry.DerMeter, siteW+batteryW, nil, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() + } + return got +} + +func lastOr(v []float64, def float64) float64 { + if len(v) == 0 { + return def + } + return v[len(v)-1] +} + +// Seeds 207535, 207539 and 207530: PV 5 kW, house 3.6 kW, car 11 kW. The +// site imports about 10 kW, yet with battery_covers_ev off the battery +// charged from the PV the car was using. The car comes first: the battery +// charges only from what is left after the car, and is never discharged +// for the car. +func TestCarBeforeBatteryChargeWhenCoverIsOff(t *testing.T) { + const pvW, houseW, carW = 5000.0, 3611.0, 11040.0 + got := runCarBeforeBattery(t, false, houseW+carW-pvW, carW, 632) + for i, w := range got { + if w > 632+1 { + t.Fatalf("tick %d: battery charge rose to %.0f W while the site imports for the car", i, w) + } + if w < -1 { + t.Fatalf("tick %d: battery discharged %.0f W into the car with battery_covers_ev off", i, w) + } + } + if final := lastOr(got, 632); math.Abs(final) > 1 { + t.Errorf("battery settled at %.0f W, want 0 W: the car uses all PV", final) + } +} + +// With PV beyond house and car, the battery takes exactly the surplus the +// car leaves. +func TestCarBeforeBatteryChargesOnlyTheSurplusAfterTheCar(t *testing.T) { + for _, coversEV := range []bool{false, true} { + const pvW, houseW, carW = 16000.0, 3000.0, 11040.0 + got := runCarBeforeBattery(t, coversEV, houseW+carW-pvW, carW, 0) + const wantW = pvW - houseW - carW + for i, w := range got { + if w > wantW+60 { + t.Fatalf("covers_ev=%v tick %d: battery charge %.0f W takes PV the car uses (surplus after car %.0f W)", coversEV, i, w, wantW) + } + } + if final := lastOr(got, 0); math.Abs(final-wantW) > 60 { + t.Errorf("covers_ev=%v: battery settled at %.0f W, want about %.0f W", coversEV, final, wantW) + } + } +} + +// battery_covers_ev off still covers the house, never the car. +func TestCarBeforeBatteryCoverOffDischargesForHouseOnly(t *testing.T) { + const pvW, houseW, carW = 1000.0, 3000.0, 11040.0 + got := runCarBeforeBattery(t, false, houseW+carW-pvW, carW, 0) + const wantW = pvW - houseW // -2000 W: the house, not the car + for i, w := range got { + if w < wantW-60 { + t.Fatalf("tick %d: battery discharged %.0f W, past the house's %.0f W", i, w, wantW) + } + } + if final := lastOr(got, 0); math.Abs(final-wantW) > 60 { + t.Errorf("battery settled at %.0f W, want about %.0f W", final, wantW) + } +} + +// battery_covers_ev on lets the battery feed the car instead of charging. +func TestCarBeforeBatteryCoverOnDischargesForTheCar(t *testing.T) { + const pvW, houseW, carW = 5000.0, 3611.0, 11040.0 + got := runCarBeforeBattery(t, true, houseW+carW-pvW, carW, 632) + for i, w := range got { + if w > 632+1 { + t.Fatalf("tick %d: battery charge rose to %.0f W while the site imports", i, w) + } + } + if final := lastOr(got, 632); final > -1000 { + t.Errorf("battery settled at %.0f W, want a discharge that covers the car", final) + } +} + +// The energy path follows the same rule: a planned PV charge takes only +// the surplus the car leaves, while a planned purchase keeps its charge. +func TestCarBeforeBatteryEnergyPathPVChargeOnly(t *testing.T) { + const pvW, houseW, carW = 8000.0, 500.0, 6000.0 + for _, tc := range []struct { + name string + plannedW float64 + wantW float64 + }{ + {"pv charge", -1000, pvW - houseW - carW}, + {"planned purchase", 2000, 4000}, + } { + for _, coversEV := range []bool{false, true} { + now := time.Now() + dir := SlotDirective{ + SlotStart: now, + SlotEnd: now.Add(15 * time.Minute), + BatteryEnergyWh: 1000, // 4 kW for 15 minutes + Strategy: "cheap", + PlannedGridW: tc.plannedW, + HasPlannedGridW: true, + } + store := seedStore(houseW+carW-pvW, []struct { + name string + currentW, soc float64 + }{ + {"ferroamp", 0, 0.5}, + }) + st := NewState(0, 50, "ferroamp") + st.Mode = ModePlannerCheap + st.UseEnergyDispatch = true + st.SlotDirective = func(time.Time) (SlotDirective, bool) { return dir, true } + st.EVChargingW = carW + st.BatteryCoversEV = coversEV + st.SlewRateW = 100000 + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 3*25*230) + if len(targets) != 1 { + t.Fatalf("%s covers_ev=%v: want 1 target, got %+v", tc.name, coversEV, targets) + } + got := targets[0].TargetW + if coversEV { + // The plan's grid check sees the car on the meter and + // backs the charge off further; only the car-first bound + // matters here. + if tc.plannedW < 100 && got > tc.wantW+1 { + t.Errorf("%s covers_ev=true: battery target %.0f W takes PV the car uses (want at most %.0f W)", tc.name, got, tc.wantW) + } + continue + } + if math.Abs(got-tc.wantW) > 1 { + t.Errorf("%s covers_ev=false: battery target %.0f W, want %.0f W", tc.name, got, tc.wantW) + } + } + } +} + +// runPeakShavingCar drives manual peak_shaving against the same plant as +// runCarBeforeBattery. The peak limit sits far above the site, so the +// import the car causes lands in peak_shaving's dead zone. +func runPeakShavingCar(t *testing.T, coversEV bool, siteW, carW, startBatteryW float64) []float64 { + t.Helper() + store := seedStore(siteW+startBatteryW, []struct { + name string + currentW, soc float64 + }{ + {"ferroamp", startBatteryW, 0.5}, + }) + st := NewState(0, 50, "ferroamp") + st.Mode = ModePeakShaving + st.PeakLimitW = 30000 + st.EVChargingW = carW + st.BatteryCoversEV = coversEV + st.SlewRateW = 100000 + st.MinDispatchIntervalS = 0 + const fuseW = 3 * 25 * 230 + batteryW := startBatteryW + var got []float64 + for i := 0; i < 20; i++ { + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), fuseW) + if len(targets) == 1 { + batteryW = targets[0].TargetW + } + got = append(got, batteryW) + store.Update("ferroamp", telemetry.DerBattery, batteryW, ptrF64(0.5), nil) + store.Update("ferroamp", telemetry.DerMeter, siteW+batteryW, nil, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() + } + return got +} + +// The 207535 shape in manual peak_shaving. The battery was charging 871 W +// from the house surplus when the car started. The site now imports for +// the car, inside peak_shaving's dead zone, so nothing used to cut the +// charge. The car comes first: the charge goes, and the battery never +// discharges, because the import is under the peak limit. +func TestCarBeforeBatteryPeakShavingDropsARunningCharge(t *testing.T) { + const pv, house, car = 5000.0, 3611.0, 11040.0 + for _, cover := range []bool{false, true} { + got := runPeakShavingCar(t, cover, house+car-pv, car, 871) + for i, w := range got { + if w < -1 { + t.Fatalf("cover=%v tick %d: battery discharges %.0f W under the peak limit: %v", cover, i, w, got) + } + } + if final := lastOr(got, 871); final > 1 { + t.Fatalf("cover=%v: battery still charges %.0f W while the site imports %.0f W for the car: %v", + cover, final, house+car-pv+final, got) + } + } +} + +// With more PV than house and car together, peak_shaving still charges +// from what the car leaves: 18000 − 3611 − 11040 = 3349 W. +func TestCarBeforeBatteryPeakShavingChargesTheSurplusAfterTheCar(t *testing.T) { + const pv, house, car = 18000.0, 3611.0, 11040.0 + want := pv - house - car + for _, cover := range []bool{false, true} { + got := runPeakShavingCar(t, cover, house+car-pv, car, 0) + if final := lastOr(got, 0); math.Abs(final-want) > 60 { + t.Fatalf("cover=%v: battery settled at %.0f W, want the %.0f W left after the car: %v", cover, final, want, got) + } + } +} + +// On the PI path with battery_covers_ev off, a discharge stops at once +// when the house alone has surplus: what it gives now goes to the car. The +// battery was discharging 2,900 W; 5 kW of PV beats the 3.6 kW house, and +// the car draws 11 kW. No order may discharge, not even as a PI step on +// the way to 0 W. +func TestCarBeforeBatteryCoverOffStopsADischargeForTheCarAtOnce(t *testing.T) { + const pv, house, car = 5000.0, 3611.0, 11040.0 + got := runPeakShavingCar(t, false, house+car-pv, car, -2900) + for i, w := range got { + if w < -1 { + t.Fatalf("tick %d: battery discharges %.0f W into the car with battery_covers_ev off: %v", i, w, got) + } + } +} diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 727719151..c782d3819 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -179,6 +179,17 @@ type chargerOffers struct { // charger as 0 before it; a cut takes it as shown, since the reading is // all Core knows of it. found sentCommand + // driver is the charger's driver, whose power reading only the battery + // side reads, and only before any offer of this run has surely landed + // (evFoundDrawW). maxW is the loadpoint's max_charge_w; zero when not + // configured. + driver string + maxW float64 + // draws are the charger's samples while that window is open (noteDraw), + // and watchFrom the receipt of the first reading noteDraw took there: + // a sample already there then may have arrived at any earlier time. + draws []evDraw + watchFrom time.Time // carPhases is how many phases the car draws on, as the loadpoint saw // it on the site meter: 1 or 3, or 0 when unknown. carPhase is the phase // a car on one phase draws on, 1-3. @@ -375,6 +386,184 @@ func (s *State) evShownW(at time.Time) float64 { return w } +// evFoundDrawW is EV draw the battery side may count as shown beyond +// evShownW (shownW) while a charger has no order of this run that has +// surely landed: from the first offer Core logs for it with no fuse limit +// set (at Core's start, or at a plug-in once no other managed charger was +// plugged), until that offer is older than the charger's settle time. Core +// then does not know which offer the charger runs on, and its first offers +// (often 0 A) say nothing of what the meter shows. +// +// A car seen drawing in that window is in the site reading when the +// charger sampled that draw before the reading, and no sample since reads +// less. The charger stamps its samples with its own clock (OCPP +// MeterValues), which may run behind Core's by any amount, so Core judges +// a sample by when it first arrived on Core's clock (noteDraw): a sample +// arrives after it was measured. It counts as before the reading only +// when Core saw it arrive evMeterDelay before the reading did (arrivedBy), +// and no more than one control interval before that: a fast charger may +// have applied a 0 A offer, or the car paused, since an older sample. The +// draw is the lowest of that sample and every sample since (noteDraw, each +// the lowest of the sample, raw and smoothed), up to the charger's current +// reading. So a rise sampled after the reading never counts, whatever the +// charger's clock. A held reading after a failed poll says nothing of now. +// A charger that samples less often (OCPP MeterValues every 10 s or more) counts +// only on the ticks from evMeterDelay to one control interval more after a +// sample arrives, and holds its battery's discharge between them. +// +// Until the latest offer lands, the car may still rise from that draw to +// the offer in force. Core does not know that offer: one from before a +// restart, under an older max_charge_w, or the charger's own default. The +// reserve takes it as the larger of the loadpoint's max_charge_w and the +// site fuse's rating, and keeps the rise past the latest offer; a charger +// with no max_charge_w counts nothing. Each charger counts no more than its +// latest offer, and in all no more than the consumption the site meter +// shows (siteConsumptionW) less shownW. The car's own limit never reads +// this; only how much of our latest offers the battery reserves as a rise +// to come. +// gridW is the site reading, which arrived at meterAt. +func (s *State) evFoundDrawW(store *telemetry.Store, gridW float64, meterAt, at time.Time, shownW float64) float64 { + if store == nil { + return 0 + } + consumptionW := siteConsumptionW(store, gridW) + if !finiteFuseValue(consumptionW) { + return 0 + } + skew := time.Duration(s.ControlIntervalS) * time.Second + if skew <= 0 { + skew = 2 * time.Second // config's default control_interval_s + } + edge := meterAt.Add(-evMeterDelay) + from := edge.Add(-skew) + var w float64 + for _, c := range s.evLimit.offers { + n := len(c.log) + if n == 0 || !c.foundOpen(at) || c.driver == "" || !(c.maxW > 0) || !finiteFuseValue(c.maxW) { + continue + } + now, ok := readEVDraw(store, c.driver, at) + if !ok { + continue + } + for _, d := range c.draws { + if d.at.Equal(now.at) { + d.merge(now) + now = d + } + } + if now.seen.Before(from) { + continue + } + drawW, before := now.w, c.arrivedBy(now, edge) + for _, d := range c.draws { + if d.seen.Before(from) { + continue + } + drawW, before = math.Min(drawW, d.w), before || c.arrivedBy(d, edge) + } + if !before { + continue + } + unshown, _ := c.log.unshown(at, c.batterySettle()) + latest := c.log[n-1].w + capW := math.Max(c.maxW, s.siteFuseMaxW()) + inLatestW := math.Min(latest, drawW-math.Max(0, capW-latest)) + w += math.Max(0, inLatestW-math.Max(0, latest-unshown)) + } + return math.Max(0, math.Min(w, consumptionW-shownW)) +} + +// evMeterDelay is how much earlier than the site reading a charger's +// sample must arrive to count as in that reading (evFoundDrawW): the site +// meter's own delay from measurement to arrival. +const evMeterDelay = time.Second + +// evDraw is one charger sample: its stamp on the charger's own clock, +// which only tells samples apart; when it first arrived on Core's clock; +// when the charger last vouched for it, on Core's clock (its arrival, or a +// later confirmed reading); and the lowest of the sample, raw and smoothed. +type evDraw struct { + at, got, seen time.Time + confirmed bool + w float64 +} + +// merge folds a repeat of the same sample into e: the lowest draw, the +// first arrival, and a later receipt only where the charger confirms it. +func (e *evDraw) merge(d evDraw) { + e.w = math.Min(e.w, d.w) + if d.got.Before(e.got) { + e.got = d.got + } + if d.confirmed && d.seen.After(e.seen) { + e.seen, e.confirmed = d.seen, true + } +} + +// readEVDraw is the charger's current sample, while its telemetry is live, +// its last poll succeeded and the sample passes qualifiedFuseObservation, +// taken as arriving with the charger's current reading. +func readEVDraw(store *telemetry.Store, driver string, at time.Time) (evDraw, bool) { + lowerW, live := store.OnlineLowerW(driver, telemetry.DerEV) + if !live { + return evDraw{}, false + } + r := store.Get(driver, telemetry.DerEV) + p, ok := qualifiedFuseObservation(r, at) + if !ok { + return evDraw{}, false + } + w := math.Min(p.PowerW, lowerW) + return evDraw{at: p.At, got: r.UpdatedAt, seen: r.UpdatedAt, confirmed: !p.ConfirmedAt.IsZero(), w: w}, finiteFuseValue(w) +} + +// arrivedBy reports whether Core saw sample d arrive by edge. A sample +// already there when Core began to watch the charger arrived at a time +// Core does not know, so it never counts as arrived. +func (c chargerOffers) arrivedBy(d evDraw, edge time.Time) bool { + return !c.watchFrom.IsZero() && d.got.After(c.watchFrom) && !d.got.After(edge) +} + +// foundOpen is true while the charger has no order of this run that has +// surely landed (evFoundDrawW). +func (c chargerOffers) foundOpen(at time.Time) bool { + return !c.found.at.IsZero() && at.Sub(c.found.at) < c.settle +} + +// noteDraw keeps the charger's samples while foundOpen, for evFoundDrawW, +// over telemetry.ControlWindowDuration, each with its first arrival. +func (c *chargerOffers) noteDraw(store *telemetry.Store, at time.Time) { + if !c.foundOpen(at) || c.driver == "" { + c.draws, c.watchFrom = nil, time.Time{} + return + } + d, ok := readEVDraw(store, c.driver, at) + if !ok { + return + } + if c.watchFrom.IsZero() { + c.watchFrom = d.got + } + if n := len(c.draws); n > 0 && !d.at.After(c.draws[n-1].at) { + if last := &c.draws[n-1]; d.at.Equal(last.at) { + last.merge(d) + } + return + } + c.draws = append(slices.DeleteFunc(c.draws, func(e evDraw) bool { + return at.Sub(e.seen) > telemetry.ControlWindowDuration + }), d) +} + +// siteConsumptionW is the consumption a site reading of gridW shows: grid +// less each live battery, PV and V2X reading, each at the higher of raw and +// smoothed. A smoothed value lags an output that just fell; the higher one +// keeps the estimate at or below what the meter shows. +func siteConsumptionW(store *telemetry.Store, gridW float64) float64 { + return gridW - store.SumOnlineHigherW(telemetry.DerBattery, telemetry.DerPV, telemetry.DerV2X) +} + // batterySettle is how long the battery side counts a raise of this // charger's as unshown: its declared response time, else evSettle // (evShownW). @@ -499,7 +688,8 @@ func (s *State) riseAheadA(c chargerOffers) (ahead [3]float64) { // recordEVOffers keeps each plugged charger's offers, with the current each // puts on the phases its command names, for the longer of evSettle and the // power_max_age_s its driver declares, or evUnknownSettle when it declares -// none. Our own commands only: a charger's power reading never counts. +// none. Our own commands only: a charger's power reading never counts. The +// samples kept for the battery side (noteDraw) take no part in the limit. func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) { now := s.now() read := now @@ -512,7 +702,7 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) continue } c := s.evLimit.offers[point.ID] - c.settle, c.declared, c.carPhases, c.carPhase = evUnknownSettle, false, point.CarPhases, point.CarPhase + c.settle, c.declared, c.carPhases, c.carPhase, c.driver, c.maxW = evUnknownSettle, false, point.CarPhases, point.CarPhase, point.DriverName, point.MaxChargeW if r := store.Get(point.DriverName, telemetry.DerEV); r != nil { if declared := telemetry.DeclaredPowerMaxAge(r.Data); declared > 0 { c.settle, c.declared = max(evSettle, declared), true @@ -529,6 +719,7 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) } c.log = c.log.keep(sent, read.Add(-c.settle)) } + c.noteDraw(store, now) offers[point.ID] = c } s.evLimit.offers = offers diff --git a/go/internal/control/ev_site_test.go b/go/internal/control/ev_site_test.go index 6a079b0d8..8458c3998 100644 --- a/go/internal/control/ev_site_test.go +++ b/go/internal/control/ev_site_test.go @@ -178,10 +178,12 @@ func TestEVSiteScheduledEVMayImportOnCheapNight(t *testing.T) { } } -func TestEVSiteSurplusOnlyDoesNotClaimPVSoakWhenTogetherTheyWouldImport(t *testing.T) { +func TestEVSiteSurplusOnlyTakesPVSoakWithoutImport(t *testing.T) { // Battery plan 4 kW of 7.5 kW leftover — soak, not Pixii buying. - // The old live reader treated any meter import as grid-charge and - // offered the full leftover, so a 3Φ snap held while soak+EV imported. + // The car comes before battery charge (owner decision P1, 27 Oct + // 2026), so the car may take the soak; the battery takes what the + // car leaves. Together they never import, and the battery never + // discharges for the car. start := evComboSiteStart() site := newSiteClock(t, evSiteConfig{ Start: start, @@ -191,14 +193,17 @@ func TestEVSiteSurplusOnlyDoesNotClaimPVSoakWhenTogetherTheyWouldImport(t *testi PVW: evComboPVW, }) site.run(12) - const soakHeadroom = 3500.0 // leftover 7500 − soak 4000 for _, rec := range site.ticks { if rec.N < 4 { continue } - if rec.EVW > soakHeadroom+loadpoint.SitePowerEpsW { - t.Fatalf("tick %d: surplus-only EV %.0f W claimed PV-soak (headroom %.0f); ticks=%s", - rec.N, rec.EVW, soakHeadroom, site.dumpTicks()) + if rec.GridW > loadpoint.SitePowerEpsW { + t.Fatalf("tick %d: surplus-only EV %.0f W and battery %.0f W import %.0f W; ticks=%s", + rec.N, rec.EVW, rec.BatW, rec.GridW, site.dumpTicks()) + } + if rec.BatW < -loadpoint.SitePowerEpsW { + t.Fatalf("tick %d: battery discharged %.0f W beside surplus-only EV %.0f W; ticks=%s", + rec.N, rec.BatW, rec.EVW, site.dumpTicks()) } } } diff --git a/go/internal/control/fuse_line_property_test.go b/go/internal/control/fuse_line_property_test.go index 1cc28fe92..a4aa32241 100644 --- a/go/internal/control/fuse_line_property_test.go +++ b/go/internal/control/fuse_line_property_test.go @@ -225,10 +225,11 @@ func testFuseLineIdleStart(t *testing.T, inA float64, phases int, marginA, lineA } } -// fitScenario runs a 3 × 16 A site, safety margin 0.5 A, with houseLoadA of -// house on each phase. At 3.5 A twelve steps of the car fit exactly under -// the 15.5 A line. The car asks for 16 A. -func fitScenario(houseLoadA float64, run time.Duration, noise func() float64) *simSite { +// fitScenario runs a 3 × 16 A site with the default safety margin and +// enough house on each phase to leave roomA under the line. With 12 A of +// room, twelve steps of the car fit exactly. The car asks for 16 A. +func fitScenario(roomA float64, run time.Duration, noise func() float64) *simSite { + houseLoadA := 16 - simFuseSafetyA - roomA sc := simScenario{name: "exact fit", run: run, plant: func(p *sitesim.Plant) { p.Fuse.RatingA = 16 @@ -240,12 +241,12 @@ func fitScenario(houseLoadA float64, run time.Duration, noise func() float64) *s // Closed loop: the car climbs to the line and stays there for 30 min. func TestSiteSimCarEndsAtTheLine(t *testing.T) { - s := fitScenario(3.5, 30*time.Minute, nil) + s := fitScenario(12, 30*time.Minute, nil) if s.failure != "" { t.Fatal(s.failure) } lineA := s.state.fuseLineA() - if want := (lineA - 3.5) * 690; s.p.EVSE.DrawW != want || s.peakA > lineA+1e-6 { + if want := 12.0 * 690; s.p.EVSE.DrawW != want || s.peakA > lineA+1e-6 { t.Fatalf("car %.0f W, peak phase %.3f A; want %.0f W with every phase at most %.1f A", s.p.EVSE.DrawW, s.peakA, want, lineA) } var last float64 @@ -275,16 +276,16 @@ func TestSiteSimCarEndsAtTheLine(t *testing.T) { // faster hunt fails. func TestSiteSimNoisyLineCyclesSlowly(t *testing.T) { for _, tc := range []struct { - houseA, sigma float64 - cutsPerHour float64 // the most allowed after the climb - dropSteps float64 // the furthest below the top + roomA, sigma float64 + cutsPerHour float64 // the most allowed after the climb + dropSteps float64 // the furthest below the top }{ - {3.25, .1, 0, 0}, {3.75, .2, 0, 0}, // part of a step left - {3.5, .03, 5, 1}, {3.5, .1, 5, 1}, {3.5, .2, 5, 2}, // exact fit + {12.25, .1, 0, 0}, {11.75, .2, 0, 0}, // part of a step left + {12, .03, 5, 1}, {12, .1, 5, 1}, {12, .2, 5, 2}, // exact fit } { - t.Run(fmt.Sprintf("house_%.2fA/noise_%.2fA", tc.houseA, tc.sigma), func(t *testing.T) { + t.Run(fmt.Sprintf("room_%.2fA/noise_%.2fA", tc.roomA, tc.sigma), func(t *testing.T) { rng := rand.New(rand.NewPCG(1, 0)) - s := fitScenario(tc.houseA, 2*time.Hour, func() float64 { return tc.sigma * rng.NormFloat64() }) + s := fitScenario(tc.roomA, 2*time.Hour, func() float64 { return tc.sigma * rng.NormFloat64() }) if s.failure != "" { t.Fatal(s.failure) } diff --git a/go/internal/control/peak_limit_test.go b/go/internal/control/peak_limit_test.go index d8a7be220..b1c7f7861 100644 --- a/go/internal/control/peak_limit_test.go +++ b/go/internal/control/peak_limit_test.go @@ -7,8 +7,8 @@ import ( ) // SetPeakLimit is the one place the peak-shaving threshold is validated -// against the site's fuse. A 16 A / 230 V / 3-phase site is 11040 W; the -// default 0.5 A safety margin costs 345 W, so the highest limit that can +// against the site's fuse. A 16 A / 230 V / 3-phase site is 11040 W; an +// opt-in 0.5 A safety margin costs 345 W, so the highest limit that can // still be the first thing to bind is 10695 W. func fusedState() *State { st := NewState(0, 50, "ferroamp") @@ -63,6 +63,23 @@ func TestSetPeakLimitRejectsValueInsideTheSafetyMargin(t *testing.T) { } } +// The message names the margin only when the site has one. With the +// default 0 the ceiling is the fuse itself. +func TestSetPeakLimitMessageNamesOnlyARealMargin(t *testing.T) { + st := fusedState() + if err := st.SetPeakLimit(20000); err == nil || !strings.Contains(err.Error(), "less a 0.5 A safety margin") { + t.Fatalf("with a 0.5 A margin the message must name it, got %v", err) + } + st.SiteFuseSafetyA = 0 + err := st.SetPeakLimit(20000) + if err == nil || strings.Contains(err.Error(), "margin") || !strings.Contains(err.Error(), "11040 W") { + t.Fatalf("with no margin the message must give the fuse as the ceiling and no margin, got %v", err) + } + if err := st.SetPeakLimit(11040); err != nil { + t.Fatalf("with no margin the fuse itself is the ceiling, got %v", err) + } +} + // Zero is a real threshold in peak shaving — "correct everything above // 0 W of import" — not the zero-means-disabled convention that // PeakImportCeilingW and MaxExportW use. Dispatch has always read it that diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 09e8c1a42..84316e2c0 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -21,7 +21,7 @@ import ( const simCoreEvery = 2 * time.Second // simFuseSafetyA is the site's fuse.safety_margin_a, the config default. -const simFuseSafetyA = .5 +const simFuseSafetyA = 0 // simSite runs Core's control tick against a sitesim.Plant in main.go's // order: site import, charger tick, then battery dispatch. Core's settings @@ -45,6 +45,14 @@ type simSite struct { // evMaxAge is the power_max_age_s the charger driver declares outside // the field matrix; zero declares none. evMaxAge time.Duration + // evEvery is how often the charger samples its draw, as OCPP + // MeterValues do: between samples each poll repeats the last sample + // with its own time. Zero samples on every poll. + evEvery time.Duration + // evClockLag is how far the charger's clock runs behind Core's: it + // stamps each sample that much early, as an OCPP charger stamps + // MeterValues with its own clock (or cuts the stamp to whole seconds). + evClockLag time.Duration // meterNoise is added to each phase current Core reads, not to the // plant's own; nil reads the plant exactly. meterNoise func() float64 @@ -240,8 +248,18 @@ func (s *simSite) step() { s.emit("battery", telemetry.DerBattery, b.W, &b.SoC, nil) } if e := tick.EV; e != nil { - s.ev, s.evAt = *e, now - s.emit(s.lp.DriverName, telemetry.DerEV, e.W, nil, s.evData()) + if s.evEvery <= 0 || s.evAt.IsZero() || now.Sub(s.evAt) >= s.evEvery { + s.ev, s.evAt = *e, now + } + data := s.evData() + if s.evEvery > 0 || s.evClockLag > 0 { + data = maps.Clone(data) + if data == nil { + data = map[string]any{} + } + data["control_power_observed_at"] = s.evAt.Add(-s.evClockLag).Format(time.RFC3339Nano) + } + s.emit(s.lp.DriverName, telemetry.DerEV, s.ev.W, nil, data) } if !now.Before(s.nextTick) { s.nextTick = now.Add(s.coreEvery()) @@ -609,6 +627,23 @@ func simDay(p *sitesim.Plant) { } func simScenarios() []simScenario { + // A charger whose own load balancer holds each phase to balancerA. + loadBalancer := func(balancerA float64) simScenario { + name := "charger's own load balancer" + if balancerA != 25 { + name = fmt.Sprintf("%s at %.1f A", name, balancerA) + } + return simScenario{name: name, run: 20 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW, p.EVSE.LoadBalancerA = houseA(12, 12, 12), balancerA + p.Events = []sitesim.Event{ + {At: 5 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(18, 12, 12) }}, + {At: 10 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(4, 4, 4) }}, + {At: 15 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(20, 20, 20) }}, + } + }} + } + const loadBalancerBug = "a cut comes off an offer the car does not draw, stepEVMeterLimit (ev_fuse_budget.go:769)" slowCharger := func(delay time.Duration) func(*sitesim.Plant) { return func(p *sitesim.Plant) { p.HouseW, p.EVSE.Delay = houseA(15, 15, 15), delay } } @@ -624,42 +659,40 @@ func simScenarios() []simScenario { c.AllowedStepsW = append(steps, c.AllowedStepsW[1:]...) } } + replay1761 := simScenario{name: "#1761 replay", run: 20 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW, p.Car.MaxW = houseA(1, 1, 1), 4200 + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 9400, MaxDischargeW: 9400, CapacityWh: 20000, SoC: .3, Latency: 3 * time.Second} + p.Events = []sitesim.Event{{At: 10 * time.Minute, Do: func(p *sitesim.Plant) { p.Car.MaxW = 11040 }}} + }, + lp: func(c *loadpoint.Config) { + c.MaxChargeW, c.AllowedStepsW = 10350, c.AllowedStepsW[:len(c.AllowedStepsW)-1] + }, + setup: planBatteryCharge(9400), + expect: []simExpect{{9*time.Minute + 50*time.Second, func(s *simSite) error { + p := s.p + offerW := p.EVSE.OfferA * p.Voltage * float64(p.EVSE.OfferPhases) + if offerW != 10350 || math.Abs(p.EVSE.DrawW-4200) > 50 || math.Abs(p.Battery.W-9400) > 200 { + return fmt.Errorf("not the #1761 state before the car takes its offer: offer %.0f W, car %.0f W, battery %.0f W", offerW, p.EVSE.DrawW, p.Battery.W) + } + return nil + }}}} return []simScenario{ {name: "export start", run: 5 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW, p.PVW = houseA(2, 2, 2), -22*690 }}, {name: "export start, unsigned phase currents", run: 5 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW, p.PVW, p.Meter.Unsigned = houseA(2, 2, 2), -22*690, true }}, - {name: "charger's own load balancer", known: "a cut comes off an offer the car does not draw, ev_fuse_budget.go:172", run: 20 * time.Minute, - plant: func(p *sitesim.Plant) { - p.HouseW, p.EVSE.LoadBalancerA = houseA(12, 12, 12), 25 - p.Events = []sitesim.Event{ - {At: 5 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(18, 12, 12) }}, - {At: 10 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(4, 4, 4) }}, - {At: 15 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(20, 20, 20) }}, - } - }}, + loadBalancer(25), + // The balancer sits at the rating, so at the default 0 A margin it + // never lets the car past Core's line. Above the line the bug shows. + withKnown(withSafetyMarginA(loadBalancer(25), .5), loadBalancerBug), + withKnown(loadBalancer(25.5), loadBalancerBug), {name: "slow cloud charger 60 s", run: 15 * time.Minute, plant: slowCharger(time.Minute)}, {name: "slow cloud charger 120 s", run: 15 * time.Minute, plant: slowCharger(2 * time.Minute)}, - {name: "#1761 replay", run: 20 * time.Minute, - plant: func(p *sitesim.Plant) { - p.HouseW, p.Car.MaxW = houseA(1, 1, 1), 4200 - p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 9400, MaxDischargeW: 9400, CapacityWh: 20000, SoC: .3, Latency: 3 * time.Second} - p.Events = []sitesim.Event{{At: 10 * time.Minute, Do: func(p *sitesim.Plant) { p.Car.MaxW = 11040 }}} - }, - lp: func(c *loadpoint.Config) { - c.MaxChargeW, c.AllowedStepsW = 10350, c.AllowedStepsW[:len(c.AllowedStepsW)-1] - }, - setup: planBatteryCharge(9400), - expect: []simExpect{{9*time.Minute + 50*time.Second, func(s *simSite) error { - p := s.p - offerW := p.EVSE.OfferA * p.Voltage * float64(p.EVSE.OfferPhases) - if offerW != 10350 || math.Abs(p.EVSE.DrawW-4200) > 50 || math.Abs(p.Battery.W-9400) > 200 { - return fmt.Errorf("not the #1761 state before the car takes its offer: offer %.0f W, car %.0f W, battery %.0f W", offerW, p.EVSE.DrawW, p.Battery.W) - } - return nil - }}}}, + replay1761, + withSafetyMarginA(replay1761, .5), {name: "meter every 30 s after a cut", known: "a meter slower than evSettle makes every reading a gap, ev_fuse_budget.go:141", run: 10 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW = houseA(3, 3, 3) @@ -781,6 +814,26 @@ func simScenarios() []simScenario { } } +// withKnown marks sc as failing on the finding known. +func withKnown(sc simScenario, known string) simScenario { + sc.known = known + return sc +} + +// withSafetyMarginA runs sc with an opt-in fuse.safety_margin_a. The +// default is 0; a site that keeps the old 0.5 A must still pass. +func withSafetyMarginA(sc simScenario, a float64) simScenario { + setup := sc.setup + sc.name = fmt.Sprintf("%s, safety_margin_a %.1f A", sc.name, a) + sc.setup = func(s *simSite) { + if setup != nil { + setup(s) + } + s.state.SiteFuseSafetyA = a + } + return sc +} + // runSimScenario runs sc from start and returns the site, with the first // broken invariant or expectation in failure. func runSimScenario(sc simScenario, start time.Time) *simSite { diff --git a/go/internal/control/testdata/golden/seeded_ev_boost.json b/go/internal/control/testdata/golden/seeded_ev_boost.json index f4fd9c77b..89eadd288 100644 --- a/go/internal/control/testdata/golden/seeded_ev_boost.json +++ b/go/internal/control/testdata/golden/seeded_ev_boost.json @@ -1913,12 +1913,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 588, + "target_w": 0, "clamped": false }, { "driver": "sungrow", - "target_w": 588, + "target_w": 0, "clamped": false } ], @@ -1941,9 +1941,9 @@ }, "site_totals": { "battery_current_w": -3117, - "battery_target_sum_w": 1176, + "battery_target_sum_w": 0, "raw_grid_w": -1466, - "projected_grid_w": 2827 + "projected_grid_w": 1651 } }, { @@ -2506,14 +2506,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 5000, - "clamped": true + "target_w": 3950, + "clamped": false } ], "clamp_attributions": { - "clamped_drivers": [ - "ferroamp" - ], + "clamped_drivers": [], "all_targets_zero_clamped": false, "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, @@ -2531,9 +2529,9 @@ }, "site_totals": { "battery_current_w": 1982, - "battery_target_sum_w": 5000, + "battery_target_sum_w": 3950, "raw_grid_w": -2307, - "projected_grid_w": 711 + "projected_grid_w": -339 } }, { diff --git a/go/internal/control/testdata/golden/slew_limiter.json b/go/internal/control/testdata/golden/slew_limiter.json index 79b55820e..067416ae7 100644 --- a/go/internal/control/testdata/golden/slew_limiter.json +++ b/go/internal/control/testdata/golden/slew_limiter.json @@ -10653,7 +10653,7 @@ }, { "driver": "sungrow", - "target_w": 635.7936507936507, + "target_w": 0, "clamped": false } ], @@ -10678,9 +10678,9 @@ }, "site_totals": { "battery_current_w": -2923, - "battery_target_sum_w": 491.7936507936507, + "battery_target_sum_w": -144, "raw_grid_w": -1903, - "projected_grid_w": 1511.7936507936506 + "projected_grid_w": 876 } }, { diff --git a/go/internal/telemetry/store.go b/go/internal/telemetry/store.go index 32e668501..b87d97cb7 100644 --- a/go/internal/telemetry/store.go +++ b/go/internal/telemetry/store.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "math" + "slices" "strings" "sync" "time" @@ -688,6 +689,35 @@ func (s *Store) SumOnlineV2XW() float64 { return sum } +// OnlineLowerW returns the lower of a driver's raw and smoothed power of +// one type, while the driver is still reporting and its latest poll +// succeeded. A held reading after a failed poll returns false. +func (s *Store) OnlineLowerW(driver string, t DerType) (float64, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + r, found := s.readings[key(driver, t)] + h, known := s.health[driver] + if !found || !known || !h.TelemetryLive() || h.LastReadFailed() { + return 0, false + } + w := math.Min(r.RawW, r.SmoothedW) + return w, !math.IsNaN(w) && !math.IsInf(w, 0) +} + +// SumOnlineHigherW sums the higher of raw and smoothed power over the +// readings of these types whose drivers are still reporting. +func (s *Store) SumOnlineHigherW(types ...DerType) float64 { + s.mu.RLock() + defer s.mu.RUnlock() + var sum float64 + for _, r := range s.readings { + if h, ok := s.health[r.Driver]; ok && h.TelemetryLive() && slices.Contains(types, r.DerType) { + sum += math.Max(r.RawW, r.SmoothedW) + } + } + return sum +} + // OnlineMeterW returns a site meter's latest power and when it arrived, // while its driver is still reporting. A watchdog-offline meter returns // false, so its last value never counts as a new reading. diff --git a/go/internal/telemetry/telemetry_test.go b/go/internal/telemetry/telemetry_test.go index 2ac3d4412..56895f170 100644 --- a/go/internal/telemetry/telemetry_test.go +++ b/go/internal/telemetry/telemetry_test.go @@ -572,6 +572,49 @@ func TestSumOnlineV2XWIsSignedAndSkipsOffline(t *testing.T) { } } +func TestOnlineLowerWNeedsALivePoll(t *testing.T) { + s := NewStore() + s.Update("charger", DerEV, 8000, nil, nil) + s.DriverHealthMut("charger").RecordSuccess() + s.Get("charger", DerEV).SmoothedW = 5000 + if w, ok := s.OnlineLowerW("charger", DerEV); !ok || w != 5000 { + t.Fatalf("got %v %v, want the lower 5000 W", w, ok) + } + s.Get("charger", DerEV).RawW = 4000 + if w, ok := s.OnlineLowerW("charger", DerEV); !ok || w != 4000 { + t.Fatalf("got %v %v, want the lower 4000 W", w, ok) + } + s.DriverHealthMut("charger").RecordError("timeout") + if _, ok := s.OnlineLowerW("charger", DerEV); ok { + t.Fatal("a reading held after a failed poll counted") + } + s.DriverHealthMut("charger").RecordSuccess() + s.DriverHealthMut("charger").SetOffline() + if _, ok := s.OnlineLowerW("charger", DerEV); ok { + t.Fatal("an offline driver's reading counted") + } + if _, ok := s.OnlineLowerW("missing", DerEV); ok { + t.Fatal("a missing reading counted") + } +} + +func TestSumOnlineHigherWSumsTheNamedTypes(t *testing.T) { + s := NewStore() + s.Update("battery", DerBattery, -3000, nil, nil) + s.Update("pv", DerPV, -2000, nil, nil) + s.Update("ev", DerEV, 7000, nil, nil) + s.Update("offline", DerBattery, -9000, nil, nil) + for _, d := range []string{"battery", "pv", "ev"} { + s.DriverHealthMut(d).RecordSuccess() + } + s.DriverHealthMut("offline").SetOffline() + s.Get("battery", DerBattery).SmoothedW = -1000 + s.Get("pv", DerPV).SmoothedW = -2500 + if got := s.SumOnlineHigherW(DerBattery, DerPV); got != -3000 { + t.Fatalf("got %v, want -1000 W battery plus -2000 W PV", got) + } +} + func TestWatchdogPerDriverOverride(t *testing.T) { s := NewStore() // Two drivers: one with the site-default tolerance, one with a diff --git a/go/test/site/README.md b/go/test/site/README.md index c09718fcf..c5e600b07 100644 --- a/go/test/site/README.md +++ b/go/test/site/README.md @@ -73,7 +73,7 @@ its sources (`provenance` in [catalog_test.go](catalog_test.go)). ## Scenarios -"Over the limit" is the worst phase above the fuse − 1 A, measured through +"Over the limit" is the worst phase above the fuse rating, measured through the fuse. A device's response time counts as 2 s, the meter and control tick as 2 s more. diff --git a/go/test/site/ev_battery_priority_test.go b/go/test/site/ev_battery_priority_test.go index ab11ca8af..c0ef73233 100644 --- a/go/test/site/ev_battery_priority_test.go +++ b/go/test/site/ev_battery_priority_test.go @@ -2,6 +2,8 @@ package site +import "math" + // #1868: the car has only [0, 4140] W, while the battery can charge at // 6000 W. The actual devices run over Modbus and OCPP; marks and the // physical meter record let the judge see which device yielded. @@ -49,15 +51,20 @@ func judgeS18(d *runData) (metrics, []criterion) { carMin := func(w []*sample) float64 { return minOf(w, func(r *sample) float64 { return carW(r, 0) }) } restart := d.firstT(back, func(r *sample) bool { return carW(r, 0) >= 4100 }) batteryBefore, batteryAfter := meanOf(pre, batSigned), meanOf(d.window(none-20, none), batSigned) + // Core holds the phases at the fuse line, so the house's 0.1 A step + // lands over it until Core cuts; the I²t model judges that stretch. + // Own commands are the rest, as in the minor window. + ownI := math.Max(maxOf(d.window(t0, small), maxAbsI), maxOf(minor, maxAbsI)) m := metrics{"battery_before_w": batteryBefore, "battery_after_w": batteryAfter, "restart_s": restart, "car_before_w": carMin(pre), "car_small_step_w": carMin(minor), "car_recovered_w": carMin(recovered), - "fuse_blown": anyBlown(d.samples), "max_i_before_house_overload": maxOf(d.window(t0, none), maxAbsI)} + "fuse_blown": anyBlown(d.samples), "max_i_before_house_overload": maxOf(d.window(t0, none), maxAbsI), + "max_i_own_commands": ownI} return m, []criterion{ crit("setup: car and battery charge together", len(pre) > 0 && carMin(pre) >= 4100 && batteryBefore > 1000), crit("battery yields before the minimum-step car", len(minor) > 0 && carMin(minor) >= 4100 && batteryAfter < batteryBefore-40), crit("car stops when house leaves no room", len(paused) > 0 && maxOf(paused, func(r *sample) float64 { return carW(r, 0) }) < 100), crit("car restarts automatically within 150 s", le(restart, 150) && len(recovered) > 0 && carMin(recovered) >= 4100), - crit("own commands stay inside the rated fuse", maxOf(d.window(t0, none), maxAbsI) <= 16.01), + crit("own commands stay inside the rated fuse", ownI <= 16.01), crit("fuse holds", !anyBlown(d.samples)), } } diff --git a/go/test/site/evaluate_test.go b/go/test/site/evaluate_test.go index 1e3caaee4..9a2e95a13 100644 --- a/go/test/site/evaluate_test.go +++ b/go/test/site/evaluate_test.go @@ -650,8 +650,7 @@ func le(p *float64, limit float64) bool { return p != nil && *p <= limit } func (d *runData) overage(t0, t1, fuse float64) metrics { w := d.window(t0, t1) m := metrics{"max_i": round(maxOf(w, maxAbsI), 2)} - m["over_limit_longest_s"], m["over_limit_total_s"] = d.runsOver(t0, t1, fuse-1) - m["over_fuse_longest_s"], _ = d.runsOver(t0, t1, fuse) + m["over_limit_longest_s"], m["over_limit_total_s"] = d.runsOver(t0, t1, fuse) m["fuse_damage_max"] = round(maxOf(w, damage), 4) m["fuse_blown"] = anyBlown(w) return m diff --git a/go/test/site/random_test.go b/go/test/site/random_test.go index aeaa2b10d..a0c7f3377 100644 --- a/go/test/site/random_test.go +++ b/go/test/site/random_test.go @@ -217,7 +217,6 @@ type eventWindow struct{ from, to float64 } func judgeInvariants(d *runData) (metrics, []criterion) { fuse := d.metaF("fuse") - limit := fuse - 1 h := d.timing() t0, t1 := d.marks["start"], d.marks["end"] run := d.window(t0, t1) @@ -269,7 +268,7 @@ func judgeInvariants(d *runData) (metrics, []criterion) { var firstBad float64 var start *sample for i, r := range run { - over := maxAbsI(r) > limit + over := maxAbsI(r) > fuse if over && start == nil { start = r } @@ -367,7 +366,8 @@ func judgeInvariants(d *runData) (metrics, []criterion) { for _, hw := range holds { run4 = 0 for i, r := range d.window(hw.from+grace, hw.to) { - roomA := limit - (maxAbsI(r) - carW(r, 0)/(3*volt)) + // Room below fuse - 1 A, the car's line in Core. + roomA := fuse - 1 - (maxAbsI(r) - carW(r, 0)/(3*volt)) bad := carW(r, 0) < 0.5*1380 && roomA >= 7 && !inFault(r.T) && !nearRestart(r.T) if bad && i > 0 { run4++ diff --git a/go/test/site/rig_unit_test.go b/go/test/site/rig_unit_test.go index 7e921c2ad..5afd0dc0b 100644 --- a/go/test/site/rig_unit_test.go +++ b/go/test/site/rig_unit_test.go @@ -4,6 +4,8 @@ package site import ( "encoding/json" + "slices" + "strings" "testing" ) @@ -113,7 +115,7 @@ func TestInvariantsTellStepsFromOwnRaises(t *testing.T) { for i := 0; i <= 100; i++ { a := 10.0 if i >= 50 && i < 56 { - a = 26 // 6 s over the 24 A limit + a = 26 // 6 s over the 25 A fuse } d.samples = append(d.samples, &sample{T: float64(i), PhaseA: [3]float64{a, a, a}, Bat: plantDevice{SoCPct: 50}}) } @@ -139,3 +141,111 @@ func TestInvariantsTellStepsFromOwnRaises(t *testing.T) { t.Fatalf("without a step: %v", f) } } + +// s18 at margin 0: Core holds the phases at the 16 A line, so the house's own +// 0.1 A step lands over it until Core cuts. That stretch is the house's, not +// an own command. The same overage at any other time is Core's and fails. +func TestS18OwnCommandsSkipTheHouseStep(t *testing.T) { + const own = "own commands stay inside the rated fuse" + mk := func(overFrom float64) *runData { + d := &runData{marks: map[string]float64{"charge_now": 0, "small_step": 100, "no_room": 200, "room_returns": 300, "end": 450}} + for i := 0; i <= 450; i++ { + a := 16.0 + if float64(i) >= overFrom && float64(i) < overFrom+4 { + a = 16.1 + } + d.samples = append(d.samples, &sample{T: float64(i), PhaseA: [3]float64{a, a, a}}) + } + return d + } + ok := func(d *runData) bool { + _, cs := judgeS18(d) + for _, c := range cs { + if c.Name == own { + return c.OK + } + } + t.Fatalf("no %q criterion", own) + return false + } + if !ok(mk(100)) { + t.Error("4 s at 16.1 A right after the house's step failed") + } + for _, at := range []float64{50, 150} { + if ok(mk(at)) { + t.Errorf("4 s at 16.1 A from %.0f s with no outside step passed", at) + } + } +} + +// s8 at margin 0: the car settles at the fuse rating, as the owner's rule +// says. A phase at exactly the fuse for the whole run passes; 0.1 A above +// it for longer than the charger's delay + 7 s fails. Before, the judge +// counted every second above fuse - 1 A, and s8-60-25 failed at 25 A. +func TestS8JudgesAtTheFuse(t *testing.T) { + const over = "no longer than the charger's delay + 7 s" + ok := func(a float64) bool { + d := &runData{marks: map[string]float64{"charge_now": 0, "end": 300}, meta: map[string]any{"fuse": 25.0, "charger_delay_s": 60.0}} + for i := 0; i <= 300; i++ { + d.samples = append(d.samples, &sample{T: float64(i), PhaseA: [3]float64{a, 15, 15}}) + } + _, cs := judgeS8(d) + for _, c := range cs { + if strings.HasSuffix(c.Name, over) { + return c.OK + } + } + t.Fatalf("no criterion ending %q", over) + return false + } + if !ok(25) { + t.Error("a phase at exactly the 25 A fuse failed") + } + if ok(25.1) { + t.Error("a phase 0.1 A over the 25 A fuse for 300 s passed") + } +} + +// The limit is the fuse rating: a phase may sit at exactly the fuse until +// the end of the run, and 0.1 A above it fails once the time budget is up. +func TestInvariantsJudgeAtTheFuse(t *testing.T) { + const fuse = 25.0 + const own = "over the limit at most 3 s after FTW's own raise" + const step = "over the limit no longer than an outside step allows" + for _, c := range []struct { + name string + a float64 + to int // the level holds from 50 s until this second + withStep bool + want []string + }{ + {"at the fuse", fuse, 101, false, nil}, + {"at the fuse after a step", fuse, 101, true, nil}, + // 20 s is past the 3 s for FTW's own raise, and past the + // 2 + 2 + 3 s a step allows while the battery can answer. + {"0.1 A over", fuse + 0.1, 70, false, []string{own}}, + {"0.1 A over after a step", fuse + 0.1, 70, true, []string{step}}, + } { + d := &runData{marks: map[string]float64{"start": 0, "end": 100}, meta: map[string]any{"fuse": fuse}} + for i := 0; i <= 100; i++ { + a := 10.0 + if i >= 50 && i < c.to { + a = c.a + } + d.samples = append(d.samples, &sample{T: float64(i), PhaseA: [3]float64{a, a, a}, Bat: plantDevice{SoCPct: 50}}) + } + if c.withStep { + d.events = append(d.events, houseEventLine{T: 49.5, Event: houseEvent{Do: "loads"}}) + } + var failed []string + _, cs := judgeInvariants(d) + for _, x := range cs { + if !x.OK { + failed = append(failed, x.Name) + } + } + if !slices.Equal(failed, c.want) { + t.Errorf("%s: failed %v, want %v", c.name, failed, c.want) + } + } +} diff --git a/go/test/site/scenarios_test.go b/go/test/site/scenarios_test.go index c1cb69f9d..294124cca 100644 --- a/go/test/site/scenarios_test.go +++ b/go/test/site/scenarios_test.go @@ -1010,7 +1010,7 @@ func judgeS15bat(d *runData) (metrics, []criterion) { start := d.firstT(t0, func(r *sample) bool { return carW(r, 0) >= 0.95*1380 }) over, longest := 0.0, 0.0 for _, r := range d.window(t0, t1) { - if maxAbsI(r) > fuse-1 { + if maxAbsI(r) > fuse { over++ longest = math.Max(longest, over) } else { @@ -1018,13 +1018,13 @@ func judgeS15bat(d *runData) (metrics, []criterion) { } } m := metrics{"battery_rise_w_20s_before": math.Round(rise), "car_start_s": roundp(start, 1), - "car_w_last60": math.Round(meanOf(d.window(t1-60, t1), func(r *sample) float64 { return carW(r, 0) })), - "over_fuse_minus1_longest_s": longest, "max_abs_i": round(maxOf(d.window(t0, t1), maxAbsI), 1)} + "car_w_last60": math.Round(meanOf(d.window(t1-60, t1), func(r *sample) float64 { return carW(r, 0) })), + "over_fuse_longest_s": longest, "max_abs_i": round(maxOf(d.window(t0, t1), maxAbsI), 1)} return m, []criterion{ crit("the battery order rose over 1 150 W in the 20 s before Charge now", rise > 1150), // the setup crit("car charging within 10 s of Charge now", le(start, 10)), crit("fuse holds", !anyBlown(d.window(t0, t1))), - crit("over fuse - 1 A at most 3 s", longest <= 3), + crit("over the fuse at most 3 s", longest <= 3), } } diff --git a/go/test/site/verdicts_test.go b/go/test/site/verdicts_test.go index 9cb180db0..cfc2c678f 100644 --- a/go/test/site/verdicts_test.go +++ b/go/test/site/verdicts_test.go @@ -85,11 +85,10 @@ func judgeS3(d *runData) (metrics, []criterion) { "pre_step_max_i": round(maxOf(pre, maxAbsI), 2), } m["post_step_max_i"] = round(maxOf(d.window(tStep, tBack), maxAbsI), 2) - m["over_fuse_longest_s"], m["over_fuse_total_s"] = d.runsOver(tStep, tBack, fuse) - overLongest, overTotal := d.runsOver(tStep, tBack, fuse-1) - m["over_fuse_minus1_longest_s"], m["over_fuse_minus1_total_s"] = overLongest, overTotal - m["i2_over_fuse_minus1_s"], m["i2_peak_a"] = d.i2Over(tCN, t1, fuse-1) - m["back_under_fuse_minus1_s"] = roundp(d.firstT(tStep, func(r *sample) bool { return maxAbsI(r) <= fuse-1 && r.T > tStep+1 }), 1) + overLongest, overTotal := d.runsOver(tStep, tBack, fuse) + m["over_fuse_longest_s"], m["over_fuse_total_s"] = overLongest, overTotal + m["i2_over_fuse_s"], m["i2_peak_a"] = d.i2Over(tCN, t1, fuse) + m["back_under_fuse_s"] = roundp(d.firstT(tStep, func(r *sample) bool { return maxAbsI(r) <= fuse && r.T > tStep+1 }), 1) last := d.window(tBack-60, tBack) m["post_step_car_w_last60"] = math.Round(meanOf(last, func(r *sample) float64 { return carW(r, 0) })) m["post_step_bat_w_last60"] = math.Round(meanOf(last, batSigned)) @@ -105,15 +104,12 @@ func judgeS3(d *runData) (metrics, []criterion) { // Device response + meter and tick (2 + 2 s) + 3 s. allowed := d.metaF("charger_delay_s") + 2 + 2 + 3 m["allowed_over_s"] = allowed - m["run_over_fuse_minus1_longest_s"], _ = d.runsOver(tCN, t1, fuse-1) + m["run_over_fuse_longest_s"], _ = d.runsOver(tCN, t1, fuse) m["run_max_i"] = round(maxOf(after, maxAbsI), 2) - // Before the step, layers with different margins may hold a phase - // between fuse - 1 A and fuse - 0.5 A; reported, not judged. - m["pre_step_over_fuse_minus1_longest_s"], _ = d.runsOver(tCN, tStep, fuse-1) - m["pre_step_over_fuse_minus_half_longest_s"], _ = d.runsOver(tCN, tStep, fuse-0.5) + m["pre_step_over_fuse_longest_s"], _ = d.runsOver(tCN, tStep, fuse) return m, []criterion{ crit("fuse holds", !isBlown), - crit("over fuse - 1 A no longer than the response time + 3 s after the step", overLongest <= allowed), + crit("over the fuse no longer than the response time + 3 s after the step", overLongest <= allowed), } } @@ -412,10 +408,9 @@ func judgeS8(d *runData) (metrics, []criterion) { fuse := d.metaF("fuse") t0, t1 := d.marks["charge_now"], d.marks["end"] m := d.carSummary(t0, t1, maxW) - m["over_fuse_longest_s"], m["over_fuse_total_s"] = d.runsOver(t0, t1, fuse) - overLongest, overTotal := d.runsOver(t0, t1, fuse-1) - m["over_fuse_minus1_longest_s"], m["over_fuse_minus1_total_s"] = overLongest, overTotal - m["i2_over_fuse_minus1_s"], m["i2_peak_a"] = d.i2Over(t0, t1, fuse-1) + overLongest, overTotal := d.runsOver(t0, t1, fuse) + m["over_fuse_longest_s"], m["over_fuse_total_s"] = overLongest, overTotal + m["i2_over_fuse_s"], m["i2_peak_a"] = d.i2Over(t0, t1, fuse) var prs [][2]float64 for i, p := range connector1(profiles(d.frames, t0, t1)) { if i >= 20 { @@ -433,7 +428,7 @@ func judgeS8(d *runData) (metrics, []criterion) { } return m, []criterion{ crit("fuse holds", !m["fuse_blown"].(bool)), - crit("over fuse - 1 A no longer than the charger's delay + 7 s", overLongest <= allowed), + crit("over the fuse no longer than the charger's delay + 7 s", overLongest <= allowed), } } @@ -459,8 +454,8 @@ func judgeS9(d *runData) (metrics, []criterion) { m["profiles_after_switch"] = seq return m, []criterion{ crit("fuse holds", !before["fuse_blown"].(bool) && !after["fuse_blown"].(bool)), - crit("over fuse - 1 A at most 3 s on 3p", before["over_limit_longest_s"].(float64) <= 3), - crit("over fuse - 1 A at most 3 s after the switch to 1p", after["over_limit_longest_s"].(float64) <= 3), + crit("over the fuse at most 3 s on 3p", before["over_limit_longest_s"].(float64) <= 3), + crit("over the fuse at most 3 s after the switch to 1p", after["over_limit_longest_s"].(float64) <= 3), } } @@ -500,7 +495,7 @@ func judgeS10(d *runData) (metrics, []criterion) { } return m, []criterion{ crit("fuse holds", !before["fuse_blown"].(bool) && !after["fuse_blown"].(bool)), - crit("over fuse - 1 A at most 3 s after the step", after["over_limit_longest_s"].(float64) <= 3), + crit("over the fuse at most 3 s after the step", after["over_limit_longest_s"].(float64) <= 3), } } @@ -547,7 +542,7 @@ func judgeS11(d *runData) (metrics, []criterion) { } return m, []criterion{ crit("an offer within 60 s of the restart", le(firstPosS, 60)), - crit("over fuse - 1 A at most 3 s", run["over_limit_longest_s"].(float64) <= 3), + crit("over the fuse at most 3 s", run["over_limit_longest_s"].(float64) <= 3), crit("fuse holds", !run["fuse_blown"].(bool)), } } diff --git a/web/settings-shell.test.mjs b/web/settings-shell.test.mjs index 6f940efbc..5c625044b 100644 --- a/web/settings-shell.test.mjs +++ b/web/settings-shell.test.mjs @@ -522,7 +522,7 @@ async function electricalShell(fuse) { const rig = loadShell(structuredClone(original)); rig.loadTab('./settings/tabs/control.js'); const fields = {}; - for (const [key, fallback] of Object.entries({ phases: 3, voltage: 230, max_amps: 16, safety_margin_a: 0.5 })) { + for (const [key, fallback] of Object.entries({ phases: 3, voltage: 230, max_amps: 16, safety_margin_a: 0 })) { fields[key] = Object.assign(stubElement(), { dataset: { path: 'fuse.' + key }, type: 'number', value: String(fuse[key] ?? fallback), }); @@ -563,6 +563,21 @@ describe('electricity connection settings', () => { }); } + it('shows an unset safety margin as 0 and offers a margin as opt-in', () => { + const rig = loadShell({}); + rig.loadTab('./settings/tabs/control.js'); + const config = { site: {}, fuse: { max_amps: 16 } }; + const getByPath = (obj, path, dflt) => path.split('.').reduce((node, key) => node?.[key], obj) ?? dflt; + const html = rig.tabs.control.render({ + config, getByPath, escHtml: s => String(s ?? ''), + help: text => '?', + field: (label, path, type, dflt) => '', + }); + assert.match(html, /data-path="fuse\.safety_margin_a" value="0"/); + assert.match(html, /Optional\. Amps to keep below the fuse rating[^"]*0 works to the rating\./); + assert.doesNotMatch(html, /Defaults to 0\.5 A/); + }); + it('only replaces custom voltage when the user picks a standard connection', async () => { const rig = await electricalShell({ phases: 2, voltage: 240, max_amps: 20, safety_margin_a: 0 }); rig.connection.value = '3'; diff --git a/web/settings/tabs/control.js b/web/settings/tabs/control.js index e5611b15d..40d9a7f4b 100644 --- a/web/settings/tabs/control.js +++ b/web/settings/tabs/control.js @@ -96,8 +96,8 @@ '