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/c-battery-fuse-no-buffer.md b/.changeset/c-battery-fuse-no-buffer.md new file mode 100644 index 000000000..de59208a1 --- /dev/null +++ b/.changeset/c-battery-fuse-no-buffer.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +When the home battery cuts its charge or discharge to protect the fuse, it now cuts only by the overage, so the grid settles on the limit instead of below it. It used to cut half of `safety_margin_a` more on top. Sites without a safety margin see no change. 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/s1-ev-cut-landed-and-unshown.md b/.changeset/s1-ev-cut-landed-and-unshown.md new file mode 100644 index 000000000..202e88fbf --- /dev/null +++ b/.changeset/s1-ev-cut-landed-and-unshown.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The car's fuse cut now acts on what the site meter shows. A cut the meter already shows no longer counts as still on its way, so when the house adds load after it, FTW cuts again once the fuse's heat budget is spent instead of waiting up to three minutes. A house load that moves on one phase alone is not taken for the car's cut landing, so it no longer stacks a second cut on a slow charger. A raise that is on its way but no longer fits now comes off before it lands, when it would take a phase above 1.2 times the fuse rating, or over the line on a phase whose heat budget is spent. For a car the site meter has shown on three phases, this also holds when the house steps up on one phase alone. Until then, a rise on any phase counts as the raise landing, so a car on one phase is not cut while every phase is under the line. diff --git a/.changeset/s2-battery-rise-per-phase.md b/.changeset/s2-battery-rise-per-phase.md new file mode 100644 index 000000000..f416fc33a --- /dev/null +++ b/.changeset/s2-battery-rise-per-phase.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A battery charge rise now fits each phase before FTW sends it. The room is the fuse's working limit less the phase's reading and less FTW's own rises the meter does not show yet: the car's raises and the battery's earlier orders. The car's raise comes first. A battery order stays counted until the meter has had time to show the battery's own reading of it, so a battery that ramps slowly does not get a second rise on top. On a meter that reports phase currents without phase power, a phase counts as export only when the other phases cannot carry the exported total alone; any other phase counts as import. Near the line this keeps the battery from charging into a phase the meter shows full, and from stacking a rise on one the meter has not shown yet, which pushed a phase over the fuse and made the charge rise and fall around the limit. Holding a charge is never a rise, so this check never cuts a charge. A frozen meter is not covered here. 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/docs/safety.md b/docs/safety.md index 11981bd67..6229cb646 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -66,7 +66,10 @@ tariff import ceiling still applies. A phase's current takes its direction from the same phase's power. It counts as export only when that power shows it clearly and the phases' -powers add up to the meter's total; otherwise it counts as import. An +powers add up to the meter's total; otherwise it counts as import. The +battery's fuse checks make one exception for a meter with no phase power +while the total exports: a phase counts as export when the other phases' +currents cannot carry that export alone. An exporting phase is never cut for: less EV load would raise its export. A rise or start on it reads its current as it is, without its heat. 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/battery_rise_phase_test.go b/go/internal/control/battery_rise_phase_test.go new file mode 100644 index 000000000..522880bd9 --- /dev/null +++ b/go/internal/control/battery_rise_phase_test.go @@ -0,0 +1,236 @@ +package control + +import ( + "fmt" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// C2 of the 27 Oct rig triage: a battery charge rise fits each phase's room, +// (fuseLineA − measured phase − our unshown rises) × V × phases, before it is +// sent. 16 A fuse, 230 V, 3 phases. + +// riseStore holds one meter reading with these phase currents, all import, +// and the battery's reading. +func riseStore(t *testing.T, phaseA [3]float64, batW float64) *telemetry.Store { + t.Helper() + data := map[string]any{} + var totalW float64 + for i, a := range phaseA { + data[fmt.Sprintf("l%d_a", i+1)] = math.Abs(a) + data[fmt.Sprintf("l%d_w", i+1)] = a * 230 + totalW += a * 230 + } + return phaseDirectionStore(t, totalW, batW, data) +} + +// riseState asks the battery for askW of charge (Charge mode at a charge +// limit of askW), so the fuse path alone shapes the order. +func riseState(askW float64) *State { + st := phaseDirectionState(0) + st.Mode = ModeCharge + now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + st.clock = func() time.Time { return now } + st.batteryCommands = map[string]commandLog{} + ask(st, askW) + return st +} + +func ask(st *State, w float64) { + st.DriverLimits = map[string]PowerLimits{"bat": {MaxChargeW: w, MaxDischargeW: 10000}} +} + +func batteryOrder(t *testing.T, store *telemetry.Store, st *State) float64 { + t.Helper() + for _, o := range ComputeDispatch(store, st, map[string]float64{"bat": 15200}, 16*230*3) { + if o.Driver == "bat" { + return o.TargetW + } + } + t.Fatal("no battery target") + return 0 +} + +// Seed 207530 at 897 s: L3 reads 16.04 A, over the 16 A line, with the +// battery at 491 W after a cut to 491 W. A plan target of 3350 W is no rise +// room: the order stays at or below the battery's reading. +func TestBatteryRiseIntoPhaseOverTheLine(t *testing.T) { + store := riseStore(t, [3]float64{8.5, 10.5, 16.04}, 491) + st := riseState(3350) + sent(st, "bat", 3349, 491) + if got := batteryOrder(t, store, st); got > 491+1e-6 { + t.Fatalf("order %.0f W with L3 at 16.04 A and the battery at 491 W; want <= 491 W", got) + } +} + +// Seed 207535 at 37-41 s, with no margin. L3 reads 14.27 A with the battery +// at 12 W: 871 W fits under the line and goes out whole. Two seconds later +// the battery reads 871 W, but the meter's reading is from before the +// battery moved and still shows L3 at 14.27 A. The 871 W rise is ours and +// not shown, so a further ask may add only what is left above it: the order +// stays at or below 12 W + (16 − 14.27) × 690 W. +func TestBatteryRiseCountsOwnUnshownRise(t *testing.T) { + phases := [3]float64{13.77, 13.97, 14.27} + limitW := 12 + (16-14.27)*690 + + st := riseState(871) + sent(st, "bat", 12) + if got := batteryOrder(t, riseStore(t, phases, 12), st); math.Abs(got-871) > 1e-6 { + t.Fatalf("first ask: order %.1f W, want the whole 871 W (it fits under %.1f W)", got, limitW) + } + + st = riseState(1500) + sent(st, "bat", 12, 871) + got := batteryOrder(t, riseStore(t, phases, 871), st) + if got > limitW+1e-6 { + t.Fatalf("order %.1f W while the meter does not show our 871 W yet: L3 would reach %.2f A; want <= %.1f W", + got, 14.27+(got-12)/690, limitW) + } + if got < 871-1e-6 { + t.Fatalf("order %.1f W cut below our 871 W hold; a rise check never cuts", got) + } +} + +// Closed loop, battery only, no margin: house loads 13.77/13.97/14.27 A and +// a charge ask that grows 300 W a tick from 600 W to 3000 W, as a PV +// surplus does. The battery reaches each order one tick later; the meter +// shows the battery one tick after the battery's own reading does. L3 +// never passes the 16 A line, the order never moves down (no hunting), and +// it ends at the line: 1193.7 W. +func TestBatteryRiseClosedLoopStopsAtTheLine(t *testing.T) { + house := [3]float64{13.77, 13.97, 14.27} + st := riseState(600) + now := st.now() + st.clock = func() time.Time { return now } + var orders, l3 []float64 + batW, meterBatW := 0.0, 0.0 + for tick := 0; tick < 40; tick++ { + ask(st, math.Min(3000, 600+300*float64(tick))) + var a [3]float64 + for i := range a { + a[i] = house[i] + meterBatW/690 + } + order := batteryOrder(t, riseStore(t, a, batW), st) + orders = append(orders, math.Round(order*10)/10) + l3 = append(l3, math.Round(a[2]*1000)/1000) + meterBatW, batW = batW, order + now = now.Add(2 * time.Second) + } + for i := range orders { + if l3[i] > 16+1e-3 { + t.Errorf("tick %d: L3 %.3f A over the 16 A line", i, l3[i]) + } + if i > 0 && orders[i] < orders[i-1]-0.5 { + t.Errorf("tick %d: order fell %.1f -> %.1f W", i, orders[i-1], orders[i]) + } + } + if want := (16 - 14.27) * 690; math.Abs(orders[len(orders)-1]-want) > 1 { + t.Errorf("final order %.1f W, want %.1f W: L3 at the line", orders[len(orders)-1], want) + } + if t.Failed() { + t.Logf("orders %v", orders) + t.Logf("L3 %v", l3) + } +} + +// Car first: the car's raise from 6 A to 10 A per phase went out 2 s ago +// and the meter does not show it yet: L3 reads 11 A with the car at 6 A. +// The raise takes L3 to 15 A, so the battery's 2000 W charge ask gets the +// 1 A left, 690 W. Once the meter shows the car at 10 A and the battery at +// 690 W, L3 sits at the 16 A line and the battery holds. With the car's demand +// and without it (a raise still on its way after the demand left). +func TestBatteryRiseLeavesRoomForUnshownCarRaise(t *testing.T) { + for _, demand := range []bool{true, false} { + t.Run(fmt.Sprintf("demand=%v", demand), func(t *testing.T) { + st := riseState(2000) + now := st.now() + st.clock = func() time.Time { return now } + sent(st, "bat", 0) + var log commandLog + log = log.keep(sentCommand{at: now.Add(-evSettle - time.Second), w: 4140, a: 6, n: 3}, now.Add(-evSettle)) + log = log.keep(sentCommand{at: now.Add(-2 * time.Second), w: 6900, a: 10, n: 3}, now.Add(-evSettle)) + st.evLimit.offers = map[string]chargerOffers{"car": {log: log, settle: evSettle}} + st.EVCommandedW = 6900 + if demand { + st.evFuseDemands = []evFuseDemand{{"car", 6900, "3p"}} + } + got := batteryOrder(t, riseStore(t, [3]float64{9, 9, 11}, 0), st) + if got > 690+1e-6 { + t.Fatalf("battery %.1f W beside the car's unshown 4 A: L3 would reach %.2f A", got, 11+4+got/690) + } + + now = now.Add(evSettle) + got = batteryOrder(t, riseStore(t, [3]float64{13 + got/690, 13 + got/690, 15 + got/690}, got), st) + if math.Abs(got-690) > 1 { + t.Fatalf("battery %.1f W once the meter shows the car, want 690 W: L3 at the 16 A line", got) + } + }) + } +} + +// A meter that reports current magnitudes and no phase power, as Tibber +// Pulse does (l1_a..l3_a only). L1 reads 15.5 A, L2 and L3 8.7 A, and the +// total exports 1300 W. L2 and L3 alone can carry that export, so L1 may be +// a load importing 15.5 A beside them, and a three-phase charge rise adds to +// it: the order stays at or below (16 − 15.5) × 690 W. When the currents add +// up to the export, every phase must export, and charge may soak it up. +func TestBatteryRiseMagnitudeOnlyMeter(t *testing.T) { + st := riseState(3000) + sent(st, "bat", 0) + store := phaseDirectionStore(t, -1300, 0, map[string]any{"l1_a": 15.5, "l2_a": 8.7, "l3_a": 8.7}) + if got, limit := batteryOrder(t, store, st), (16-15.5)*690; got > limit+1e-6 { + t.Fatalf("order %.0f W: L1 would reach %.2f A if it imports; want <= %.0f W", got, 15.5+got/690, limit) + } + + st = riseState(3000) + sent(st, "bat", 0) + store = phaseDirectionStore(t, -12075, 0, map[string]any{"l1_a": 17.5, "l2_a": 17.5, "l3_a": 17.5}) + if got := batteryOrder(t, store, st); math.Abs(got-3000) > 1e-6 { + t.Fatalf("order %.0f W with every phase exporting 17.5 A; want the whole 3000 W ask", got) + } +} + +// A battery that ramps 50 W a tick toward its order, so it has not reached +// an order older than evSettle, and a meter that shows the battery two ticks +// after the battery reads it. The order counts as unshown until evSettle +// after the battery reads it: L3 never passes the 16 A line and the order +// never falls. +func TestBatteryRiseSlowBatteryLaggingMeter(t *testing.T) { + house := [3]float64{13.77, 13.97, 14.27} + st := riseState(3000) + now := st.now() + st.clock = func() time.Time { return now } + var hist, orders []float64 + batW, maxL3 := 0.0, 0.0 + for tick := 0; tick < 120; tick++ { + meterBatW := 0.0 + if n := len(hist); n >= 2 { + meterBatW = hist[n-2] + } + var a [3]float64 + for i := range a { + a[i] = house[i] + meterBatW/690 + } + order := batteryOrder(t, riseStore(t, a, batW), st) + orders = append(orders, math.Round(order)) + maxL3 = math.Max(maxL3, house[2]+batW/690) + hist = append(hist, batW) + batW = math.Min(order, batW+50) + now = now.Add(2 * time.Second) + } + if maxL3 > 16+1e-3 { + t.Errorf("L3 reached %.3f A, over the 16 A line", maxL3) + } + for i := 1; i < len(orders); i++ { + if orders[i] < orders[i-1]-0.5 { + t.Errorf("tick %d: order fell %.0f -> %.0f W", i, orders[i-1], orders[i]) + break + } + } + if t.Failed() { + t.Logf("orders %v", orders) + } +} 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 2a63140be..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 @@ -400,6 +402,9 @@ type State struct { // batteryCommands and evLimit.offers are our own recent commands, the // only device input the EV hard limit and the EV/battery split use. batteryCommands map[string]commandLog + // batteryReadings is each battery's own reading at our last commands, + // for the power the site meter may not show yet (unshownBatteryW). + batteryReadings map[string]commandLog // batteryFuseRelief is the discharge the fuse guard added to each // battery's last dispatched command (forceFuseDischarge). It ends once // the site fits without it, so an EV rise or start never counts it as @@ -1103,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 @@ -1494,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)) @@ -1573,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 @@ -1755,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 @@ -1771,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 @@ -1865,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 } @@ -1995,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 @@ -2394,6 +2434,7 @@ func applyDispatchSafetyPipeline( } // ---- Fuse guard (bidirectional, #145) ---- targets = applyFuseGuard(targets, store, state, fuseMaxW) + targets = fitBatteryRisesToFuse(targets, store, state) // ---- Plan/exec sign-mismatch floor (energy planner modes only) ---- // Operator-report 2026-04-28 (08:00-08:15 CEST): planner_arbitrage @@ -2443,6 +2484,7 @@ func applyDispatchSafetyPipeline( recordDispatchTargets(targets, state, opts.updatePrevTargets, opts.recordDispatch) if opts.recordDispatch { state.recordFuseRelief(beforeRelief, targets) + state.recordBatteryReadings(targets, store) } return targets } @@ -3862,20 +3904,11 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta return out } - // Headroom buffer: shrink targets by `overage + half the configured - // safety margin` so post-clamp the grid sits *below* the threshold - // instead of riding right at it. Without this, the next dispatch - // tick lets the planner re-ramp into the threshold and the system - // oscillates at the boundary — the operator's "safety margin" - // becomes the active steady-state instead of a buffer below it. - // - // Half-margin chosen so the post-clamp phase amps still cluster - // near the threshold (operator wants the fuse used efficiently) - // but with enough buffer to absorb load fluctuations and per-phase - // imbalance on the next tick. Operators wanting tighter or looser - // buffer just adjust safety_margin_a. - buffer := state.fuseSafetyMarginW() * 0.5 - + // Cut by the overage only, so the grid lands on the line, not below it. + // The prediction from the held targets above keeps the next dispatch + // from re-ramping through the line, and the 30 s hold below keeps the + // planner at the clamped total. A site that wants headroom sets + // safety_margin_a, which lowers the line itself. switch { case importOverage > 0: // Too much import (aggregate or per-phase) → shrink charging. @@ -3892,7 +3925,7 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta // still flip idle batteries to discharge. return out } - newTotal := totalCharge - importOverage - buffer + newTotal := totalCharge - importOverage if newTotal < 0 { newTotal = 0 } @@ -3920,7 +3953,7 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta // is the lever in that scenario (annotateCurtailment). return out } - newTotal := totalDischarge - exportOverage - buffer + newTotal := totalDischarge - exportOverage if newTotal < 0 { newTotal = 0 } @@ -4018,6 +4051,145 @@ func livePhaseSample(store *telemetry.Store, driver string, meter *telemetry.Der return phaseSample{meter: meter, totalW: totalW, store: store} } +// signedPhasesA is the sample's phase currents, import positive +// (phaseCurrentsA). When no phase reads as export while the total does, the +// meter gives no direction per phase. A phase then counts as export only +// when it must: when the other phases' currents, at most V × A of real power +// each, cannot carry the exported total on their own (tolerance as in +// phasePowersAddUp). A phase that may import counts as import, +// phaseCurrentsA's safe side, since a reactive load can draw its whole +// current beside exporting phases. false without usable phase currents. +func (p phaseSample) signedPhasesA(state *State) ([]float64, bool) { + if p.meter == nil || state == nil { + return nil, false + } + amps, _, valid := phaseCurrentsA(p.meter, state, p.totalW) + if !valid || len(amps) == 0 { + return nil, false + } + exports := p.totalW < 0 + var sumW float64 + for _, a := range amps { + exports = exports && a >= 0 + sumW += a * state.SiteFuseVoltage + } + if !exports { + return amps, true + } + tolW := math.Max(250, sumW/10) + for i, a := range amps { + if sumW-a*state.SiteFuseVoltage < -p.totalW-tolW { + amps[i] = -a + } + } + return amps, true +} + +// unshownBatteryW is the batteries' increase the site meter may not show +// yet: each battery's latest order less the least the meter may show of it, +// the lowest order of the last evSettle (as pendingBatteryCommandsW) or the +// lowest of the battery's own readings over that time, the current one +// included. A battery that ramps slower than evSettle has not reached an +// order older than evSettle, so the order stays unshown until evSettle after +// the battery reads it. +func (s *State) unshownBatteryW(now time.Time, store *telemetry.Store) float64 { + var unshown float64 + for driver, log := range s.batteryCommands { + low, _, latest, ok := log.span(now) + if !ok { + continue + } + if read, _, _, seen := s.batteryReadings[driver].span(now); seen { + low = math.Min(low, read) + } + if w, _, live := store.OnlineRawW(driver, telemetry.DerBattery); live { + low = math.Min(low, w) + } + unshown += math.Max(0, latest-low) + } + return unshown +} + +// recordBatteryReadings logs each dispatched battery's reading for +// unshownBatteryW. +func (s *State) recordBatteryReadings(targets []DispatchTarget, store *telemetry.Store) { + if store == nil { + return + } + now := s.now() + for _, t := range targets { + w, _, live := store.OnlineRawW(t.Driver, telemetry.DerBattery) + if !live { + continue + } + if s.batteryReadings == nil { + s.batteryReadings = map[string]commandLog{} + } + s.batteryReadings[t.Driver] = s.batteryReadings[t.Driver].add(now, w) + } +} + +// fitBatteryRisesToFuse keeps battery charge rises inside the room the site +// meter shows on its busiest phase: the working limit (fuseLineA) less the +// phase's reading and less our own rises that reading may not show yet, the +// chargers' offers (counted as evShownW does) and the batteries' orders +// (unshownBatteryW). A three-phase battery puts its power evenly on +// every phase. A rise is charge above the battery's latest order, or above +// its reading before our first order. Holding an order is never a rise, and +// a discharge we end uses room first. Nothing here cuts below the hold: an +// overage is the relief's (applyFuseGuard, forceFuseDischarge), which reads +// the battery's own power. Without usable phase currents the aggregate guard +// is the check. +func fitBatteryRisesToFuse(targets []DispatchTarget, store *telemetry.Store, state *State) []DispatchTarget { + if store == nil || state == nil || len(targets) == 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases <= 0 { + return targets + } + meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) + if meter == nil { + return targets + } + amps, ok := livePhaseSample(store, state.SiteMeterDriver, meter).signedPhasesA(state) + if !ok { + return targets + } + now := state.now() + perA := state.SiteFuseVoltage * float64(state.SiteFusePhases) + unshownA := state.unshownBatteryW(now, store) / perA + for _, c := range state.evLimit.offers { + _, a := c.log.unshown(now, c.batterySettle()) + unshownA += a // A one-phase offer may land on any phase. + } + worstA := math.Inf(-1) + for _, a := range amps { + worstA = math.Max(worstA, a) + } + roomW := (state.fuseLineA() - worstA - unshownA) * perA + holds := make([]float64, len(targets)) + var riseW float64 + for i, t := range targets { + var base float64 + if _, _, latest, sent := state.batteryCommands[t.Driver].span(now); sent { + base = latest + } else if w, _, live := store.OnlineRawW(t.Driver, telemetry.DerBattery); live { + base = w + } + roomW -= math.Max(0, math.Min(0, t.TargetW)-math.Min(0, base)) + holds[i] = math.Min(t.TargetW, math.Max(0, base)) + riseW += t.TargetW - holds[i] + } + if riseW <= 0 || roomW >= riseW { + return targets + } + scale := math.Max(0, roomW) / riseW + out := append([]DispatchTarget(nil), targets...) + for i := range out { + if out[i].TargetW > holds[i] { + out[i].TargetW, out[i].Clamped = holds[i]+(out[i].TargetW-holds[i])*scale, true + } + } + return out +} + // moveW is the batteries' change from the raw power the phases hold to // the targets. The phase currents are raw, so the move is too. Each // battery's raw power comes live from the store and must be the reading in @@ -4103,7 +4275,7 @@ func phaseReliefForTargets(p phaseSample, state *State, targets []DispatchTarget importW, exportW = phaseFuseReliefW(p, state, moveW) sentImportW, sentExportW := phaseSentReliefW(p, state, sentW) importW, exportW = math.Max(importW, sentImportW), math.Max(exportW, sentExportW) - if amps, ok := p.signedA(state); ok { + if amps, ok := p.signedPhasesA(state); ok { if worst, stuck, backTo := state.phaseUnresponsive(amps, p.totalW, rawW); stuck { capW := sentW if backTo != nil { @@ -4246,7 +4418,7 @@ func batteriesMovedW(ref *map[string]float64, rawW map[string]float64) (movedW f // the import side gives way to phaseFuseReliefW's least-over move; the // same holds the other way round. func phaseSentReliefW(p phaseSample, state *State, sentW float64) (importW, exportW float64) { - amps, ok := p.signedA(state) + amps, ok := p.signedPhasesA(state) if !ok { return 0, 0 } @@ -4269,29 +4441,6 @@ func phaseSentReliefW(p phaseSample, state *State, sentW float64) (importW, expo return importW, exportW } -// signedA is the phase currents, + import, from phaseCurrentsA. When no -// phase reads as export while the total does, the meter gives no direction -// per phase, and the phases take the total's. -func (p phaseSample) signedA(state *State) ([]float64, bool) { - if p.meter == nil || state == nil { - return nil, false - } - amps, _, valid := phaseCurrentsA(p.meter, state, p.totalW) - if !valid || len(amps) == 0 { - return nil, false - } - exports := p.totalW < 0 - for _, a := range amps { - exports = exports && a >= 0 - } - if exports { - for i := range amps { - amps[i] = -amps[i] - } - } - return amps, true -} - // phaseFuseReliefW is the aggregate battery move that brings every phase // inside the working limit, fuseLineA: the fuse less safety_margin_a, the // same line the car uses. importW asks for less charge or more @@ -4299,17 +4448,15 @@ func (p phaseSample) signedA(state *State) ([]float64, bool) { // the power this reading already holds (phaseSample.moveW); like the // relief, it spreads evenly over the site's phases. // -// The phases are the meter's signed currents (phaseCurrentsA). When no -// phase reads as export while the total does, the meter gives no direction -// per phase, and the phases take the total's. When no move fits every -// phase, the move leaves the worst phase least over, so on a meter that -// gives each phase's direction a move never raises the current on the phase -// it protects. Two limits remain: a meter without phase power that mixes -// directions under an exporting total, where an importing phase is read as -// export, and a battery on one phase, which is read as balanced. 0, 0 -// without usable phase currents. +// The phases are the meter's signed currents (signedPhasesA). When no move +// fits every phase, the move leaves the worst phase least over, so on a +// meter that gives each phase's direction a move never raises the current +// on the phase it protects. Two limits remain: a meter without phase power +// under an exporting total, where a phase the total does not force to +// export is read as import, and a battery on one phase, which is read as +// balanced. 0, 0 without usable phase currents. func phaseFuseReliefW(p phaseSample, state *State, moveW float64) (importW, exportW float64) { - amps, ok := p.signedA(state) + amps, ok := p.signedPhasesA(state) if !ok { return 0, 0 } 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_fuse_hunt_sitesim_test.go b/go/internal/control/ev_battery_fuse_hunt_sitesim_test.go new file mode 100644 index 000000000..049d0a0a6 --- /dev/null +++ b/go/internal/control/ev_battery_fuse_hunt_sitesim_test.go @@ -0,0 +1,83 @@ +package control + +import ( + "fmt" + "math" + "math/rand/v2" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/sitesim" +) + +// The site rig's s18 at 16 A: a 3 A house, Charge now for a car whose only +// step is 6 A on three phases, and a 6 kW battery charge hold beside it. The +// battery may charge up to the fuse's working limit (fuseLineA) beside the +// car. Its fuse guard must then land it on that line and keep it there; a +// guard that cuts below the line and lets it rise again after its hold makes +// the battery hunt, and where it stands when the house moves is chance. The +// rig judges that move: 0.1 A more house must come off the battery. +func TestBatteryBesideMinimumCarHoldsTheFuseLine(t *testing.T) { + const carW = 6 * 690.0 + start := simStart + p := simPlant(start) + p.Fuse.RatingA, p.HouseW = 16, houseA(3, 3, 3) + p.EVSE.Delay, p.EVSE.ReadbackLag = 2*time.Second, 0 + p.Car.MaxW = carW + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 6000, MaxDischargeW: 6000, CapacityWh: 10000, SoC: .4, Latency: 2 * time.Second} + p.Events = []sitesim.Event{{At: 8 * time.Minute, Kind: sitesim.Change, Do: func(p *sitesim.Plant) { p.HouseW = houseA(3.1, 3.1, 3.1) }}} + lp := simLoadpoint() + lp.MinChargeW, lp.MaxChargeW, lp.AllowedStepsW = carW, carW, []float64{0, carW} + // The rig's car draws a few watts either side of its offer, so the meter + // reads the phases a hundredth of an amp either side of the line. + jitter := rand.New(rand.NewPCG(18, 16)) + s := &simSite{p: p, lp: lp, meterNoise: func() float64 { return (jitter.Float64() - .5) * .02 }, + setup: func(s *simSite) { + s.state.SetBatteryManualHold(BatteryManualHold{PowerW: 6000, ExpiresAt: start.Add(time.Hour)}) + }} + s.boot() + + // The battery each second, from 3 min, once car and battery run. + var battery []float64 + s.run(3 * time.Minute) + for s.p.Now.Before(start.Add(11*time.Minute)) && s.failure == "" { + s.step() + battery = append(battery, s.p.Battery.W) + } + if s.failure != "" { + t.Fatal(s.failure) + } + if s.p.EVSE.DrawW < carW-.1 { + t.Fatalf("car draws %.0f W, want its %.0f W step", s.p.EVSE.DrawW, carW) + } + // Five minutes of a steady house: the battery stays where it is. + steady := battery[:5*60] + lo, hi := math.Inf(1), math.Inf(-1) + for _, w := range steady { + lo, hi = math.Min(lo, w), math.Max(hi, w) + } + if hi-lo > 60 { + var moves []string + last := math.NaN() + for _, c := range s.commands { + if c.device == "battery" && c.at.After(start.Add(3*time.Minute)) && c.at.Before(start.Add(8*time.Minute)) && !(math.Abs(c.w-last) < 50) { + moves, last = append(moves, fmt.Sprintf("%s %.0f", c.at.Sub(start), c.w)), c.w + } + } + t.Errorf("battery hunts under a steady house: %.0f..%.0f W; commands %v", lo, hi, moves) + } + // The rig's judge: the 20 s before the step against the 20 s before 11 min. + mean := func(w []float64) float64 { + var sum float64 + for _, x := range w { + sum += x + } + return sum / float64(len(w)) + } + before, after := mean(battery[5*60-20:5*60]), mean(battery[len(battery)-20:]) + if after > before-40 { + t.Errorf("battery %.0f W before 0.1 A more house, %.0f W after; want at least 40 W less", before, after) + } + t.Logf("battery %.0f..%.0f W steady, %.0f -> %.0f W across the step; peak phase %.2f A", + lo, hi, before, after, s.peakA) +} 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_battery_priority_test.go b/go/internal/control/ev_battery_priority_test.go index d1ca72bc0..5b0e43068 100644 --- a/go/internal/control/ev_battery_priority_test.go +++ b/go/internal/control/ev_battery_priority_test.go @@ -60,10 +60,9 @@ func TestIssue1868BatteryChargeYieldsBeforeTheCar(t *testing.T) { } // Charge mode asks for a raise. No phase reaches the 15.5 A // line, so only the hour's limit binds: the guard cuts the - // 69 W overage and half the margin (172.5 W) to 3758.5 W, - // and the car's headroom then takes the same 69 W again - // (3689.5 W). - if mode == ModeCharge && target.TargetW < 3689 { + // 69 W overage to 3931 W, and the car's headroom then takes + // the same 69 W again (3862 W). + if mode == ModeCharge && target.TargetW < 3861 { t.Fatalf("small overage needlessly stopped battery charging: %+v", target) } } 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 6925686a0..c782d3819 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -3,6 +3,7 @@ package control import ( "log/slog" "math" + "slices" "time" "github.com/srcfl/ftw/go/internal/loadpoint" @@ -178,6 +179,181 @@ 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. + carPhases, carPhase int + // shownA is the current on each phase of the charger's offers that the + // site meter shows, as far as it can tell (seeOffers); unsure while it + // cannot. + shownA [3]float64 + unsure bool + // riseBaseA is the site meter's phase currents when a raise went out + // with no other raise still to show, and riseFromA the most of the + // charger's offers that reading may have shown. riseAheadA measures the + // rise still to come against them; riseSet is false while no raise is + // on its way. + riseBaseA [3]float64 + riseFromA float64 + riseSet bool +} + +// evMoveA is half a charger step. An order landing shows on the site meter +// as every phase moving the same way, by evMoveA or more on their mean and +// no more than a step apart, or as one phase alone moving a whole step +// while the others hold within evMoveA (a car on one phase). Meter noise +// rarely moves that far. +const evMoveA = evStepA / 2 + +// chargerMoveA is the move per phase from one reading to the next when it +// has the shape of an order landing (evMoveA), signed: alikeA when every +// phase moved alike, oneA and its phase when one phase moved alone. +func chargerMoveA(prev, now []float64) (alikeA, oneA float64, phase int) { + var sum, held, moved, d1 float64 + lo, hi, p1 := math.Inf(1), math.Inf(-1), 0 + for i := range now { + d := now[i] - prev[i] + sum, lo, hi = sum+d, math.Min(lo, d), math.Max(hi, d) + switch { + case math.Abs(d) >= 2*evMoveA: + moved, d1, p1 = moved+1, d, i + case math.Abs(d) < evMoveA: + held++ + } + } + n := float64(len(now)) + switch { + case hi-lo <= 2*evMoveA && (lo > 0 && sum/n >= evMoveA || hi < 0 && sum/n <= -evMoveA): + return sum / n, 0, 0 + case moved == 1 && held == n-1: + return 0, d1, p1 + 1 + } + return 0, 0, 0 +} + +// seeOffers reads a new site reading against the last one. With one charger +// in the pool and no change of phases in its log, a move shaped like an +// order landing (chargerMoveA) moves the current the meter shows of the +// charger on each phase (shownA), in a shape the offers can take there. A +// car the loadpoint has seen draw on one phase, or a one-phase order, is +// one level on every phase: every phase dropping alike moves it, and so +// does a move on the car's phase alone where that phase is known. A move on +// one phase while the car's phase is not known may be a house load, so it +// does not count. A three-phase order moves only on every phase alike: +// a phase that moves alone is a house load, or a car not yet seen that +// draws on one phase, and Core cannot tell which. The shown current moves +// only toward the latest offer, and stays within the offers the reading +// may show. While our own battery order moves, a move of every phase alike +// may be the battery's, so no reading counts; after a reading that did not +// count, the shown current is known again once only one offer may show. A +// house load that moves in a charger's shape reads as ours. It can only +// take cover off a cut. The rise a raise has still to show reads the meter +// differently (riseAheadA). +func (s *State) seeOffers(meter *telemetry.DerReading, gridW float64) { + lim := &s.evLimit + amps, _, valid := phaseCurrentsA(meter, s, gridW) + var now [3]float64 + copy(now[:], amps) + prev, read := lim.phasesA, valid && lim.phasesSeen && len(lim.offers) == 1 && len(amps) >= 2 && !s.batteryOrderMoves() + lim.phasesA, lim.phasesSeen = now, valid + var alikeA, oneA float64 + var phase int + if read { + alikeA, oneA, phase = chargerMoveA(prev[:len(amps)], now[:len(amps)]) + } + for id, c := range lim.offers { + if read && c.readable() { + var moveA [3]float64 + switch { + case c.carPhase > 0 || c.log[0].n == 1: + // One car on one phase: one level, moved by a drop of + // every phase alike or by its known phase alone. + m := math.Min(0, alikeA) + if c.carPhase == phase { + m += oneA + } + moveA = [3]float64{m, m, m} + default: + // A three-phase order moves every phase alike. Read as the + // car, a load cycling on the phase over the line took the + // cut's cover off there on each drop and never gave it + // back, and Core cut again and again. + moveA = [3]float64{alikeA, alikeA, alikeA} + } + // The charger moves toward its latest offer, never past it. + latestA := c.log[len(c.log)-1].a + for i, m := range moveA { + if m < 0 && c.shownA[i] > latestA { + c.shownA[i] = math.Max(latestA, c.shownA[i]+m) + } else if m > 0 && c.shownA[i] < latestA { + c.shownA[i] = math.Min(latestA, c.shownA[i]+m) + } + } + } else { + c.unsure = true + } + low, high := c.offerRangeA(lim.readingAt) + for i := range c.shownA { + c.shownA[i] = math.Min(high, math.Max(low, c.shownA[i])) + } + c.unsure = c.unsure && low < high + if ahead := s.riseAheadA(c); low >= high || ahead == [3]float64{} { + c.riseSet = false + } + lim.offers[id] = c + } +} + +// batteryOrderMoves reports whether one of our battery orders of the last +// evSettle moves the site's phases by evMoveA or more. +func (s *State) batteryOrderMoves() bool { + for _, log := range s.batteryCommands { + if low, high, _, ok := log.span(s.now()); ok && high-low >= evMoveA*s.SiteFuseVoltage*float64(s.SiteFusePhases) { + return true + } + } + return false +} + +// readable reports whether the site meter can tell what it shows of the +// charger's offers: every offer the log holds lands on the same phases. +func (c chargerOffers) readable() bool { + for _, o := range c.log { + if o.n != c.log[0].n { + return false + } + } + return len(c.log) > 0 +} + +// offerRangeA is the lowest and highest current per phase among the +// offers a reading taken at `at` may show: the one in force settle before +// it and each one sent since. Before the first logged offer the charger +// counts as before. +func (c chargerOffers) offerRangeA(at time.Time) (low, high float64) { + from := at.Add(-c.settle) + low, high = c.found.a, c.found.a + for _, o := range c.log { + switch { + case o.at.After(at): + case !o.at.After(from): + low, high = o.a, o.a + default: + low, high = math.Min(low, o.a), math.Max(high, o.a) + } + } + return low, high } // pendingLoad is load we ordered that a site reading may not show yet: @@ -203,17 +379,201 @@ func (s *State) evShownW(at time.Time) float64 { var w float64 for _, c := range s.evLimit.offers { if n := len(c.log); n > 0 { - settle := evSettle - if c.declared { - settle = c.settle - } - unshown, _ := c.log.unshown(at, settle) + unshown, _ := c.log.unshown(at, c.batterySettle()) w += math.Max(0, c.log[n-1].w-unshown) } } 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). +func (c chargerOffers) batterySettle() time.Duration { + if c.declared { + return c.settle + } + return evSettle +} + func (s *State) pendingLoad(at time.Time) pendingLoad { var p pendingLoad for _, c := range s.evLimit.offers { @@ -237,7 +597,14 @@ func (s *State) pendingLoad(at time.Time) pendingLoad { // cannot see the hottest phase. A single-phase cut covers on the phase // it lands on, which Core does not know, so with other chargers in the pool // it covers no phase. -func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingLoad, latestW float64) { +// +// Where the meter tells what it shows of the charger (seeOffers), a cut +// covers no more than the meter has still to show on each phase: a cut the +// meter shows covers no overage that comes after it. aheadA is then the +// rise on each phase the meter has still to show of the latest offers +// (riseAheadA), which a raise's own trigger reads (stepEVMeterLimit), and +// rise counts at least that much. Elsewhere aheadA is 0. +func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingLoad, aheadA [3]float64, latestW float64) { lim := &s.evLimit for _, c := range lim.offers { w, a := c.log.toCome(at.Add(-c.settle), at, c.found) @@ -258,21 +625,71 @@ func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingL if latest.n == 1 && len(lim.offers) > 1 { coverA = 0 } + riseA := math.Max(0, a) + phaseCoverA, phaseRiseA, ahead := [3]float64{coverA, coverA, coverA}, [3]float64{riseA, riseA, riseA}, [3]float64{} + if perPhase && lim.phasesSeen && len(lim.offers) == 1 && s.SiteFusePhases >= 2 && c.readable() && !c.unsure { + low, high := c.offerRangeA(at) + ahead = s.riseAheadA(c) + for i := range min(s.SiteFusePhases, len(ahead)) { + toComeA := latest.a - math.Min(high, math.Max(low, c.shownA[i])) + phaseCoverA[i] = math.Min(coverA, math.Max(0, -toComeA)) + phaseRiseA[i] = math.Max(riseA, ahead[i]) + } + } rise.w += math.Max(0, w) cover.w += math.Max(0, math.Min(-w, -runW)) for i := range rise.phaseA { - rise.phaseA[i] += math.Max(0, a) - cover.phaseA[i] += coverA + rise.phaseA[i] += phaseRiseA[i] + cover.phaseA[i] += phaseCoverA[i] + aheadA[i] += ahead[i] } latestW += latest.w } - return rise, cover, latestW + return rise, cover, aheadA, latestW +} + +// riseAheadA is the rise on each phase the site meter has still to show +// of the charger's latest offer: the raise since riseFromA less the meter's +// own rise since the raise went out, within the offers the reading may +// show. A car the loadpoint has seen draw on three phases takes the +// smallest rise across the phases: a house step on some phases does not +// show the raise on the rest. A car on one phase takes its own phase's +// rise. Any other car takes the largest rise: until the loadpoint has seen +// it, it may draw on one phase Core does not know. A landing counts in +// whatever shape it comes, a slow ramp or beside a house load that moves +// in the same reading. A house load that drops while the raise travels +// still reads as raise not shown yet, by as much as it dropped. +func (s *State) riseAheadA(c chargerOffers) (ahead [3]float64) { + lim := &s.evLimit + n := min(s.SiteFusePhases, len(ahead)) + if !c.riseSet || !lim.phasesSeen || n < 2 || len(c.log) == 0 { + return ahead + } + latest := c.log[len(c.log)-1] + rises := make([]float64, n) + for i := range rises { + rises[i] = lim.phasesA[i] - c.riseBaseA[i] + } + riseA, phases := slices.Min(rises), ahead[:n] + switch { + case c.carPhase > 0 && c.carPhase <= n: + riseA, phases = rises[c.carPhase-1], ahead[c.carPhase-1:c.carPhase] + case c.carPhases != 3 || latest.n == 1: + riseA = slices.Max(rises) + } + low, high := c.offerRangeA(lim.readingAt) + toComeA := math.Max(0, latest.a-math.Min(high, math.Max(low, c.riseFromA+riseA))) + for i := range phases { + phases[i] = toComeA + } + return ahead } // 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 @@ -285,7 +702,7 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) continue } c := s.evLimit.offers[point.ID] - c.settle, c.declared = evUnknownSettle, false + 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 @@ -296,10 +713,13 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) sent := sentCommand{at: now, w: w, a: a, n: phases} if n := len(c.log); n == 0 || c.log[n-1].w != sent.w || c.log[n-1].a != sent.a || c.log[n-1].n != sent.n { if n == 0 && !s.evLimit.set { - c.found = sent + c.found, c.shownA = sent, [3]float64{sent.a, sent.a, sent.a} + } else if _, high := c.offerRangeA(now); sent.a > high && !c.riseSet && s.evLimit.phasesSeen { + c.riseBaseA, c.riseFromA, c.riseSet = s.evLimit.phasesA, high, true } c.log = c.log.keep(sent, read.Add(-c.settle)) } + c.noteDraw(store, now) offers[point.ID] = c } s.evLimit.offers = offers @@ -343,13 +763,17 @@ type evMeterLimit struct { offers map[string]chargerOffers // each plugged charger's, by loadpoint phaseRoomA float64 // below the working limit on the busiest phase at readingAt; an idle pool's as a start reads it phaseRoomKnown bool // the meter reports phase currents - overSince time.Time // first reading of a run over the hour's import limit - fuseOverAt time.Time // first reading of a run with a phase, or the total, over the fuse's working limit - fuseCutAt time.Time // the first cut of that run - fuseUnderAt time.Time // first reading of a stretch under that limit - overOffers map[string]float64 // each charger's offer when that run began - idleWh float64 // import since roomSince, for the ceiling's start test - byImport bool // the hour's import limit, not the fuse, left less room at readingAt + phasesA [3]float64 // the phase currents at readingAt, when phasesSeen + phasesSeen bool + cutForA [3]float64 // each phase's highest reading that a cut still on its way was sent on, when cutForSet + cutForSet bool + overSince time.Time // first reading of a run over the hour's import limit + fuseOverAt time.Time // first reading of a run with a phase, or the total, over the fuse's working limit + fuseCutAt time.Time // the first cut of that run + fuseUnderAt time.Time // first reading of a stretch under that limit + overOffers map[string]float64 // each charger's offer when that run began + idleWh float64 // import since roomSince, for the ceiling's start test + byImport bool // the hour's import limit, not the fuse, left less room at readingAt batteryYield evBatteryYield } @@ -372,7 +796,10 @@ type evPool struct { // A cut counts our orders both ways: a raise on its way adds to the overage, // and a cut on its way covers the overage it was sent for until its charger // can have applied it, within its run over the limit and while the phases -// stay at or below fuseHourFactor (unshownOrders). +// stay at or below fuseHourFactor (unshownOrders), but never once the meter +// shows it. Our own rise on its way cuts before the meter shows it where it +// would read above fuseInstantFactor, or over the working limit on a phase +// whose heat budget is spent. // EVFusePhaseRoomA gives the same room per phase to the split between cars. // An idle pool starts only after the meter has kept showing // room for its minimum below the working limit. Each reading acts once, so a silent @@ -408,6 +835,7 @@ func (s *State) stepEVMeterLimit(store *telemetry.Store, meter *telemetry.DerRea first, prevAt := !lim.set, lim.readingAt gap := first || at.Sub(prevAt) > evSettle lim.readingAt = at + s.seeOffers(meter, gridW) prev := math.Inf(1) if !first { prev = lim.capW @@ -460,20 +888,51 @@ func (s *State) stepEVMeterLimit(store *telemetry.Store, meter *telemetry.DerRea case at.Sub(lim.fuseUnderAt) >= evSettle: lim.fuseOverAt, lim.fuseCutAt = time.Time{}, time.Time{} } - rise, cover, latestW := s.unshownOrders(at, r.perPhase) - fuseCutW := r.fuseOverW + rise.w - cover.w - if r.perPhase { + rise, cover, aheadA, latestW := s.unshownOrders(at, r.perPhase) + fuseCutW, over := r.fuseOverW+rise.w-cover.w, r.fuseOverW > 0 + if r.perPhase && lim.phasesSeen { // A single-phase order may land on any phase, so the cut takes the - // most a rise adds to a phase and the least a cut takes off one. - // Past the current a gG fuse carries for an hour, nothing on its - // way holds back a cut. - coverA := min(cover.phaseA[0], cover.phaseA[1], cover.phaseA[2]) + // most a rise adds to a phase. A cut covers each phase by what it + // takes off there, the same on every phase unless the meter tells + // the phases apart (unshownOrders). Past the current a gG fuse + // carries for an hour, nothing on its way holds back a cut. + coverA := cover.phaseA if s.fuseHeat.worstA() > fuseHourFactor*s.SiteFuseAmps { - coverA = 0 + coverA = [3]float64{} } - fuseCutW = (r.fuseOverA + max(rise.phaseA[0], rise.phaseA[1], rise.phaseA[2]) - coverA) * s.SiteFuseVoltage * pool.phases + if slices.Max(coverA[:s.SiteFusePhases]) <= 0 { + lim.cutForSet = false + } + riseA := max(rise.phaseA[0], rise.phaseA[1], rise.phaseA[2]) + lineA, instantA, cutA := s.fuseLineA(), fuseInstantFactor*s.SiteFuseAmps, 0.0 + for i, a := range lim.phasesA[:s.SiteFusePhases] { + // Cuts on their way cover a phase up to the reading they were + // sent for. A phase that has risen since by more than noise + // carries an overage that came later: they cover only the + // part of it that phase had then. + phaseCoverA, aheadA := coverA[i], aheadA[i] + if lim.cutForSet && a > lim.cutForA[i]+evMoveA { + phaseCoverA = math.Min(phaseCoverA, math.Max(0, lim.cutForA[i]-lineA)) + } + // Where the heat budget is spent, the overage the meter shows + // comes off with our rise on its way. Elsewhere our own rise on + // its way is judged as the reading it will make: above + // fuseInstantFactor, or over the line on a phase whose heat + // budget is spent, it comes off now, as far as it takes the + // phase over the line. Under that, the heat budget judges it + // once the meter shows it, as any other reading. + hot, needA := s.fuseHeat.sq[i] > lineA*lineA, 0.0 + if landA := a + aheadA - phaseCoverA; landA > instantA || hot && landA > lineA { + needA = math.Min(aheadA-phaseCoverA, landA-lineA) + } + if a > lineA && (hot || a > instantA) { + needA = math.Max(needA, a+riseA-phaseCoverA-lineA) + } + cutA = math.Max(cutA, needA) + } + fuseCutW, over = cutA*s.SiteFuseVoltage*pool.phases, over || cutA > 0 } - over, capW, byImport := r.fuseOverW > 0, math.Inf(1), false + capW, byImport := math.Inf(1), false if over { capW = latestW if fuseCutW > 0 { @@ -514,13 +973,21 @@ func (s *State) stepEVMeterLimit(store *telemetry.Store, meter *telemetry.DerRea lim.capW, lim.roomSince = math.Min(prev, math.Max(0, capW)), time.Time{} if lim.capW < prev { lim.cutAt = now + if lim.phasesSeen { + for i, a := range lim.phasesA { + if !lim.cutForSet || a > lim.cutForA[i] { + lim.cutForA[i] = a + } + } + lim.cutForSet = true + } if r.fuseOverW > 0 && lim.fuseCutAt.IsZero() { lim.fuseCutAt = now } slog.Info("EV hard limit: site meter over limit", "grid_w", gridW, "worst_phase_a", r.worstA, "fuse_heat_a", s.fuseHeat.worstA(), "import_limit_w", s.effectiveImportCeilingW(fuseMaxW), "overage_w", r.fuseOverW, "import_overage_w", math.Max(0, -r.limitW), - "unshown_rise_w", rise.w, "unshown_cut_w", cover.w, "offer_w", latestW, "cap_w", lim.capW) + "unshown_rise_w", rise.w, "unshown_cut_w", cover.w, "rise_on_its_way_a", aheadA, "offer_w", latestW, "cap_w", lim.capW) } return } diff --git a/go/internal/control/ev_fuse_cut_netting_test.go b/go/internal/control/ev_fuse_cut_netting_test.go index 88dfb337b..fdc60ce6a 100644 --- a/go/internal/control/ev_fuse_cut_netting_test.go +++ b/go/internal/control/ev_fuse_cut_netting_test.go @@ -70,6 +70,9 @@ type cutRig struct { offerW float64 phases int // the phases the offer lands on snap func(w float64) float64 // nil: whole amps on three phases + // carPhases is the loadpoint's verdict on the car: 0 unknown, 1 one + // phase, 3 three phases. + carPhases int } func newCutRig(t *testing.T, offerW, declaredS float64) *cutRig { @@ -100,8 +103,12 @@ func (r *cutRig) deliver(gridW float64, data []byte) float64 { r.now = r.now.Add(time.Second) r.store.Update("meter", telemetry.DerMeter, gridW, nil, data) r.store.Get("meter", telemetry.DerMeter).UpdatedAt = r.now - _, _, points, configs := evMeterBudgetFixture(r.t, 0, nil, r.offerW, "3p") - points[0].CommandedPhases = r.phases + mode := "3p" + if r.phases == 1 { + mode = "1p" + } + _, _, points, configs := evMeterBudgetFixture(r.t, 0, nil, r.offerW, mode) + points[0].CommandedPhases, points[0].CarPhases = r.phases, r.carPhases capW, _ := EVFuseBudget(r.store, r.state, points, configs, 17250) if capW < r.offerW { r.offerW = math.Floor(capW/690) * 690 @@ -194,19 +201,20 @@ func TestEVFuseSevereOverageCutsWhateverIsOnItsWay(t *testing.T) { } } -// A cut covers only within the run over the limit it was sent in. A fast +// A cut the meter shows covers no overage that comes after it. A fast // charger that declares nothing applies a cut in 5 s, but counts as slow -// for 180 s. Once the meter has shown L1 under the limit for evSettle, a -// second step on L1 is a new run, and the landed cut covers none of it. -// Within evSettle of the landing it is the same run, and the landed cut -// covers the step until the run's first cut has had 180 s: that is the -// cost of trusting a cut on its way, at under 1.25 x In. +// for 180 s. The meter shows the cut land as every phase falling 7 A. A +// second step on L1, 30 s later in a new run or 10 s later in the same +// one, then cuts once L1's heat budget is spent, about 22-28 s at 29 A on +// the 24 A line. Before, a landed cut still covered the step in the same +// run until the run's first cut had had 180 s: L1 sat at 29 A for 2 min +// 55 s (#1914, TestSiteSimRandomHouse seed 1 at margin 0). func TestEVFuseLandedCutCoversOnlyItsRun(t *testing.T) { for _, tc := range []struct { after time.Duration // the second step after the first cut maxOver time.Duration }{ - {30 * time.Second, 30 * time.Second}, {10 * time.Second, evUnknownSettle}, + {30 * time.Second, 30 * time.Second}, {10 * time.Second, 30 * time.Second}, } { t.Run(fmt.Sprintf("step_after_%v", tc.after), func(t *testing.T) { s := fuseHeatSite(t) @@ -354,3 +362,315 @@ func TestEVFuseRestartTakesFoundOfferAsShown(t *testing.T) { t.Fatalf("cuts %v while the first travels", cuts) } } + +// A cut whose landing the meter cannot tell from an outside step covers a +// phase only up to the reading it was sent on. A house step takes L2 to +// 26 A; once its heat budget is spent, Core cuts 2 A. The second the cut +// lands, the house adds 8 A on L3, so the meter shows L1 and L2 falling +// 2 A and L3 rising 6 A: no charger's shape. L3 had no overage when the +// cut went out, so the cut covers none of L3's 2 A over the 24 A line, and +// L3 cuts once its own heat budget is spent (about 61 s from 20 A). Before, +// the landed cut covered L3 until the charger's 180 s had passed +// (TestSiteSimRandomHouse seed 68 at margin 0). +func TestEVFuseCutCoversOnlyThePhaseOverageItWasSentFor(t *testing.T) { + s := fuseHeatSite(t) + s.run(time.Minute) + s.addHouseA(0, 6, 0) // L2 26 A + if took, offer := s.firstCut(2 * time.Minute); took < 0 || offer != 8970 { + t.Fatalf("L2 cut after %v to %.0f W, want 8,970 W", took, offer) + } + s.run(s.chargerDelay - time.Second) + s.addHouseA(0, 0, 8) // lands with the cut: L3 26 A + var over time.Duration + for range 4 * 60 { + s.step() + if s.phaseA()[2] > 24 { + over += time.Second + } + } + if a := s.phaseA(); over > 70*time.Second || a[2] > 24 { + t.Fatalf("L3 over the 24 A line for %v, phases %.1f A after; cuts %v", over, a, offerWatts(s.cutsSince(time.Time{}))) + } + t.Logf("L3 over the working limit for %v", over) +} + +// C4 (27 Oct triage, seeds 207537 and 207539): our own rise on its way that +// no longer fits cuts before the meter shows it. On a 16 A fuse with no +// margin, a charger that declares 40 s takes the car from 6 A to 16 A with +// L1 at 6 A: the step fits under the line. 30 s later the house adds 9.7 A +// on L1 and 3 A on L2 before the raise lands. For a car the loadpoint has +// seen draw on three phases, L1 reads 15.7 A, under the line, but the raise +// would take it to 25.7 A, over 1.2 x In: on that reading the raise comes +// off as far as it takes L1 over the line, to 6 A in whole amps. Before, +// Core waited for the meter to show it. +func TestEVFuseRaiseOnItsWayThatNoLongerFitsCutsAtOnce(t *testing.T) { + r := newCutRig(t, 4140, 40) + r.state.SiteFuseAmps, r.carPhases = 16, 3 + for range 60 { + r.read([3]float64{6, 6, 6}) + } + r.offerW = 11040 // 6 -> 16 A + for i := range 30 { + if r.read([3]float64{6, 6, 6}); r.offerW != 11040 { + t.Fatalf("raise cut to %.0f W after %d s with L1 at 6 A", r.offerW, i+1) + } + } + if r.read([3]float64{15.7, 9, 6}); r.offerW != 4140 { + t.Fatalf("offer %.0f W with L1 at 15.7 A and 10 A on its way, want 4,140 W", r.offerW) + } +} + +// The C4 case as the triage wrote it, with only L1 moving, for a car the +// loadpoint has seen draw on three phases. A house step on L1 alone is then +// not the raise landing: the raise would take L1 to 25.7 A, over 1.2 x In, +// so it comes off on that reading. For a car not yet seen, a step on one +// phase may be a one-phase car taking the raise; the meter judges it then. +func TestEVFuseRaiseOnItsWayCutsForACarSeenOnThreePhases(t *testing.T) { + r := newCutRig(t, 4140, 40) + r.state.SiteFuseAmps, r.carPhases = 16, 3 + for range 60 { + r.read([3]float64{6, 6, 6}) + } + r.offerW = 11040 // 6 -> 16 A + for range 30 { + r.read([3]float64{6, 6, 6}) + } + if r.read([3]float64{15.7, 6, 6}); r.offerW > 4140 { + t.Fatalf("offer %.0f W with L1 at 15.7 A and 10 A on its way, want 4,140 W or less", r.offerW) + } +} + +// A house load that moves on one phase alone is not a three-phase cut +// landing. While a cut travels to a charger that declares 40 s, such a move +// must not take the cut's cover off the phase it was sent for. Before, a +// 3 A load switching off on L2 read as the cut landing: Core cut again to +// 7,590 W and left L1 2 A under the line for over 2 min. A 1.5 A load +// cycling on L2 gave three cuts and two rises; one cut is enough. +func TestEVFuseOnePhaseHouseMoveIsNotTheCutLanding(t *testing.T) { + t.Run("drop_on_L2", func(t *testing.T) { + s := fuseHeatSite(t) + s.chargerDelay, s.chargerMaxAgeS = 40*time.Second, 40 + s.addHouseA(0, 3, 0) // L2 23 A + s.run(2 * time.Minute) + stepAt := s.now + s.addHouseA(6, 0, 0) // L1 26 A + if took, offer := s.firstCut(2 * time.Minute); took < 0 || offer != 8970 { + t.Fatalf("first cut after %v to %.0f W, want 8,970 W", took, offer) + } + s.run(10 * time.Second) + s.addHouseA(0, -3, 0) + s.run(5 * time.Minute) + if cuts := offerWatts(s.cutsSince(stepAt)); len(cuts) != 1 || s.offer() != 8970 { + t.Fatalf("cuts %v, offer %.0f W after; want one cut to 8,970 W", cuts, s.offer()) + } + }) + t.Run("cycling_on_L2", func(t *testing.T) { + s := fuseHeatSite(t) + s.chargerDelay, s.chargerMaxAgeS = 40*time.Second, 40 + s.run(time.Minute) + stepAt := s.now + s.addHouseA(5, 0, 0) // L1 25 A on the 24 A line + for i := range 20 * 60 { + if i%7 == 0 { + s.addHouseA(0, []float64{1.5, -1.5}[i/7%2], 0) + } + s.step() + } + var offers []float64 + for _, o := range s.offersSince(stepAt) { + if n := len(offers); n == 0 || o.w != offers[n-1] { + offers = append(offers, o.w) + } + } + if len(offers) != 2 || offers[1] != 9660 { + t.Fatalf("offers %v, want one cut to 9,660 W and no rise", offers) + } + }) +} + +// While our own battery order moves the phases, a move of every phase +// alike may be the battery's, so it is not read as the car's cut landing. +// A 3 A cut travels to a charger that declares 40 s. A battery order of +// 1 A a phase discharge lands and goes back: L1 reads 28, 27, then 28 A +// again. Read as the cut landing, the 1 A drop took a third of the cut's +// cover, and Core cut a second time, to 8,280 W, for an overage the first +// cut already covers. +func TestEVFuseBatteryOrderMoveIsNotTheCutLanding(t *testing.T) { + r := newCutRig(t, 11040, 40) // 16 A + for range 120 { + r.read([3]float64{20, 15, 15}) + } + for i := 0; i < 120 && r.offerW == 11040; i++ { + r.read([3]float64{28, 15, 15}) // 3 A over the 25 A line + } + if r.offerW != 8970 { + t.Fatalf("heat cut to %.0f W, want 8,970 W", r.offerW) + } + r.state.batteryCommands = map[string]commandLog{"bat": commandLog{}.add(r.now.Add(-time.Minute), 0).add(r.now, -690)} + for range 3 { + r.read([3]float64{27, 14, 14}) + } + r.state.batteryCommands["bat"] = r.state.batteryCommands["bat"].add(r.now, 0) + for range 8 { + if r.read([3]float64{28, 15, 15}); r.offerW != 8970 { + t.Fatalf("offer %.0f W after our battery order came and went, want the one cut to 8,970 W", r.offerW) + } + } +} + +// A raise that fits lands, and no phase passes the line, so nothing cuts. +// 16 A fuse, charger that declares 40 s, car from 6 A to 14 A while every +// phase has 8 A of room. Before, C4 counted a landing only when the move +// had a charger's shape. A house load on L2 that switched off in the +// landing reading, or a car that ramped 0.4 A a reading, left the whole +// raise counted as on its way on top of a reading that already held it: +// 14 + 8 A read as over 1.2 x In, and Core cut to 5,520 or 6,900 W. +func TestEVFuseRaiseThatLandedIsNotCountedTwice(t *testing.T) { + for _, tc := range []struct { + name string + l2 float64 // L2 before the landing; it reads 14 A after + carPhases int + }{ + {"clean", 6, 0}, + {"house_off_on_L2_1.2A", 7.2, 0}, + {"house_off_on_L2_2A", 8, 0}, + {"house_off_on_L2_2A_car_seen_on_three_phases", 8, 3}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newCutRig(t, 4140, 40) + r.state.SiteFuseAmps, r.carPhases = 16, tc.carPhases + for range 60 { + r.read([3]float64{6, tc.l2, 6}) + } + r.offerW = 9660 // 6 -> 14 A + for range 10 { + r.read([3]float64{6, tc.l2, 6}) + } + for i := range 120 { + if r.read([3]float64{14, 14, 14}); r.offerW != 9660 { + t.Fatalf("raise that landed at 14/14/14 A cut to %.0f W after %d s", r.offerW, i+1) + } + } + }) + } + for _, carPhases := range []int{0, 3} { + t.Run(fmt.Sprintf("slow_ramp_car_phases_%d", carPhases), func(t *testing.T) { + r := newCutRig(t, 4140, 40) + r.state.SiteFuseAmps, r.carPhases = 16, carPhases + for range 60 { + r.read([3]float64{6, 6, 6}) + } + r.offerW = 9660 + r.read([3]float64{6, 6, 6}) + a := 6.0 + for i := range 120 { + a = math.Min(14, a+0.4) + if r.read([3]float64{a, a, a}); r.offerW != 9660 { + t.Fatalf("raise ramping to 14 A cut to %.0f W at %.1f A after %d s", r.offerW, a, i+1) + } + } + }) + } +} + +// A one-phase car the loadpoint has not seen yet takes a three-phase raise +// on L1 while the house drifts on L2. 25 A fuse, 40 s charger: the car goes +// from 6 to 16 A, L1 lands at 24 A, and no phase is ever over the line. +// Before, the one-phase landing counted only while L2 stayed within 0.5 A +// of its reading when the raise went out, over the whole response time: +// 0.6 A of drift made the raise count as still to come, L1 read 34 A, and +// Core cut to 4,830 W one second after the landing (field matrix with +// 0.7 A on L2 every 17 s: car1p cut 11,100 -> 0 W). A house load of a +// step or more on L2 in the landing reading hid it too. Until the loadpoint +// has seen the car on three phases, the largest rise counts as the landing. +func TestEVFuseUnseenOnePhaseCarLandsBesideDrift(t *testing.T) { + for _, tc := range []struct { + name string + before, landA float64 // L2 drift before the landing, and in its reading + }{ + {"no_drift", 0, 0}, + {"drift_0.6A_before", 0.6, 0}, + {"drift_1A_before", 1, 0}, + {"drift_-0.6A_before", -0.6, 0}, + {"drift_0.7A_in_the_landing_reading", 0, 0.7}, + {"drift_-0.7A_in_the_landing_reading", 0, -0.7}, + {"house_1.2A_on_L2_in_the_landing_reading", 0, 1.2}, + {"house_-1.2A_on_L2_in_the_landing_reading", 0, -1.2}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newCutRig(t, 4140, 40) // 6 A a phase ordered + for range 120 { + r.read([3]float64{14, 5, 5}) // car 6 A on L1, house 8 A + } + r.offerW = 11040 // 16 A + r.read([3]float64{14, 5, 5}) + for range 10 { + r.read([3]float64{14, 5 + tc.before, 5}) + } + l2 := 5 + tc.before + tc.landA + for i := range 120 { + if r.read([3]float64{24, l2, 5}); r.offerW != 11040 { + t.Fatalf("one-phase car landed at 24 A on a 25 A fuse, L2 %.1f A: cut to %.0f W after %d s", l2, r.offerW, i+1) + } + } + }) + } +} + +// A one-phase load that cycles on a phase is not a three-phase cut landing, +// even for a car not yet seen on three phases. The house falls from 18 to +// 10 A a phase, so the car rises toward the line, while 1.5 A on L2 +// toggles. Before, each drop of the load read as the cut landing on L2 and +// took its cover off there; the load's return could not give it back, so +// Core cut again and again: 9,660 down to 4,830 W and up, 21 cuts in 20 min +// with a 40 s charger. #1913 cut once. +func TestEVFuseLoadCyclingOnTheOverPhaseDoesNotHunt(t *testing.T) { + for _, period := range []int{3, 11} { + t.Run(fmt.Sprintf("every_%ds", period), func(t *testing.T) { + s := newEVMeterSite(t, 0, 18*690) + s.chargerDelay, s.chargerMaxAgeS = 40*time.Second, 40 + s.run(5 * time.Minute) + s.setHouse(10 * 690) + stepAt := s.now + loadA := 1.5 + for i := range 20 * 60 { + if i%period == 0 { + s.addHouseA(0, loadA, 0) + loadA = -loadA + } + s.step() + } + if cuts := offerWatts(s.cutsSince(stepAt)); len(cuts) > 1 { + t.Fatalf("cuts %v; one is enough", cuts) + } + }) + } +} + +// A one-phase order's car phase is not known, so a house load that moves +// alone on another phase is not its cut landing. 25 A fuse, a charger that +// declares 40 s, 16 A ordered on one phase: L1 reads 28 A and the heat cut +// takes it to 13 A. While that cut travels, a 3 A load on L2 switches off. +// Before, that move took 3 A of cover off the cut still on its way, and +// Core cut again for an overage the first cut already covered. +func TestEVFuseOnePhaseOrderHouseMoveIsNotTheCutLanding(t *testing.T) { + r := newCutRig(t, 3680, 40) // 16 A on one phase + r.phases = 1 + r.snap = func(w float64) float64 { return math.Floor(w/230) * 230 } + for range 120 { + r.read([3]float64{20, 15, 12}) + } + for i := 0; i < 120 && r.offerW == 3680; i++ { + r.read([3]float64{28, 15, 12}) // 3 A over the 25 A line + } + first := r.offerW + if first >= 3680 { + t.Fatalf("no heat cut with L1 3 A over the line") + } + r.read([3]float64{28, 15, 12}) + for i := range 20 { + if r.read([3]float64{28, 12, 12}); r.offerW != first { + t.Fatalf("offer %.0f W %d s after a 3 A house load on L2 switched off, want the one cut to %.0f W", r.offerW, i+1, first) + } + } +} diff --git a/go/internal/control/ev_fuse_offer_logged_late_test.go b/go/internal/control/ev_fuse_offer_logged_late_test.go new file mode 100644 index 000000000..d8674a3b2 --- /dev/null +++ b/go/internal/control/ev_fuse_offer_logged_late_test.go @@ -0,0 +1,60 @@ +package control + +import ( + "encoding/json" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Site rig s1 and s15 (28 Oct 2026, c9b52c36): Charge now from idle on a +// 25 A or 16 A fuse, 3 A of house per phase covered by the battery, so every phase +// reads 0 A. The charger answers in about a second. Core logs an offer when +// the next control tick reads the loadpoint's command, 2 s after it went +// out, and that tick's meter reading came in 7 ms before it. The reading +// already shows the car at 15.9 A, but it is older than the logged offer. +// The landing then read as a raise not shown yet: 15.9 + 16 A passed +// 1.2 x In, and Core cut 11,040 W to 5,520 W with 9 A of room on every +// phase ("EV hard limit: site meter over limit ... worst_phase_a=15.9 +// rise_on_its_way_a=[16 16 16] cap_w=5520"). The car then crept back 1 A +// at a time and held 9,660 W on fuse_limit. Since #1923's ab027102 the +// rise still to come reads the meter's own rise since the raise went out. +func TestEVFuseRaiseShownBeforeItsOfferIsLogged(t *testing.T) { + for _, tc := range []struct { + name string + fuseA float64 + phases int + offerW float64 + landedA [3]float64 + }{ + {"s1_25A_fuse", 25, 3, 11040, [3]float64{15.9, 15.9, 15.9}}, + {"s1_16A_fuse", 16, 3, 10350, [3]float64{15, 15, 15}}, + {"s15_one_phase_charger_on_L1", 25, 1, 3680, [3]float64{16, 0, 0}}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newCutRig(t, 0, 0) + r.phases, r.state.SiteFuseAmps = tc.phases, tc.fuseA + // Each reading comes in 7 ms before the tick that reads it. + read := func(phaseA [3]float64) { + t.Helper() + data, _ := json.Marshal(map[string]any{"l1_a": phaseA[0], "l2_a": phaseA[1], "l3_a": phaseA[2]}) + r.now = r.now.Add(2 * time.Second) + r.store.Update("meter", telemetry.DerMeter, (phaseA[0]+phaseA[1]+phaseA[2])*230, nil, data) + r.store.Get("meter", telemetry.DerMeter).UpdatedAt = r.now.Add(-7 * time.Millisecond) + _, _, points, configs := evMeterBudgetFixture(t, 0, nil, r.offerW, "3p") + points[0].CommandedPhases = r.phases + if capW, _ := EVFuseBudget(r.store, r.state, points, configs, tc.fuseA*690); capW < r.offerW { + t.Fatalf("offer %.0f W cut to %.0f W with the car at %v A on a %.0f A fuse", r.offerW, capW, phaseA, tc.fuseA) + } + } + for range 15 { + read([3]float64{}) + } + r.offerW = tc.offerW // sent at the last tick, logged at this one + for range 60 { + read(tc.landedA) + } + }) + } +} 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/fuse_saver_test.go b/go/internal/control/fuse_saver_test.go index 006bc35e7..fac8238ae 100644 --- a/go/internal/control/fuse_saver_test.go +++ b/go/internal/control/fuse_saver_test.go @@ -474,9 +474,8 @@ func TestPerPhaseClampHonorsSafetyMargin(t *testing.T) { } // Aggregate effFuseW = 11040 − 1.0×230×3 = 10350. Predicted = // 5000 + 4000 − 0 = 9000 → no aggregate overage. Per-phase wins. - // Buffer = half-margin = 0.5×1×230×3 = 345 W (post-fix headroom). - // Charge 4000 − 550 − 345 = 3105. - expected := 3105.0 + // Charge 4000 − 550 = 3450, which puts L1 on the 15 A line. + expected := 3450.0 if math.Abs(guarded[0].TargetW-expected) > 1 { t.Errorf("charge after safety-margin clamp = %.0f W, want %.0f W", guarded[0].TargetW, expected) @@ -558,10 +557,10 @@ func TestPerPhaseReliefMatchesSinglePhaseSiteOnExport(t *testing.T) { if !guarded[0].Clamped { t.Fatalf("export-side per-phase overage must clamp the discharge target") } - // Discharge 5000 − overage 1955 − buffer (half the 115 W margin) - // = 2987.5 W. Demanding 1955 × 3 leaves nothing: the discharge is - // zeroed and the site swings to 520 W of import. - expected := -2987.5 + // Discharge 5000 − overage 1955 = 3045 W. Demanding 1955 × 3 leaves + // nothing: the discharge is zeroed and the site swings to 520 W of + // import. + expected := -3045.0 if math.Abs(guarded[0].TargetW-expected) > 1 { t.Errorf("discharge after 1Φ export clamp = %.1f W, want %.1f W", guarded[0].TargetW, expected) @@ -653,33 +652,63 @@ func TestFuseSaverFiresInIdleMode(t *testing.T) { } } -// Operator-report 2026-04-30: with safety_margin_a=1 (threshold = 15 A) -// the system stabilised with phase amps oscillating between 15.0 and -// 15.8 A — fuse guard reactively scaled targets just enough to clear -// the trip, then the planner re-ramped, then it re-tripped. Half-margin -// buffer keeps post-clamp aggregate below the threshold by an explicit -// margin so the next tick has room before re-tripping. -func TestFuseGuardLeavesHeadroomBelowThreshold(t *testing.T) { - store, state, _ := setupPerPhase(-7000, 12, 18, 8, 0.6, 10000) - state.SiteFuseSafetyA = 1.0 - state.SiteFusePhases = 3 - soc := 0.6 - store.Update("bat", telemetry.DerBattery, -5000, &soc, nil) - store.DriverHealthMut("bat").RecordSuccess() +// A guard cut lands the worst phase on the line, In − safety_margin_a, and +// stays there. It takes no extra headroom below the line: the prediction +// from held targets and the 30 s hold keep the next dispatch from +// re-ramping through it. 16 A fuse, margin 1.2 A, so the line is 14.8 A. +// The battery follows each order; the loop runs 60 ticks of 5 s, so the +// hold expires and fires again several times. +func TestFuseGuardCutLandsOnTheLine(t *testing.T) { + const marginA, lineA = 1.2, 14.8 + for _, tc := range []struct { + name string + houseA [3]float64 // signed: + import, − export + requestW float64 + roomA float64 // worst phase's room to the line before the battery moves + }{ + {"charge on import", [3]float64{13, 7, 5}, 4000, lineA - 13}, + {"discharge on export", [3]float64{-8, -12, -5}, -5000, lineA - 12}, + } { + t.Run(tc.name, func(t *testing.T) { + st := NewState(0, 50, "meter") + st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases, st.SiteFuseSafetyA = 16, 230, 3, marginA + st.DriverLimits = map[string]PowerLimits{"bat": {MaxChargeW: 10000, MaxDischargeW: 10000}} + now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + st.clock = func() time.Time { return now } + store := telemetry.NewStore() + wantW := math.Copysign(tc.roomA*690, tc.requestW) + batW := 0.0 + for tick := 0; tick < 60; tick++ { + a := tc.houseA + data := map[string]any{} + for i := range a { + a[i] += batW / 690 + data[fmt.Sprintf("l%d_a", i+1)] = a[i] + data[fmt.Sprintf("l%d_w", i+1)] = a[i] * 230 + } + raw, _ := json.Marshal(data) + store.Update("meter", telemetry.DerMeter, (a[0]+a[1]+a[2])*230, nil, raw) + store.DriverHealthMut("meter").RecordSuccess() + soc := 0.5 + store.Update("bat", telemetry.DerBattery, batW, &soc, nil) + store.DriverHealthMut("bat").RecordSuccess() + + worstA := 0.0 + for _, x := range a { + worstA = math.Max(worstA, math.Abs(x)) + } + if tick > 0 && math.Abs(worstA-lineA) > 0.01 { + t.Fatalf("tick %d: worst phase %.3f A, want %.1f A (the line, not below it)", tick, worstA, lineA) + } - out := applyFuseGuard( - []DispatchTarget{{Driver: "bat", TargetW: -5000}}, - store, state, 11040) - if !out[0].Clamped { - t.Fatalf("expected per-phase export clamp") - } - // perPhase = (18−15) × 230 × 3 = 2070 W - // half-margin buffer = 0.5 × 1A × 230V × 3p = 345 W - // totalDischarge = 5000 → newTotal = 5000 − 2070 − 345 = 2585 - expected := -2585.0 - if math.Abs(out[0].TargetW-expected) > 1 { - t.Errorf("post-clamp target = %.0f W, want %.0f W (overage + half-margin)", - out[0].TargetW, expected) + out := applyFuseGuard([]DispatchTarget{{Driver: "bat", TargetW: tc.requestW}}, store, st, 16*230*3) + if math.Abs(out[0].TargetW-wantW) > 1 { + t.Fatalf("tick %d: target %.1f W, want %.1f W (cut by the overage only)", tick, out[0].TargetW, wantW) + } + batW = out[0].TargetW + now = now.Add(5 * time.Second) + } + }) } } 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_property_test.go b/go/internal/control/sitesim_property_test.go index 519a18fbc..b498e485e 100644 --- a/go/internal/control/sitesim_property_test.go +++ b/go/internal/control/sitesim_property_test.go @@ -13,9 +13,8 @@ import ( // randomHouseKnown names the seeds that break an invariant on master, by // kind, and the finding. A listed kind that passes fails the test until its -// line goes. The fuse's own kind is never listed. Seed 2 no longer reaches -// the case where a landed cut still covers the next overage; that cost is -// pinned in TestEVFuseLandedCutCoversOnlyItsRun. +// line goes. The fuse's own kind is never listed. A landed cut covers no +// later overage (TestEVFuseLandedCutCoversOnlyItsRun). var randomHouseKnown = map[uint64]map[string]string{} // Core keeps a random house under the fuse. For each fixed seed the house diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index caa84aa04..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()) @@ -507,7 +525,7 @@ func fieldNightLoadpoint(c *loadpoint.Config) { // carPhasesUnknown checks that the session's car was never marked one-phase. func carPhasesUnknown(s *simSite) error { - if st, _ := s.mgr.State(s.lp.ID); st.CarPhases != 0 { + if st, _ := s.mgr.State(s.lp.ID); st.CarPhases == 1 { return fmt.Errorf("car marked one-phase on L%d; car draws %.0f W on %d phases", st.CarPhase, s.p.EVSE.DrawW, s.p.EVSE.DrawPhases) } return nil @@ -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/early_exit_protections.json b/go/internal/control/testdata/golden/early_exit_protections.json index e1147757e..c8e9929d2 100644 --- a/go/internal/control/testdata/golden/early_exit_protections.json +++ b/go/internal/control/testdata/golden/early_exit_protections.json @@ -775,7 +775,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 2300, + "per_phase_overage_w": 2070, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, 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/seeded_fuse.json b/go/internal/control/testdata/golden/seeded_fuse.json index 41ca9a8c4..5f301eb6b 100644 --- a/go/internal/control/testdata/golden/seeded_fuse.json +++ b/go/internal/control/testdata/golden/seeded_fuse.json @@ -6496,17 +6496,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1450.8333333333328, + "target_w": -1508.3333333333326, "clamped": true }, { "driver": "pixii", - "target_w": -1450.8333333333328, + "target_w": -1508.3333333333326, "clamped": true }, { "driver": "sungrow", - "target_w": -1450.8333333333328, + "target_w": -1508.3333333333326, "clamped": true } ], @@ -6524,7 +6524,7 @@ "fuse_ev_max_w": 0, "fuse_hold_latched": true, "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 4352.499999999998, + "fuse_hold_max_discharge_w": 4524.999999999998, "per_phase_overage_w": 0, "effective_import_ceiling_w": 16905, "effective_export_ceiling_w": 16905, @@ -6533,9 +6533,9 @@ }, "site_totals": { "battery_current_w": -4249, - "battery_target_sum_w": -4352.499999999998, + "battery_target_sum_w": -4524.999999999998, "raw_grid_w": -14716, - "projected_grid_w": -14819.499999999998 + "projected_grid_w": -14991.999999999998 } }, { @@ -7900,17 +7900,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 4493.333333333333, + "target_w": 4608.333333333333, "clamped": true }, { "driver": "pixii", - "target_w": 4493.333333333333, + "target_w": 4608.333333333333, "clamped": true }, { "driver": "sungrow", - "target_w": 4493.333333333333, + "target_w": 4608.333333333333, "clamped": true } ], @@ -7927,7 +7927,7 @@ "fuse_saturated": false, "fuse_ev_max_w": 0, "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 13480, + "fuse_hold_max_charge_w": 13825, "fuse_hold_max_discharge_w": 0, "per_phase_overage_w": 0, "effective_import_ceiling_w": 10350, @@ -7937,9 +7937,9 @@ }, "site_totals": { "battery_current_w": 853, - "battery_target_sum_w": 13480, + "battery_target_sum_w": 13825, "raw_grid_w": -7899, - "projected_grid_w": 4728 + "projected_grid_w": 5073 } }, { 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/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index e532d9d3a..ef16f9de2 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -204,8 +204,9 @@ type State struct { CarMaxW float64 `json:"-"` CarChargeLimit float64 `json:"-"` // CarPhases is 1 when this session's car draws on one phase of a - // three-phase order, seen on the site meter, and 0 when unknown; a car - // profile's phases may set it later. CarPhase is that phase, 1-3. The + // three-phase order, seen on the site meter, 3 when it draws on all + // three, and 0 when unknown; a car profile's phases may set it later. + // CarPhase is the phase of a one-phase car, 1-3. The // plan counts the car's one-phase levels (OnePhaseCarSteps) and the // order names the current per phase it should take. CarPhases int `json:"-"` @@ -1382,8 +1383,11 @@ func (lp *loadpointRuntime) snapshot() State { CommandedPhases: lp.commandedPhases, CarPhase: lp.carPhases.phase, } - if lp.carPhases.phase > 0 { + switch { + case lp.carPhases.phase > 0: st.CarPhases = 1 + case lp.carPhases.three: + st.CarPhases = 3 } if lp.VehicleCapacityWh <= 0 { st.VehicleCapacityWh = 60000 diff --git a/go/internal/loadpoint/one_phase_car.go b/go/internal/loadpoint/one_phase_car.go index 031bbeed3..e91c6463e 100644 --- a/go/internal/loadpoint/one_phase_car.go +++ b/go/internal/loadpoint/one_phase_car.go @@ -51,7 +51,7 @@ type phaseTest struct { } // carPhases is one session's evidence. phase is 1-3 once the car is seen on -// that phase alone. +// that phase alone; three is set once it is seen on all three. type carPhases struct { orderA float64 // per phase, the order the next rise is measured from // rising is set once the order has gone above orderA, and before is the @@ -67,6 +67,7 @@ type carPhases struct { seen bool test *phaseTest phase int + three bool } // phaseVerdict reads one meter reading against the baseline of a rise of @@ -196,7 +197,7 @@ func (m *Manager) observePhases(id string, meter SitePhases, drawA, voltage floa if t.verdict == 0 || now.Sub(t.since) < onePhaseHold { return } - s.phase = max(t.verdict, 0) + s.phase, s.three = max(t.verdict, 0), t.verdict < 0 s.test = nil } diff --git a/go/internal/loadpoint/one_phase_car_test.go b/go/internal/loadpoint/one_phase_car_test.go index ae002be08..4d84b9e07 100644 --- a/go/internal/loadpoint/one_phase_car_test.go +++ b/go/internal/loadpoint/one_phase_car_test.go @@ -83,8 +83,8 @@ func TestOnePhaseCarMarkedFromTheMeter(t *testing.T) { return threePhase(16)(d) } run(125*time.Second, 200*time.Second, allThree, 11040) - if st := state(); st.CarPhases != 0 { - t.Fatalf("a rise on all three phases left the mark on L%d", st.CarPhase) + if st := state(); st.CarPhases != 3 || st.CarPhase != 0 { + t.Fatalf("a rise on all three phases: car phases %d on L%d, want 3", st.CarPhases, st.CarPhase) } order(4830) run(205*time.Second, 210*time.Second, still, 0) 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 @@ '