Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
fab8f6f
fix(config): fuse safety_margin_a defaults to 0
frahlg Oct 27, 2026
94f19b3
docs(changeset): say what the default change moves on its own
frahlg Oct 27, 2026
a6bdd9e
fix(control): the car comes before battery charge
frahlg Oct 27, 2026
eb553dd
fix(control): peak shaving drops a running charge for the car
frahlg Oct 27, 2026
ef7ca51
fix(control): after a restart the battery credits the car the meter s…
frahlg Oct 27, 2026
e53aa4b
fix(control): credit a found car only from a current charger sample
frahlg Oct 27, 2026
1a75e9a
Merge ev-base-after-restart into d-fuse-margin-default-0
frahlg Oct 28, 2026
bbb5f1b
test(control): run the site sim at the new 0 A default margin
frahlg Oct 28, 2026
3cf26fd
docs(changeset): say what margin 0 moves once the fuse stack lands
frahlg Oct 28, 2026
17dc7c2
Merge battery-rise-per-phase-cap into car-before-battery-charge
frahlg Oct 28, 2026
8c1dfce
Merge car-before-battery-charge into ev-base-after-restart
frahlg Oct 28, 2026
021273a
Merge ev-base-after-restart into d-fuse-margin-default-0
frahlg Oct 28, 2026
4ecebfd
test(control): keep the load-balancer bug as a known line above the line
frahlg Oct 28, 2026
0fbe100
Merge battery-rise-per-phase-cap into car-before-battery-charge
frahlg Oct 28, 2026
f29e96c
test(control): the car-first discharge stop has its own test
frahlg Oct 28, 2026
255577e
Merge battery-rise-per-phase-cap into car-before-battery-charge
frahlg Oct 28, 2026
2928990
Merge battery-rise-per-phase-cap into car-before-battery-charge
frahlg Oct 28, 2026
5babe51
Merge battery-rise-per-phase-cap into car-before-battery-charge
frahlg Oct 28, 2026
f294e14
Merge car-before-battery-charge into ev-base-after-restart
frahlg Oct 28, 2026
6b10baa
fix(control): restart credit keeps room for the car's rise to its max
frahlg Oct 28, 2026
3623603
Merge battery-rise-per-phase-cap into car-before-battery-charge
frahlg Oct 28, 2026
caacc8d
fix(control): restart credit counts no sample newer than the meter
frahlg Oct 28, 2026
b152e84
Merge car-before-battery-charge into ev-base-after-restart
frahlg Oct 28, 2026
551febd
fix(control): restart credit holds against a charger clock behind Core's
frahlg Oct 28, 2026
b621bab
Merge origin/ev-base-after-restart into d-fuse-margin-default-0
frahlg Oct 28, 2026
a5cdeae
docs(control): the fuse margin is opt-in, and only the fuse ceilings …
frahlg Oct 28, 2026
fdfbfec
test(config): an unset fuse margin reaches Core as 0
frahlg Oct 28, 2026
5c9c336
Merge branch 'battery-rise-per-phase-cap' into car-before-battery-charge
frahlg Oct 28, 2026
b82e44d
Merge branch 'car-before-battery-charge' into ev-base-after-restart
frahlg Oct 28, 2026
0befc58
Merge branch 'ev-base-after-restart' into d-fuse-margin-default-0
frahlg Oct 28, 2026
dbc4332
test(site): s18 judges own commands apart from the house's step
frahlg Oct 28, 2026
7d7b450
Merge battery-rise-per-phase-cap into car-before-battery-charge
frahlg Oct 28, 2026
9793adf
Merge car-before-battery-charge into ev-base-after-restart
frahlg Oct 28, 2026
040ebb1
fix(control): judge a restart sample by its arrival, not its stamp
frahlg Oct 28, 2026
1422d70
Merge ev-base-after-restart into d-fuse-margin-default-0
frahlg Oct 28, 2026
2b30933
test(site): judge overages at the fuse rating, not fuse - 1 A
frahlg Oct 28, 2026
62f1180
Merge pull request #1914 from srcfl/d-fuse-margin-default-0
frahlg Oct 28, 2026
a3f9a7b
Merge pull request #1926 from srcfl/ev-base-after-restart
frahlg Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/b-ev-fuse-line-at-rating.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions .changeset/d-fuse-margin-default-0.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/p1-car-before-battery-charge.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/s4-ev-base-after-restart.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 2 additions & 3 deletions go/cmd/ftw/control_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions go/cmd/ftw/control_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
55 changes: 34 additions & 21 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
Expand Down Expand Up @@ -868,27 +869,28 @@ 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 {
MaxAmps float64 `yaml:"max_amps" json:"max_amps"`
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"`
}

Expand All @@ -897,17 +899,27 @@ 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
}
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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
39 changes: 35 additions & 4 deletions go/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down
48 changes: 48 additions & 0 deletions go/internal/config/retired_control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions go/internal/config/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion go/internal/configreload/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 29 additions & 19 deletions go/internal/control/control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading