Skip to content
Merged
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/remove-priority-weighted.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

The `priority` and `weighted` control modes are removed. Priority never regulated, because nothing set its battery order: it held each battery at its measured power, so a battery that was discharging kept discharging into export when the house load dropped. A site that had either mode stored starts in manual self-consumption, saves that and logs it once; Home Assistant, the API and the app no longer accept either mode, and Settings no longer shows the per-battery weight field. Home Assistant mode changes now behave like the app's, so a mode that cannot be saved still reaches the planner and a replan no longer holds up other Home Assistant commands. The old `planner.use_energy_dispatch` key is converted to `planner.legacy_dispatch` on load and removed from stored settings, so a site that chose the legacy dispatch path keeps it.
2 changes: 0 additions & 2 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,6 @@ modes:
- { key: charge, tier: advanced }
- { key: planner_self, tier: hidden }
- { key: planner_cheap, tier: hidden }
- { key: priority, tier: hidden }
- { key: weighted, tier: hidden }

# ---------------------------------------------------------------------------
# Error codes the box sends. The box sends the code and args; this app owns all
Expand Down
5 changes: 2 additions & 3 deletions docs/site-convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,8 @@ In self-consumption mode, the controller drives the site meter toward the
grid target (normally 0 W): it charges from live surplus and may discharge
to cover local load. It must not intentionally export via the battery; export
should come from PV unless an explicit export-capable strategy is selected.
Other modes such as peak-shaving, weighted target-following, and arbitrage
may still issue negative battery targets when their contract calls for
discharge.
Other modes such as peak-shaving and arbitrage may still issue negative
battery targets when their contract calls for discharge.

## SI units everywhere

Expand Down
5 changes: 3 additions & 2 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ func (a appBoxInfo) Identity() appproto.Identity {
// otherwise would be a spinner that never resolves.
func (a appBoxInfo) Boot() *appproto.BootProgress { return nil }

// appModes applies a mode and reads it back.
// appModes applies a mode and reads it back. Home Assistant's mode command
// uses the same SetMode, so the two doors cannot drift apart.
//
// SetMode and ObservedMode are deliberately different questions: the first is
// what was asked for, the second is what the box is running. cmd.result
Expand All @@ -204,7 +205,7 @@ func (a *appModes) SetMode(ctx context.Context, m control.Mode) error {

if a.state != nil {
if err := a.state.SaveConfig("mode", string(m)); err != nil {
slog.Warn("app uplink could not persist the mode", "err", err)
slog.Warn("could not persist the mode", "mode", m, "err", err)
}
}
if a.prefs != nil {
Expand Down
44 changes: 44 additions & 0 deletions go/cmd/ftw/app_link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package main

import (
"math"
"path/filepath"
"sync"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/telemetry"
)

Expand Down Expand Up @@ -231,6 +234,47 @@ func TestAppSetModeGoesThroughApplyMode(t *testing.T) {
}
}

// Home Assistant's mode command is the app's door. It used to be a third copy
// that returned when the mode could not be saved, before the export preference
// and the planner were told, so control ran one strategy while the planner
// planned another.
func TestHomeAssistantSetModeFinishesWhenTheModeCannotBeSaved(t *testing.T) {
_, ctrl := seedSite(t)
ctrl.PI = &control.PIController{}
ctrl.SetBatteryManualHold(control.BatteryManualHold{
Driver: "battery", PowerW: -2000, ExpiresAt: time.Now().Add(time.Hour),
})
st, err := state.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
prefs := config.NewPlannerPrefs(config.ForecastTrustBalanced, config.BatteryExportNotAllowed, config.SafetyKDefault)

callbacks := haCallbacks(ctrl, &sync.Mutex{}, st, nil, prefs)
if err := callbacks.SetMode(string(control.ModePlannerArbitrage)); err != nil {
t.Fatalf("SetMode: %v", err)
}

if ctrl.Mode != control.ModePlannerArbitrage {
t.Fatalf("mode = %q", ctrl.Mode)
}
if _, active := ctrl.GetBatteryManualHold(time.Now()); active {
t.Fatal("the manual hold survived a mode change")
}
if _, export, _ := prefs.Get(); export != config.BatteryExportAllowed {
t.Fatalf("battery export = %q; the mode change stopped before the planner preference", export)
}
if err := callbacks.SetMode("priority"); err == nil {
t.Fatal("Home Assistant set a removed mode")
}
if ctrl.Mode != control.ModePlannerArbitrage {
t.Fatalf("a refused mode changed the state to %q", ctrl.Mode)
}
}

func TestAppSetModeRefusesAModeTheBoxDoesNotHave(t *testing.T) {
_, ctrl := seedSite(t)
modes := &appModes{ctrl: ctrl, ctrlMu: &sync.Mutex{}}
Expand Down
40 changes: 40 additions & 0 deletions go/cmd/ftw/control_state.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package main

import (
"log/slog"
"math"
"strconv"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/state"
)

func newControlStateFromConfig(cfg *config.Config) *control.State {
Expand Down Expand Up @@ -60,3 +62,41 @@ func restoredGridTargetW(v string) (float64, bool) {
}
return f, true
}

// energyDispatchEnabled reports whether planner modes run the
// energy-allocation dispatch. planner.legacy_dispatch: true is the only way
// back to the PI-on-grid-target path, and a config without a planner section
// takes the default. Boot and hot reload both read it here so a reload cannot
// leave a site on a path a fresh boot would not choose.
func energyDispatchEnabled(cfg *config.Config) bool {
return cfg.Planner == nil || !cfg.Planner.LegacyDispatch
}

// removedModes are control modes FTW no longer runs. Priority held every
// battery at its measured power, because nothing ever set its battery order,
// and weighted split equally because nothing set its weights.
var removedModes = map[string]bool{"priority": true, "weighted": true}

// restoreStoredMode applies the mode saved in state.db. A removed mode becomes
// manual self-consumption and is saved back, so the stored mode matches what
// the site runs. Any other unknown value keeps the default mode, as before.
func restoreStoredMode(ctrl *control.State, st *state.Store) {
v, ok := st.LoadConfig("mode")
if !ok {
return
}
if m := control.Mode(v); control.IsValidMode(m) {
ctrl.Mode = m
return
}
if !removedModes[v] {
return
}
ctrl.Mode = control.ModeSelfConsumption
if err := st.SaveConfig("mode", string(ctrl.Mode)); err != nil {
slog.Warn("the stored control mode was removed; running self_consumption, but it could not be saved",
"stored", v, "err", err)
return
}
slog.Warn("the stored control mode was removed; running and saving self_consumption instead", "stored", v)
}
76 changes: 76 additions & 0 deletions go/cmd/ftw/control_state_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package main

import (
"path/filepath"
"reflect"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/mpc"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/telemetry"
)

Expand Down Expand Up @@ -177,3 +179,77 @@ func TestRestoredGridTargetIgnoresNonFinite(t *testing.T) {
t.Fatalf("restoredGridTargetW(-1500.0) = %v, %v; want -1500, true", f, ok)
}
}

// Priority and weighted were removed. A site that stored one must boot into
// self-consumption and store it, not keep a mode no door accepts any more.
func TestRestoreStoredModeMapsRemovedModesToSelfConsumption(t *testing.T) {
for stored, want := range map[string]control.Mode{
"priority": control.ModeSelfConsumption,
"weighted": control.ModeSelfConsumption,
"planner_arbitrage": control.ModePlannerArbitrage,
"peak_shaving": control.ModePeakShaving,
} {
t.Run(stored, func(t *testing.T) {
st, err := state.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.SaveConfig("mode", stored); err != nil {
t.Fatal(err)
}
ctrl := control.NewState(0, 50, "meter")
ctrl.Mode = control.ModeIdle

restoreStoredMode(ctrl, st)

if ctrl.Mode != want {
t.Fatalf("mode = %q, want %q", ctrl.Mode, want)
}
if saved, _ := st.LoadConfig("mode"); saved != string(want) {
t.Fatalf("stored mode = %q, want %q", saved, want)
}
})
}
}

// A value FTW never had keeps the default and is left for an operator to see.
func TestRestoreStoredModeLeavesAnUnknownValueAlone(t *testing.T) {
st, err := state.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.SaveConfig("mode", "turbo"); err != nil {
t.Fatal(err)
}
ctrl := control.NewState(0, 50, "meter")

restoreStoredMode(ctrl, st)

if ctrl.Mode != control.ModeSelfConsumption {
t.Fatalf("mode = %q, want the default", ctrl.Mode)
}
if saved, _ := st.LoadConfig("mode"); saved != "turbo" {
t.Fatalf("stored mode = %q, want it untouched", saved)
}
}

// Boot and hot reload read the dispatch path from one place. A reload that
// removed the planner section used to keep the legacy path a fresh boot
// would not pick.
func TestEnergyDispatchEnabled(t *testing.T) {
for _, tc := range []struct {
name string
planner *config.Planner
want bool
}{
{"no planner section", nil, true},
{"planner without legacy_dispatch", &config.Planner{Enabled: true}, true},
{"legacy_dispatch", &config.Planner{Enabled: true, LegacyDispatch: true}, false},
} {
if got := energyDispatchEnabled(&config.Config{Planner: tc.planner}); got != tc.want {
t.Errorf("%s: energyDispatchEnabled = %v, want %v", tc.name, got, tc.want)
}
}
}
67 changes: 15 additions & 52 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -577,11 +577,7 @@ func main() {
// have to be listed too — without them the strategy the user picked in
// the UI (planner_self / planner_cheap / planner_arbitrage) is silently
// dropped on restart and the dashboard appears to forget the selection.
if v, ok := st.LoadConfig("mode"); ok {
if m := control.Mode(v); control.IsValidMode(m) {
ctrl.Mode = m
}
}
restoreStoredMode(ctrl, st)
storedTrust, _ := st.LoadConfig(config.StateKeyForecastTrust)
storedExport, _ := st.LoadConfig(config.StateKeyBatteryExport)
storedSafetyK, _ := st.LoadConfig(config.StateKeySafetyK)
Expand Down Expand Up @@ -1126,13 +1122,10 @@ func main() {
mpcSvc.UpdateSiteEconomics(economics)
if newCfg.Planner != nil {
applyPlannerScalars(mpcSvc, newCfg.Planner)
ctrlMu.Lock()
ctrl.UseEnergyDispatch = !newCfg.Planner.LegacyDispatch
if newCfg.Planner.UseEnergyDispatch != nil {
ctrl.UseEnergyDispatch = *newCfg.Planner.UseEnergyDispatch
}
ctrlMu.Unlock()
}
ctrlMu.Lock()
ctrl.UseEnergyDispatch = energyDispatchEnabled(newCfg)
ctrlMu.Unlock()
}
if priceSvc != nil && newCfg.Price != nil {
vat := newCfg.Price.VATPercent
Expand Down Expand Up @@ -1851,20 +1844,10 @@ func main() {
// the regulator (decides HOW batteries react — from live
// telemetry, not plan forecasts).
// `planner.legacy_dispatch: true` opts back to the old
// PI-on-grid-target path for emergency rollback.
//
// Back-compat: honor the deprecated `use_energy_dispatch`
// key when explicitly set. An operator who had
// `use_energy_dispatch: false` in their config before v0.27.0
// chose legacy on purpose — don't silently flip them.
ctrl.UseEnergyDispatch = cfg.Planner == nil || !cfg.Planner.LegacyDispatch
if cfg.Planner != nil && cfg.Planner.UseEnergyDispatch != nil {
v := *cfg.Planner.UseEnergyDispatch
slog.Warn("planner.use_energy_dispatch is deprecated — use planner.legacy_dispatch: "+
"true to opt out of the energy path instead. Honored for this run.",
"value", v)
ctrl.UseEnergyDispatch = v
}
// PI-on-grid-target path for emergency rollback. Config loading
// has already turned the removed `use_energy_dispatch` key into
// legacy_dispatch.
ctrl.UseEnergyDispatch = energyDispatchEnabled(cfg)
slog.Info("mpc planner started",
"mode", mpcSvc.Defaults.Mode,
"capacity_wh", mpcSvc.Defaults.CapacityWh,
Expand Down Expand Up @@ -4159,35 +4142,15 @@ func restoreLatestMPCDiagnostic(st *state.Store, svc *mpc.Service, now time.Time
// HA commands behave one way after boot and a different way after a
// hot-reload, which is the kind of silent skew that's hardest to debug.
func haCallbacks(ctrl *control.State, ctrlMu *sync.Mutex, st *state.Store, mpcSvc *mpc.Service, prefs *config.PlannerPrefs) ha.CommandCallbacks {
modes := &appModes{ctrl: ctrl, ctrlMu: ctrlMu, state: st, mpc: mpcSvc, prefs: prefs}
return ha.CommandCallbacks{
// Home Assistant changes the mode through the app's door:
// control.ApplyMode, which accepts exactly the set the HA discovery
// `select` advertises, then the saved mode, the export preference
// and a replan that does not hold up the MQTT handler.
SetMode: func(m string) error {
mode := control.Mode(m)
// Accept exactly the set the HA discovery `select` advertises —
// control.AllModes via IsValidMode — so a planner_* option an
// operator picks in Home Assistant isn't silently rejected here.
// Mirror the full /api/mode side-effects (manual-hold + PI reset
// + MPC propagation); the two setters must behave identically or
// HA mode changes diverge from web-UI ones (#mode-drift).
if !control.IsValidMode(mode) {
return fmt.Errorf("unknown mode: %s", m)
}
ctrlMu.Lock()
ctrl.Mode = mode
ctrl.ClearBatteryManualHold()
if ctrl.PI != nil {
ctrl.PI.Reset()
}
ctrlMu.Unlock()
if err := st.SaveConfig("mode", m); err != nil {
return err
}
if prefs != nil {
prefs.ApplyExportFromMode(m, st.SaveConfig)
}
if mm, ok := control.PlannerMPCMode(mode); ok && mpcSvc != nil {
mpcSvc.SetMode(mm)
}
return nil
// The mode is applied at once; the replan runs in the background.
return modes.SetMode(context.Background(), control.Mode(m))
},
SetGridTarget: func(w float64) error {
ctrlMu.Lock()
Expand Down
Loading
Loading