From a45fda4762653d007e7726d19a8010831927ba11 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 7 Sep 2026 23:32:34 +0200 Subject: [PATCH] Match planner admission and budgets to the selected worker contract --- .changeset/planner-admission-budget.md | 5 ++++ go/cmd/ftw/energyplan.go | 11 ++----- go/cmd/ftw/energyplan_test.go | 32 +++++++++++++++++++++ go/cmd/ftw/main.go | 6 ++-- go/internal/api/api.go | 5 +++- go/internal/api/api_mpc_unavailable_test.go | 31 ++++++++++++++++++++ go/internal/config/planner_engine.go | 24 ++++++++++++++++ go/internal/config/planner_engine_test.go | 29 +++++++++++++++++++ go/internal/mpc/energyplan.go | 4 ++- go/internal/mpc/energyplan_test.go | 17 +++++++++-- go/internal/mpc/physical_restore_test.go | 17 +++++++++++ go/internal/mpc/unavailable.go | 4 +-- go/internal/mpc/unavailable_test.go | 30 ++++++++++--------- optimizer/native/README.md | 6 +++- 14 files changed, 187 insertions(+), 34 deletions(-) create mode 100644 .changeset/planner-admission-budget.md create mode 100644 go/internal/config/planner_engine.go create mode 100644 go/internal/config/planner_engine_test.go diff --git a/.changeset/planner-admission-budget.md b/.changeset/planner-admission-budget.md new file mode 100644 index 000000000..0e8e7a3b2 --- /dev/null +++ b/.changeset/planner-admission-budget.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep sites without a home battery unavailable when Core DP is selected, and report the same reason in planner diagnostics. Keep Energyplan available for those sites. Match the documented worker budget to the request: Core's PV downside adjustment does not add scenarios or extend the budget on its own. diff --git a/go/cmd/ftw/energyplan.go b/go/cmd/ftw/energyplan.go index 60dc5b6bd..5a157895f 100644 --- a/go/cmd/ftw/energyplan.go +++ b/go/cmd/ftw/energyplan.go @@ -4,19 +4,12 @@ import ( "os" "path/filepath" "runtime" - "strings" "github.com/srcfl/ftw/go/internal/config" ) func plannerEngine(pl *config.Planner, version string) string { - if pl != nil && strings.TrimSpace(pl.Engine) != "" { - return pl.EngineName() - } - if base, valid := releaseVersionBase(version); valid && base != version && energyplanSupported(runtime.GOOS, runtime.GOARCH) { - return config.PlannerEngineEnergyplan - } - return config.PlannerEngineCore + return pl.EngineForBuild(version, runtime.GOOS, runtime.GOARCH) } func resolveEnergyplanBinary() string { @@ -42,5 +35,5 @@ func resolveEnergyplanBinary() string { } func energyplanSupported(goos, goarch string) bool { - return (goos == "linux" && (goarch == "amd64" || goarch == "arm64")) || (goos == "darwin" && goarch == "arm64") + return config.SupportsBundledEnergyplan(goos, goarch) } diff --git a/go/cmd/ftw/energyplan_test.go b/go/cmd/ftw/energyplan_test.go index 374228a5e..9353af084 100644 --- a/go/cmd/ftw/energyplan_test.go +++ b/go/cmd/ftw/energyplan_test.go @@ -55,3 +55,35 @@ func TestBuildMPCWithoutHomeBattery(t *testing.T) { defer svc.Optimizer.Close() } } + +func TestBuildMPCBatterylessEngineAdmission(t *testing.T) { + old := Version + t.Cleanup(func() { Version = old }) + for _, tc := range []struct { + name, version, engine string + want bool + }{ + {"explicit Core in beta", "v3.1.0-beta.1", "core", false}, + {"stable default", "v3.1.0", "", false}, + {"development default", "dev", "", false}, + {"invalid beta default", "dev-beta.invalid", "", false}, + {"explicit Energyplan", "v3.1.0", "energyplan", true}, + {"beta default", "v3.1.0-beta.1", "", energyplanSupported(runtime.GOOS, runtime.GOARCH)}, + } { + t.Run(tc.name, func(t *testing.T) { + Version = tc.version + cfg, _ := plannerEngineConfig(&config.Planner{Enabled: true, Engine: tc.engine}) + cfg.Drivers = nil + svc := buildMPC(cfg, nil, nil, nil) + if svc != nil && svc.Optimizer != nil { + defer svc.Optimizer.Close() + } + if (svc != nil) != tc.want { + t.Fatalf("batteryless admission=%v, want %v", svc != nil, tc.want) + } + if svc != nil && (!svc.OptimizerBundledWithCore() || svc.Defaults.CapacityWh != 0) { + t.Fatal("batteryless admission requires Energyplan without invented storage") + } + }) + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index f98b76f84..5451fcb4d 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -3694,7 +3694,7 @@ func aggregateBatteryFleetLimits(cfg *config.Config, fleet []mpc.BatteryFleetMem } // buildMPC constructs a planner from config. Returns nil if disabled, -// or if prices aren't configured. EV planning also works without home storage. +// or if prices aren't configured. Energyplan also supports sites without storage. // The skip reason is the same vocabulary /api/mpc/diagnose exposes. func buildMPC(cfg *config.Config, st *state.Store, tel *telemetry.Store, capacities map[string]float64) *mpc.Service { plannerOn := cfg.Planner != nil && cfg.Planner.Enabled @@ -3704,7 +3704,8 @@ func buildMPC(cfg *config.Config, st *state.Store, tel *telemetry.Store, capacit } fleet := mpcBatteryFleetFromConfig(cfg, capacities) totalCap, maxChg, maxDis := aggregateBatteryFleetLimits(cfg, fleet) - switch mpc.UnavailableReason(plannerOn, priceProvider, totalCap) { + engine := plannerEngine(cfg.Planner, Version) + switch mpc.UnavailableReason(plannerOn, priceProvider, totalCap, engine == config.PlannerEngineEnergyplan) { case mpc.ReasonPlannerDisabled: return nil case mpc.ReasonNoPriceProvider: @@ -3787,7 +3788,6 @@ func buildMPC(cfg *config.Config, st *state.Store, tel *telemetry.Store, capacit svc.UpdateBatteryFleet(fleet, totalCap, maxChg, maxDis) // Release defaults select the beta worker. An explicit engine wins; // Core DP remains available as an explicit choice and as fallback. - engine := plannerEngine(pl, Version) if engine == config.PlannerEngineEnergyplan { binary := resolveEnergyplanBinary() ext, err := mpc.NewEnergyplanOptimizer(binary) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 7e60f8884..11feee031 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -18,6 +18,7 @@ import ( "math" "net/http" "path/filepath" + "runtime" "sort" "strconv" "strings" @@ -2420,12 +2421,14 @@ func (s *Server) mpcUnavailableReason() string { return "" } var plannerOn bool + var supportsBatteryless bool var priceProvider string if s.deps.CfgMu != nil { s.deps.CfgMu.RLock() } if s.deps.Cfg != nil { plannerOn = s.deps.Cfg.Planner != nil && s.deps.Cfg.Planner.Enabled + supportsBatteryless = s.deps.Cfg.Planner.EngineForBuild(s.deps.Version, runtime.GOOS, runtime.GOARCH) == config.PlannerEngineEnergyplan if s.deps.Cfg.Price != nil { priceProvider = s.deps.Cfg.Price.Provider } @@ -2445,7 +2448,7 @@ func (s *Server) mpcUnavailableReason() string { if s.deps.CapMu != nil { s.deps.CapMu.RUnlock() } - return mpc.UnavailableReason(plannerOn, priceProvider, totalCap) + return mpc.UnavailableReason(plannerOn, priceProvider, totalCap, supportsBatteryless) } func (s *Server) handleMPCPlan(w http.ResponseWriter, r *http.Request) { diff --git a/go/internal/api/api_mpc_unavailable_test.go b/go/internal/api/api_mpc_unavailable_test.go index 20ef7137b..defd21ad1 100644 --- a/go/internal/api/api_mpc_unavailable_test.go +++ b/go/internal/api/api_mpc_unavailable_test.go @@ -33,6 +33,37 @@ func TestMPCDisabledEndpointsNameTheSkipReason(t *testing.T) { }, want: mpc.ReasonNoPriceProvider, }, + { + name: "batteryless explicit Core in beta", + deps: &Deps{Version: "v3.1.0-beta.1", Cfg: &config.Config{ + Planner: &config.Planner{Enabled: true, Engine: "core"}, + Price: &config.Price{Provider: "elprisetjustnu"}, + }}, + want: mpc.ReasonNoBatteryCapacity, + }, + { + name: "batteryless stable default", + deps: &Deps{Version: "v3.1.0", Cfg: &config.Config{ + Planner: &config.Planner{Enabled: true}, + Price: &config.Price{Provider: "elprisetjustnu"}, + }}, + want: mpc.ReasonNoBatteryCapacity, + }, + { + name: "batteryless development default", + deps: &Deps{Cfg: &config.Config{ + Planner: &config.Planner{Enabled: true}, + Price: &config.Price{Provider: "elprisetjustnu"}, + }}, + want: mpc.ReasonNoBatteryCapacity, + }, + { + name: "batteryless explicit Energyplan has no capacity gate", + deps: &Deps{Version: "v3.1.0", Cfg: &config.Config{ + Planner: &config.Planner{Enabled: true, Engine: "energyplan"}, + Price: &config.Price{Provider: "elprisetjustnu"}, + }}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/go/internal/config/planner_engine.go b/go/internal/config/planner_engine.go new file mode 100644 index 000000000..3b7fc7659 --- /dev/null +++ b/go/internal/config/planner_engine.go @@ -0,0 +1,24 @@ +package config + +import ( + "regexp" + "strings" +) + +var plannerBetaVersion = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$`) + +// EngineForBuild applies the same release default for planner admission and +// API diagnostics. Explicit choices, including legacy aliases, take precedence. +func (p *Planner) EngineForBuild(version, goos, goarch string) string { + if p != nil && strings.TrimSpace(p.Engine) != "" { + return p.EngineName() + } + if plannerBetaVersion.MatchString(version) && SupportsBundledEnergyplan(goos, goarch) { + return PlannerEngineEnergyplan + } + return PlannerEngineCore +} + +func SupportsBundledEnergyplan(goos, goarch string) bool { + return (goos == "linux" && (goarch == "amd64" || goarch == "arm64")) || (goos == "darwin" && goarch == "arm64") +} diff --git a/go/internal/config/planner_engine_test.go b/go/internal/config/planner_engine_test.go new file mode 100644 index 000000000..2c798238f --- /dev/null +++ b/go/internal/config/planner_engine_test.go @@ -0,0 +1,29 @@ +package config + +import "testing" + +func TestPlannerEngineForBuild(t *testing.T) { + for _, tc := range []struct { + version, engine, goos, goarch, want string + }{ + {"v3.1.0-beta.1", "", "linux", "arm64", PlannerEngineEnergyplan}, + {"v3.1.0-beta.1", "", "linux", "amd64", PlannerEngineEnergyplan}, + {"v3.1.0-beta.1", "", "darwin", "arm64", PlannerEngineEnergyplan}, + {"v3.1.0-beta.1", "", "windows", "amd64", PlannerEngineCore}, + {"v3.1.0-beta.1", "", "linux", "arm", PlannerEngineCore}, + {"v3.1.0-beta.1", "core", "linux", "arm64", PlannerEngineCore}, + {"v3.1.0", "", "linux", "arm64", PlannerEngineCore}, + {"dev", "", "linux", "arm64", PlannerEngineCore}, + {"dev-beta.invalid", "", "linux", "arm64", PlannerEngineCore}, + {"v3.1.0-beta.", "", "linux", "arm64", PlannerEngineCore}, + {"v3.1.0-beta.1-extra", "", "linux", "arm64", PlannerEngineCore}, + {"v3.1.0-rc.1", "", "linux", "arm64", PlannerEngineCore}, + {"v3.1.0", "Energyplan", "linux", "arm64", PlannerEngineEnergyplan}, + {"dev", "python", "linux", "arm64", PlannerEngineEnergyplan}, + } { + p := &Planner{Engine: tc.engine} + if got := p.EngineForBuild(tc.version, tc.goos, tc.goarch); got != tc.want { + t.Errorf("%+v: engine=%s", tc, got) + } + } +} diff --git a/go/internal/mpc/energyplan.go b/go/internal/mpc/energyplan.go index 0814c7f91..035b0dec2 100644 --- a/go/internal/mpc/energyplan.go +++ b/go/internal/mpc/energyplan.go @@ -31,7 +31,9 @@ func energyplanTimeBudget(slots []Slot, p Params) time.Duration { batteries = 1 } assets := 3*batteries + 2*len(p.activeLoadpoints()) - if len(slots)*assets >= 193*6 || p.PVCurtailment.MinW > 0 || p.PVUncertaintyW > 0 || p.PVRelativeUncertainty > 0 { + // Core already adjusts PV to one downside horizon. That margin does not + // add worker scenarios or change this model's size. + if len(slots)*assets >= 193*6 || p.PVCurtailment.MinW > 0 { return 5 * time.Second } return 500 * time.Millisecond diff --git a/go/internal/mpc/energyplan_test.go b/go/internal/mpc/energyplan_test.go index 9d3c2fb0d..dc049d04f 100644 --- a/go/internal/mpc/energyplan_test.go +++ b/go/internal/mpc/energyplan_test.go @@ -55,10 +55,15 @@ func TestCoreDPShadowCancellation(t *testing.T) { } func TestNativeEnergyplanDownsideAndAsyncShadow(t *testing.T) { - o := nativeWorker(t, 500*time.Millisecond) + template := nativeWorker(t, 500*time.Millisecond) + defer template.Close() + o, err := NewEnergyplanOptimizer(template.cfg.Command[0]) + if err != nil { + t.Fatal(err) + } t.Cleanup(func() { o.Close() }) svc := shadowTestService(t) - svc.Optimizer = &EnergyplanOptimizer{ExternalOptimizer: o} + svc.Optimizer = o info, err := svc.Optimizer.(*EnergyplanOptimizer).Health(context.Background()) if err != nil || info.Name != "ftw-solver" || info.Version != "0.3.0" { t.Fatalf("bundled worker health: %+v %v", info, err) @@ -72,6 +77,7 @@ func TestNativeEnergyplanDownsideAndAsyncShadow(t *testing.T) { } } svc.PVUncertaintyW = func() float64 { return 200 } + svc.PVRelativeUncertainty = func() float64 { return .1 } svc.PVForecastSafetyK = 1 svc.PV = func(time.Time, float64) float64 { return 1500 } var published atomic.Bool @@ -110,13 +116,17 @@ func TestNativeEnergyplanDownsideAndAsyncShadow(t *testing.T) { t.Fatal(err) } for _, slot := range input.Slots { - if math.Abs(slot.PVW-(-1300)) > .001 { + // The learned relative error replaces the absolute fallback: 1500 - 10%. + if math.Abs(slot.PVW-(-1350)) > .001 { t.Fatalf("wrong downside PV: %f", slot.PVW) } } if len(input.Scenarios) != 0 || input.Settings.CVaRWeight != 0 { t.Fatal("Energyplan received scenarios") } + if input.Settings.TimeLimitS != .5 { + t.Fatalf("deterministic downside request budget=%g, want 0.5", input.Settings.TimeLimitS) + } } func TestCoreDPShadowDoesNotAttachToNewerPlan(t *testing.T) { @@ -264,6 +274,7 @@ func TestNativeEnergyplanUsesBoundedFleetBudget(t *testing.T) { func TestBatterylessEVBudgetDoesNotInventStorage(t *testing.T) { slots, p := topologyFixture(0, 2) + p.PVUncertaintyW, p.PVRelativeUncertainty = 200, .1 horizon := make([]Slot, 193) for i := range horizon { horizon[i] = slots[0] diff --git a/go/internal/mpc/physical_restore_test.go b/go/internal/mpc/physical_restore_test.go index 8035f242f..71f166675 100644 --- a/go/internal/mpc/physical_restore_test.go +++ b/go/internal/mpc/physical_restore_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "testing" "time" + + "github.com/srcfl/ftw/go/internal/state" ) func physicalRestoreFixture(t *testing.T, batteries, evs int, pvSlot int) (Plan, Params, *Diagnostic, time.Time) { @@ -111,6 +113,21 @@ func TestNativeShadowPreservesCurrentExecutionButCannotActivateArchive(t *testin o := nativeWorker(t, 500*time.Millisecond) t.Cleanup(func() { o.Close() }) svc := shadowTestService(t) + // Hourly fixtures can fall outside Service's 15-minute lookback. Keep a + // current slot so this test checks execution permission at any wall time. + if _, err := svc.Store.ClearPrices(); err != nil { + t.Fatal(err) + } + start := time.Now().UTC().Add(-time.Minute).Truncate(time.Minute) + for i := 0; i < 4; i++ { + if err := svc.Store.SavePrices([]state.PricePoint{{ + Zone: svc.Zone, SlotTsMs: start.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(), + SlotLenMin: 15, SpotOreKwh: 50 + float64(i)*40, TotalOreKwh: 100 + float64(i)*80, + Source: "test", FetchedAtMs: time.Now().UnixMilli(), + }}); err != nil { + t.Fatal(err) + } + } svc.Optimizer = &EnergyplanOptimizer{ExternalOptimizer: o} plan := svc.Replan(context.Background()) if plan == nil || plan.Solver == nil || plan.Solver.Fallback || len(plan.Actions[0].StoragePowerW) == 0 { diff --git a/go/internal/mpc/unavailable.go b/go/internal/mpc/unavailable.go index d896415bf..5de7ab24f 100644 --- a/go/internal/mpc/unavailable.go +++ b/go/internal/mpc/unavailable.go @@ -11,14 +11,14 @@ const ( // UnavailableReason is the skip gate buildMPC already logs. Empty means // the planner should have been constructed. -func UnavailableReason(plannerEnabled bool, priceProvider string, totalCapacityWh float64) string { +func UnavailableReason(plannerEnabled bool, priceProvider string, totalCapacityWh float64, supportsBatteryless bool) string { if !plannerEnabled { return ReasonPlannerDisabled } if priceProvider == "" || priceProvider == "none" { return ReasonNoPriceProvider } - if totalCapacityWh < 0 { + if totalCapacityWh < 0 || (totalCapacityWh == 0 && !supportsBatteryless) { return ReasonNoBatteryCapacity } return "" diff --git a/go/internal/mpc/unavailable_test.go b/go/internal/mpc/unavailable_test.go index 9120edd30..6c7aef9ed 100644 --- a/go/internal/mpc/unavailable_test.go +++ b/go/internal/mpc/unavailable_test.go @@ -5,25 +5,27 @@ import "testing" func TestUnavailableReasonOrder(t *testing.T) { t.Parallel() cases := []struct { - name string - enabled bool - provider string - capWh float64 - want string + name string + enabled bool + provider string + capWh float64 + batteryless bool + want string }{ - {"disabled wins even with price and battery", false, "nordpool", 10000, ReasonPlannerDisabled}, - {"no provider", true, "", 10000, ReasonNoPriceProvider}, - {"provider none", true, "none", 10000, ReasonNoPriceProvider}, - {"without storage is supported", true, "nordpool", 0, ""}, - {"negative capacity is empty pool", true, "nordpool", -1, ReasonNoBatteryCapacity}, - {"ready", true, "nordpool", 9600, ""}, + {"disabled wins even with price and battery", false, "nordpool", 10000, true, ReasonPlannerDisabled}, + {"no provider", true, "", 10000, true, ReasonNoPriceProvider}, + {"provider none", true, "none", 10000, true, ReasonNoPriceProvider}, + {"without storage supported by engine", true, "nordpool", 0, true, ""}, + {"without storage rejected by engine", true, "nordpool", 0, false, ReasonNoBatteryCapacity}, + {"negative capacity is empty pool", true, "nordpool", -1, true, ReasonNoBatteryCapacity}, + {"ready", true, "nordpool", 9600, false, ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - if got := UnavailableReason(tc.enabled, tc.provider, tc.capWh); got != tc.want { - t.Fatalf("UnavailableReason(%v, %q, %v) = %q, want %q", - tc.enabled, tc.provider, tc.capWh, got, tc.want) + if got := UnavailableReason(tc.enabled, tc.provider, tc.capWh, tc.batteryless); got != tc.want { + t.Fatalf("UnavailableReason(%v, %q, %v, %v) = %q, want %q", + tc.enabled, tc.provider, tc.capWh, tc.batteryless, got, tc.want) } }) } diff --git a/optimizer/native/README.md b/optimizer/native/README.md index d23733f91..9207068bc 100644 --- a/optimizer/native/README.md +++ b/optimizer/native/README.md @@ -35,7 +35,9 @@ explicitly, or `core` to select Core DP. Stable and development builds keep Core as the unset default; Windows has no bundled worker. Energyplan uses the same downside PV forecast as Core. Small requests get a -500 ms solve budget; larger fleets and PV-control or risk requests get 5 s. +500 ms solve budget; larger fleets and PV-control requests get 5 s. +Core applies PV uncertainty once when forming the downside horizon. This does +not add worker scenarios or extend the solve budget by itself. The transport timeout is 7 s. After Core validates and publishes a plan, one Core DP shadow runs with a 10 s limit when Core DP can represent the site. Its result appears in @@ -58,6 +60,8 @@ A time limit can return a feasible plan with a remaining cost gap. An unknown bound is null; without a feasible candidate the worker returns a budget error. Core DP fallback cannot represent every fleet. In that case Core keeps the previous plan for diagnosis and withholds execution until a new plan succeeds. +Selecting Core DP, including the stable/development default, requires positive +home-battery capacity. Energyplan permits sites without home storage. Core only permits a planned PV generation cap when it verifies the loaded driver and current telemetry for the site's complete PV control domain. A