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/planner-admission-budget.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 2 additions & 9 deletions go/cmd/ftw/energyplan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
32 changes: 32 additions & 0 deletions go/cmd/ftw/energyplan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
}
6 changes: 3 additions & 3 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"math"
"net/http"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down
31 changes: 31 additions & 0 deletions go/internal/api/api_mpc_unavailable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions go/internal/config/planner_engine.go
Original file line number Diff line number Diff line change
@@ -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")
}
29 changes: 29 additions & 0 deletions go/internal/config/planner_engine_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
4 changes: 3 additions & 1 deletion go/internal/mpc/energyplan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions go/internal/mpc/energyplan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]
Expand Down
17 changes: 17 additions & 0 deletions go/internal/mpc/physical_restore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions go/internal/mpc/unavailable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
30 changes: 16 additions & 14 deletions go/internal/mpc/unavailable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Expand Down
6 changes: 5 additions & 1 deletion optimizer/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading