Skip to content
Draft
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/fuse-one-function.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Fuse protection now reads the main fuse the same way for the car and the home battery: the site meter's current on each phase, with its direction, under one margin (your safety margin, at least 1 A). A battery's charge or a car may rise only into the room the phases show. When a phase goes over, the car is cut and the battery gives back only what the car's cut does not cover, so the two never answer the same overage twice. The meter's total no longer counts against the fuse when the meter reports phase currents, which a Pixii total that disagrees with its phases used to trip. A car now starts when its minimum fits, a second car plugging in no longer stops the first, and fuse relief no longer discharges a battery below its soc_min (or the planner's, 10 % by default).
5 changes: 5 additions & 0 deletions .changeset/fuse-phase-direction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

A car can now start and rise while the house exports. Fuse protection read each phase's current as a magnitude, so 20 A of solar export counted as 20 A of import and the car never started. A phase now takes its direction from the same phase's power in the meter reading, and new load on an exporting phase has more room, not less. When a meter reports no phase power, its current still counts as import.
5 changes: 5 additions & 0 deletions .changeset/import-limit-reason.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

When the hourly import limit holds a car back, FTW now says so instead of blaming the main fuse. The charger's reason is `import_limit` ("Limited by the hourly import limit") whenever this hour's import budget leaves less room than the fuse, and a battery boost stopped by that budget says "Hourly import limit stopped boost". `fuse_limit` now means the main fuse alone, and the web page says "Limited by the main fuse".
7 changes: 3 additions & 4 deletions go/cmd/ftw/control_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,9 @@ func newControlStateFromConfig(cfg *config.Config) *control.State {
ctrl.InverterGroups = inverterGroupsFrom(cfg.Drivers)
ctrl.SupportsPVCurtail = supportsPVCurtailFrom(cfg.Drivers)
ctrl.SolarFeedDrivers = solarFeedDriversFrom(cfg.Drivers)
ctrl.DriverLimits = driverLimitsFrom(cfg.ModbusControlDrivers(), cfg.Batteries)
// Per-phase fuse params for the per-phase clamp inside applyFuseGuard
// + forceFuseDischarge. Reads l1_a/l2_a/l3_a from the meter driver
// when SiteFuseAmps > 0; otherwise the per-phase clamp is disabled.
ctrl.DriverLimits = driverLimitsFrom(cfg.ModbusControlDrivers(), cfg.Batteries, cfg.Planner)
// The fuse that control.fusePhases reads with the site meter's
// l1_a/l2_a/l3_a for every charger and battery decision.
ctrl.SiteFuseAmps = cfg.Fuse.MaxAmps
ctrl.SiteFuseVoltage = cfg.Fuse.Voltage
ctrl.SiteFusePhases = cfg.Fuse.Phases
Expand Down
38 changes: 28 additions & 10 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1150,7 +1150,7 @@ func main() {
ctrl.InverterGroups = inverterGroupsFrom(newCfg.Drivers)
ctrl.SupportsPVCurtail = supportsPVCurtailFrom(newCfg.Drivers)
ctrl.SolarFeedDrivers = solarFeedDriversFrom(newCfg.Drivers)
ctrl.DriverLimits = driverLimitsFrom(newCfg.ModbusControlDrivers(), newCfg.Batteries)
ctrl.DriverLimits = driverLimitsFrom(newCfg.ModbusControlDrivers(), newCfg.Batteries, newCfg.Planner)
// Fuse params + safety margin: previously startup-only.
// Hot-reload them so operators can tune the per-phase margin
// from the UI without restarting (e.g. raising it after the
Expand Down Expand Up @@ -1650,7 +1650,7 @@ func main() {
}
// Plumb the site fuse so the DP joint-plans battery + EV under
// the fuse from the start (instead of producing plans that
// dispatch later has to scale via the joint allocator).
// dispatch later has to scale to the phases' room).
mpcSvc.FuseMaxW = cfg.Fuse.MaxPowerW()
mpcSvc.PhaseImbalanceW = func() float64 {
cfgMu.RLock()
Expand Down Expand Up @@ -2010,13 +2010,18 @@ func main() {
defer ctrlMu.Unlock()
return control.EVFusePhaseRoomA(ctrl)
})
lpController.SetFuseLimitReason(func() string {
ctrlMu.Lock()
defer ctrlMu.Unlock()
return control.EVLimitReason(ctrl)
})
// Every lease is preflighted and re-evaluated on each loadpoint tick.
// This closure owns site/battery health because those concepts belong
// to core, not to the protocol-neutral loadpoint package.
lpController.SetBatteryBoostSafety(func(id string, lease loadpoint.BatteryBoostLease) loadpoint.BatteryBoostStopReason {
ctrlMu.Lock()
mode := ctrl.Mode
fuseBlocked := ctrl.FuseSaturated
fuseBlocked, importBlocked := ctrl.FuseSaturated, ctrl.FuseSaturatedByImport
_, batteryHeld := ctrl.GetBatteryManualHold(time.Now())
siteMeter := ctrl.SiteMeterDriver
ctrlMu.Unlock()
Expand All @@ -2026,6 +2031,9 @@ func main() {
if batteryHeld {
return loadpoint.BatteryBoostStoppedBatteryHold
}
if fuseBlocked && importBlocked {
return loadpoint.BatteryBoostStoppedImportLimit
}
if fuseBlocked {
return loadpoint.BatteryBoostStoppedFuseSafety
}
Expand Down Expand Up @@ -2816,8 +2824,8 @@ func main() {
ticker := time.NewTicker(controlInterval)
defer ticker.Stop()
var saveCount uint64
// Track FuseSaturated edge so we replan once when the joint allocator
// kicks in — the plan was made without knowledge of the live overage,
// Track FuseSaturated edge so we replan once when dispatch holds the
// battery or EV back — the plan was made without knowledge of the live overage,
// and a replan with current EV/PV/load state usually finds a feasible
// schedule that doesn't fight the fuse.
var prevFuseSaturated bool
Expand Down Expand Up @@ -3228,8 +3236,8 @@ func main() {
// "EV dispatch first" block above.

// ---- Trigger MPC replan on fuse-saturation rising edge ----
// The joint allocator (control.dispatch) just throttled
// battery and EV to fit under the fuse. The plan was built
// Dispatch just held battery charge or EV need back to fit
// the site's room (control.fitBatteryRisesToFuse). The plan was built
// without seeing this overage, so let MPC redraw with current
// state — usually it finds a slot allocation that doesn't
// require both battery charge and full-bore EV simultaneously.
Expand Down Expand Up @@ -3607,12 +3615,22 @@ func driverCapacitiesFrom(drvList []config.Driver, loadpoints []config.Loadpoint
return out
}

// driverLimitsFrom gives dispatch the same per-driver limits as the planner.
func driverLimitsFrom(drivers []config.Driver, batteries map[string]config.Battery) map[string]control.PowerLimits {
// driverLimitsFrom gives dispatch the same per-driver limits as the planner,
// with each battery's floor: its soc_min, else the planner's (default 10 %).
func driverLimitsFrom(drivers []config.Driver, batteries map[string]config.Battery, planner *config.Planner) map[string]control.PowerLimits {
floor := 0.10
if planner != nil && planner.SoCMin > 0 {
floor = planner.SoCMin
}
out := map[string]control.PowerLimits{}
for _, d := range drivers {
if !d.ObserveOnly {
out[d.Name] = batteryPowerLimits(d, batteries[d.Name])
limits := batteryPowerLimits(d, batteries[d.Name])
limits.SoCMin = floor
if b := batteries[d.Name]; b.SoCMin != nil {
limits.SoCMin = *b.SoCMin
}
out[d.Name] = limits
}
}
return out
Expand Down
2 changes: 1 addition & 1 deletion go/cmd/ftw/modbus_owner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func TestExternalModbusOwnerKeepsTelemetryButLeavesPlannerAndDispatch(t *testing
if got := driverCapacitiesFrom(cfg.Drivers, nil, evCatalog(), false); got["sungrow"] != 10000 {
t.Fatalf("telemetry capacity lost: %v", got)
}
if len(driverLimitsFrom(cfg.ModbusControlDrivers(), cfg.Batteries)) != 0 {
if len(driverLimitsFrom(cfg.ModbusControlDrivers(), cfg.Batteries, cfg.Planner)) != 0 {
t.Fatal("external battery remains in dispatch limits")
}
if !config.ObserveOnlyDriverSet(cfg)["sungrow"] {
Expand Down
69 changes: 7 additions & 62 deletions go/cmd/ftw/site_dispatch_safety.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ type siteDispatchDecision struct {
func (d siteDispatchDecision) Allowed() bool { return d.Reason == siteDispatchReady }

// evaluateSiteDispatchFreshnessAt rejects physical dispatch when the site
// meter itself is stale. Per-phase currents are optional until the configured
// meter has emitted at least one of them; once available, every configured
// phase becomes a required fuse-safety input and must remain fresh.
// meter itself is stale. Its phase currents are optional; once a reading
// reports any, every configured phase is a required fuse-safety input. They
// come with the reading fuse protection reads, so they are as fresh as it.
func evaluateSiteDispatchFreshnessAt(
tel *telemetry.Store,
siteMeter string,
Expand All @@ -46,8 +46,10 @@ func evaluateSiteDispatchFreshnessAt(
if fuseAmps <= 0 {
return siteDispatchDecision{}
}
_, available, fresh := sitePhaseCurrentsAt(tel, siteMeter, phaseCount, timeout, now)
if available && !fresh {
if phaseCount <= 0 {
phaseCount = 3
}
if _, present, valid := control.SitePhaseCurrents(meter, phaseCount); present && !valid {
return siteDispatchDecision{Reason: siteDispatchPhaseCurrentsStale}
}
return siteDispatchDecision{}
Expand Down Expand Up @@ -78,63 +80,6 @@ func siteDispatchNow(
return evaluateSiteDispatchFreshnessAt(tel, siteMeter, fuseAmps, phases, timeout, now)
}

// sitePhaseCurrentsAt returns the configured phases in L1/L2/L3 order.
// available is false when this meter has never exposed phase-current
// telemetry, preserving the documented optional-per-phase behavior. If any
// phase has appeared, fresh is true only when all configured phases exist and
// are within the same watchdog window as the aggregate meter.
func sitePhaseCurrentsAt(
tel *telemetry.Store,
siteMeter string,
phaseCount int,
timeout time.Duration,
now time.Time,
) (amps [3]float64, available, fresh bool) {
if tel == nil || siteMeter == "" {
return amps, false, false
}
return phaseCurrentsFromSnapshots(
tel.LatestMetricsByDriver(siteMeter), phaseCount, timeout, now,
)
}

func phaseCurrentsFromSnapshots(
metrics []telemetry.MetricSnapshot,
phaseCount int,
timeout time.Duration,
now time.Time,
) (amps [3]float64, available, fresh bool) {
if phaseCount <= 0 {
phaseCount = 3
}
if phaseCount > len(amps) {
phaseCount = len(amps)
}

byName := make(map[string]telemetry.MetricSnapshot, len(metrics))
for _, metric := range metrics {
byName[metric.Name] = metric
}
phaseMetricNames := [...]string{"meter_l1_a", "meter_l2_a", "meter_l3_a"}
fresh = true
for i := 0; i < phaseCount; i++ {
metric, ok := byName[phaseMetricNames[i]]
if !ok {
fresh = false
continue
}
available = true
amps[i] = metric.Value
if now.Sub(metric.UpdatedAt) > timeout {
fresh = false
}
}
if !available {
return amps, false, false
}
return amps, true, fresh
}

// staleSiteDefaultTracker emits one DefaultMode request per registered driver
// on entry to a stale state. Drivers added while the gate remains closed are
// defaulted once when first observed. Recovery clears the latch so a future
Expand Down
27 changes: 6 additions & 21 deletions go/cmd/ftw/site_meter_stale_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"sync"
"testing"
"time"
Expand All @@ -26,26 +27,12 @@ func TestEvaluateSiteDispatchFreshnessRejectsStaleMeter(t *testing.T) {
}
}

func TestEvaluateSiteDispatchFreshnessRejectsStalePhaseCurrents(t *testing.T) {
now := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC)
metrics := []telemetry.MetricSnapshot{
{Name: "meter_l1_a", Value: 8, UpdatedAt: now},
{Name: "meter_l2_a", Value: 9, UpdatedAt: now.Add(-2 * time.Minute)},
{Name: "meter_l3_a", Value: 7, UpdatedAt: now},
}
amps, available, fresh := phaseCurrentsFromSnapshots(metrics, 3, time.Minute, now)
if !available {
t.Fatal("phase telemetry should be available")
}
if fresh {
t.Fatalf("phase telemetry unexpectedly fresh: %v", amps)
}
}

// The phase currents come with the site reading. Once it reports any, a
// reading that misses one of the configured phases stops dispatch, and a
// complete one lets it go again.
func TestEvaluateSiteDispatchFreshnessPhaseRecovery(t *testing.T) {
tel := telemetry.NewStore()
tel.Update("meter", telemetry.DerMeter, 1200, nil, nil)
tel.EmitMetric("meter", "meter_l1_a", 8, "A", "", "")
tel.Update("meter", telemetry.DerMeter, 1200, nil, json.RawMessage(`{"l1_a":8}`))
now := time.Now()

blocked := evaluateSiteDispatchFreshnessAt(tel, "meter", 16, 3, time.Minute, now)
Expand All @@ -54,9 +41,7 @@ func TestEvaluateSiteDispatchFreshnessPhaseRecovery(t *testing.T) {
blocked.Reason, siteDispatchPhaseCurrentsStale)
}

tel.EmitMetric("meter", "meter_l2_a", 9, "A", "", "")
tel.EmitMetric("meter", "meter_l3_a", 7, "A", "", "")
tel.Update("meter", telemetry.DerMeter, 1100, nil, nil)
tel.Update("meter", telemetry.DerMeter, 1100, nil, json.RawMessage(`{"l1_a":8,"l2_a":9,"l3_a":7}`))
recovered := evaluateSiteDispatchFreshnessAt(tel, "meter", 16, 3, time.Minute, time.Now())
if !recovered.Allowed() {
t.Fatalf("complete fresh phase telemetry remained blocked: %q", recovered.Reason)
Expand Down
6 changes: 3 additions & 3 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1887,9 +1887,9 @@ func (s *Server) handleSetPeakLimit(w http.ResponseWriter, r *http.Request) {
// ---- /api/peak_import_ceiling ----
//
// Import ceiling on each clock hour's mean import, in every mode. Default
// 0 = disabled. When > 0, dispatch's import-side clamps (joint EV/battery
// allocator, applyFuseGuard import branch, forceFuseDischarge, the EV hard
// limit) use the tighter of the fuse and the hour's import limit.
// 0 = disabled. When > 0, dispatch's import-side clamps (the battery's rise
// cap, applySiteLimits, forceFuseDischarge, the EV hard limit) hold the
// hour's import limit as well as the fuse.
// Persisted in state.db so the operator's tariff stays armed across
// restarts. See control.State.PeakImportCeilingW.
func (s *Server) handleSetPeakImportCeiling(w http.ResponseWriter, r *http.Request) {
Expand Down
4 changes: 3 additions & 1 deletion go/internal/api/api_control_feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ func setControlStatus(f *ControlFeedback) {
case "battery_full", "battery_nearly_full", "battery_nearly_empty", "core_limit",
"fuse_limit", "fuse_cooldown", "charger_limit":
status = "limited"
case "import_limit":
status = "limited"
case "device_limit", "offered_current_lower":
status, severity = "limited", "warning"
case "setpoint_changed", "power_below_target", "power_above_target", "power_wrong_direction",
Expand Down Expand Up @@ -430,7 +432,7 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence,
set("limited", "device_limit", "warning")
return
}
if coreReason == "site_meter_stale" || coreReason == "site_phase_currents_stale" || coreReason == "fuse_cooldown" || coreReason == "fuse_limit" || coreReason == "charger_limit" {
if coreReason == "site_meter_stale" || coreReason == "site_phase_currents_stale" || coreReason == "fuse_cooldown" || coreReason == "fuse_limit" || coreReason == "import_limit" || coreReason == "charger_limit" {
set("limited", coreReason, "warning")
return
}
Expand Down
2 changes: 1 addition & 1 deletion go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -938,7 +938,7 @@ type Driver struct {
// with since v0.x. On a hybrid inverter that can actually deliver
// more (e.g. Ferroamp 10-15 kW, Sungrow 8-10 kW on 32 A), lifting
// the per-driver cap is the right move — site-wide fuse protection
// (applyFuseGuard) still enforces the grid-boundary budget above
// (control.fusePhases) still enforces the phases' budget above
// whatever per-battery cap you set. Issue #145.
MaxChargeW float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"`
MaxDischargeW float64 `yaml:"max_discharge_w,omitempty" json:"max_discharge_w,omitempty"`
Expand Down
1 change: 1 addition & 0 deletions go/internal/control/battery_boost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ func TestFuseEmergencyRemainsSuperiorToBatteryBoostReserve(t *testing.T) {
state := NewState(0, 0, "ferroamp")
state.BatteryBoostReserveSoC = 0.30
state.DriverLimits = map[string]PowerLimits{"battery": {MaxDischargeW: 5000}}
describeFuse(state, 5000)
targets := applyDispatchSafetyPipeline(
[]DispatchTarget{{Driver: "battery", TargetW: -1000}},
store, state, map[string]float64{"battery": 10000}, 5000,
Expand Down
1 change: 1 addition & 0 deletions go/internal/control/battery_full_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ func TestFullBatteryStillProvidesFuseRelief(t *testing.T) {
currentW, soc float64
}{{"battery", 0, 1}})
st.Mode = ModeIdle
describeFuse(st, 13800)
got := ComputeDispatch(tel, st, capacities, 13800)
if len(got) != 1 || got[0].TargetW >= 0 {
t.Fatalf("full stop blocked fuse relief: %+v", got)
Expand Down
Loading
Loading