Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/validate-phases-sitemeter.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
Expand Down
61 changes: 61 additions & 0 deletions go/internal/config/validate_site_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}