From 79bb695ecf779996b039323b3e9abc2c676c5e76 Mon Sep 17 00:00:00 2001 From: Sanjin Naidu Date: Thu, 6 Aug 2026 21:07:58 +0200 Subject: [PATCH] fix(config): reject >3 fuse phases and duplicate site meters fuse.phases above 3 was silently truncated to 3 by the dispatch freshness gate while MaxPowerW kept multiplying by the configured count, and a second is_site_meter driver was silently ignored in favor of the first. Both misconfigurations now fail at load with a clear message. Co-Authored-By: Claude Fable 5 Signed-off-by: Sanjin Naidu --- .changeset/validate-phases-sitemeter.md | 5 ++ go/internal/config/config.go | 14 ++++++ go/internal/config/validate_site_test.go | 61 ++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 .changeset/validate-phases-sitemeter.md create mode 100644 go/internal/config/validate_site_test.go diff --git a/.changeset/validate-phases-sitemeter.md b/.changeset/validate-phases-sitemeter.md new file mode 100644 index 00000000..20b90b1f --- /dev/null +++ b/.changeset/validate-phases-sitemeter.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Config validation now rejects `fuse.phases` above 3 (previously silently truncated to 3 by the dispatch freshness gate, understating fuse limits) and more than one driver with `is_site_meter: true` (previously the first match silently won). Both were already misconfigurations with surprising behavior; they now fail loudly at load time with a clear message instead. diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 074d84e8..e24efdd5 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -1580,6 +1580,12 @@ func (c *Config) Validate() error { if len(c.Drivers) > 0 && siteMeters == 0 { return errors.New("at least one driver must be is_site_meter: true") } + // SiteMeterDriver() returns the first match, so a second is_site_meter + // entry was silently ignored — the operator thinks meter B is the site + // boundary while dispatch trusts meter A. Make the ambiguity an error. + if siteMeters > 1 { + return fmt.Errorf("exactly one driver may set is_site_meter: true (found %d)", siteMeters) + } if c.Site.ControlIntervalS < 0 { return errors.New("site.control_interval_s must be >= 0") @@ -1608,6 +1614,14 @@ func (c *Config) Validate() error { if c.Fuse.Phases <= 0 { return errors.New("fuse.phases must be > 0") } + // The phase-current safety model ([3]float64 arrays, meter_l1..l3_a + // metrics) covers 1..3 conductors. A larger value used to be silently + // truncated to 3 by the dispatch freshness gate, which understates + // MaxPowerW-based limits derived here while the operator believes the + // extra phases are accounted for. Reject it instead. + if c.Fuse.Phases > 3 { + return errors.New("fuse.phases must be 1, 2 or 3") + } if c.Fuse.Voltage <= 0 { return errors.New("fuse.voltage must be > 0") } diff --git a/go/internal/config/validate_site_test.go b/go/internal/config/validate_site_test.go new file mode 100644 index 00000000..f9d2d528 --- /dev/null +++ b/go/internal/config/validate_site_test.go @@ -0,0 +1,61 @@ +package config + +import ( + "strings" + "testing" +) + +func TestValidateRejectsMoreThanThreePhases(t *testing.T) { + c := &Config{ + Site: Site{SmoothingAlpha: 0.3}, + Fuse: Fuse{MaxAmps: 16, Phases: 4, Voltage: 230}, + } + if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "fuse.phases") { + t.Errorf("expected fuse.phases error for 4 phases, got %v", err) + } +} + +func TestValidateAcceptsOneToThreePhases(t *testing.T) { + for phases := 1; phases <= 3; phases++ { + c := &Config{ + Site: Site{SmoothingAlpha: 0.3}, + Fuse: Fuse{MaxAmps: 16, Phases: phases, Voltage: 230}, + } + if err := c.Validate(); err != nil { + t.Errorf("phases=%d: unexpected error: %v", phases, err) + } + } +} + +func meterDriver(name string, siteMeter bool) Driver { + return Driver{ + Name: name, + Lua: "drivers/test.lua", + IsSiteMeter: siteMeter, + Capabilities: Capabilities{ + Modbus: &ModbusConfig{Host: "192.168.1.10", Port: 502}, + }, + } +} + +func TestValidateRejectsDuplicateSiteMeter(t *testing.T) { + c := &Config{ + Site: Site{SmoothingAlpha: 0.3}, + Fuse: Fuse{MaxAmps: 16, Phases: 3, Voltage: 230}, + Drivers: []Driver{meterDriver("a", true), meterDriver("b", true)}, + } + if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "is_site_meter") { + t.Errorf("expected duplicate is_site_meter error, got %v", err) + } +} + +func TestValidateAcceptsSingleSiteMeter(t *testing.T) { + c := &Config{ + Site: Site{SmoothingAlpha: 0.3}, + Fuse: Fuse{MaxAmps: 16, Phases: 3, Voltage: 230}, + Drivers: []Driver{meterDriver("a", true), meterDriver("b", false)}, + } + if err := c.Validate(); err != nil { + t.Errorf("unexpected error: %v", err) + } +}