diff --git a/.changeset/energyplan-beta-gates.md b/.changeset/energyplan-beta-gates.md new file mode 100644 index 00000000..749af197 --- /dev/null +++ b/.changeset/energyplan-beta-gates.md @@ -0,0 +1,11 @@ +--- +"ftw": patch +--- + +Keep the latest Core DP comparison queued when a replan cancels the previous +shadow. Cancellation no longer overwrites a comparison with a rejected verdict. +Reject active zero PV caps until dispatch can execute them. Keep the Python +PV-charge bonus aligned with Core in every mode. On hosts without a compiled +worker, verify bundle integrity and skip execution tests; keep Core as default. +Keep the release default when saving planner settings, and offer Energyplan +and Core DP as explicit choices. diff --git a/Makefile b/Makefile index 88882643..1884fc15 100644 --- a/Makefile +++ b/Makefile @@ -294,4 +294,6 @@ native-solver-check: python3 -m unittest discover -s optimizer/native -p verify_test.py native-solver-test: native-solver-check - cd go && FTW_NATIVE_SOLVER="$$(python3 ../optimizer/native/verify.py --host-binary)" go test -count=1 ./internal/mpc -run '^TestNative' + @binary="$$(python3 optimizer/native/verify.py --host-binary)"; \ + if [ -n "$$binary" ]; then cd go && FTW_NATIVE_SOLVER="$$binary" go test -count=1 ./internal/mpc -run '^TestNative'; \ + else echo "Native execution tests skipped: no bundled worker for this host"; fi diff --git a/go/cmd/ftw/energyplan.go b/go/cmd/ftw/energyplan.go index dc9129e0..60dc5b6b 100644 --- a/go/cmd/ftw/energyplan.go +++ b/go/cmd/ftw/energyplan.go @@ -13,7 +13,7 @@ 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 && runtime.GOOS != "windows" { + if base, valid := releaseVersionBase(version); valid && base != version && energyplanSupported(runtime.GOOS, runtime.GOARCH) { return config.PlannerEngineEnergyplan } return config.PlannerEngineCore @@ -40,3 +40,7 @@ func resolveEnergyplanBinary() string { // fallback reason instead of silently changing the configured engine. return filepath.Join("/app/optimizer/native/bundle", name) } + +func energyplanSupported(goos, goarch string) bool { + return (goos == "linux" && (goarch == "amd64" || goarch == "arm64")) || (goos == "darwin" && goarch == "arm64") +} diff --git a/go/cmd/ftw/energyplan_test.go b/go/cmd/ftw/energyplan_test.go index d01316d4..27b75880 100644 --- a/go/cmd/ftw/energyplan_test.go +++ b/go/cmd/ftw/energyplan_test.go @@ -17,7 +17,7 @@ func TestEnergyplanBetaSelection(t *testing.T) { {"v2.15.0-beta.1", "python", "python"}, {"dev", "Energyplan", "energyplan"}, } { - if runtime.GOOS == "windows" && tc.engine == "" { + if !energyplanSupported(runtime.GOOS, runtime.GOARCH) && tc.engine == "" { tc.want = "core" } if got := plannerEngine(&config.Planner{Engine: tc.engine}, tc.version); got != tc.want { @@ -27,7 +27,7 @@ func TestEnergyplanBetaSelection(t *testing.T) { } func TestBuildMPCBetaStartsBundledEnergyplan(t *testing.T) { - if runtime.GOOS == "windows" { + if !energyplanSupported(runtime.GOOS, runtime.GOARCH) { t.Skip("no Windows worker") } old := Version diff --git a/go/internal/mpc/core_dp_shadow.go b/go/internal/mpc/core_dp_shadow.go index 4883b2cd..8207c369 100644 --- a/go/internal/mpc/core_dp_shadow.go +++ b/go/internal/mpc/core_dp_shadow.go @@ -2,15 +2,29 @@ package mpc import ( "context" + "errors" "log/slog" "time" ) +type coreDPShadowRequest struct { + champion Plan + slots []Slot + params Params + reason string + replanAtMs int64 +} + // startCoreDPShadow runs at most one bounded comparison, after publication. // Results belong to a decision ID and can never replace the active actions. func (s *Service) startCoreDPShadow(champion Plan, slots []Slot, p Params, reason string, replanAtMs int64) { s.mu.Lock() - if s.stopping || s.shadowBusy { + if s.stopping || s.last == nil || s.last.DecisionID != champion.DecisionID { + s.mu.Unlock() + return + } + if s.shadowBusy { + s.pendingCoreShadow = &coreDPShadowRequest{champion, slots, p, reason, replanAtMs} s.mu.Unlock() return } @@ -25,12 +39,13 @@ func (s *Service) startCoreDPShadow(champion Plan, slots []Slot, p Params, reaso if r := recover(); r != nil { slog.Error("mpc: Core DP shadow panicked", "panic", r, "decision_id", champion.DecisionID) } - s.mu.Lock() - s.shadowBusy, s.shadowCancel = false, nil - s.mu.Unlock() + s.finishCoreDPShadow() }() start := time.Now() shadow, err := OptimizeContext(ctx, slots, p) + if errors.Is(err, context.Canceled) { + return + } if err == nil { err = ValidatePlan(slots, p, &shadow) } @@ -63,6 +78,17 @@ func (s *Service) startCoreDPShadow(champion Plan, slots []Slot, p Params, reaso }() } +func (s *Service) finishCoreDPShadow() { + s.mu.Lock() + s.shadowBusy, s.shadowCancel = false, nil + pending := s.pendingCoreShadow + s.pendingCoreShadow = nil + s.mu.Unlock() + if pending != nil { + s.startCoreDPShadow(pending.champion, pending.slots, pending.params, pending.reason, pending.replanAtMs) + } +} + func replayedGridCost(slots []Slot, p Params, plan Plan) float64 { total := 0.0 for i, slot := range slots { diff --git a/go/internal/mpc/energyplan_test.go b/go/internal/mpc/energyplan_test.go index 4a120b45..3b822cdf 100644 --- a/go/internal/mpc/energyplan_test.go +++ b/go/internal/mpc/energyplan_test.go @@ -146,3 +146,46 @@ func TestNativeEnergyplanRejectsUnsafeFallback(t *testing.T) { t.Fatalf("infeasible worker and unsafe DP fallback published: %+v", plan) } } + +func TestCoreDPShadowKeepsNewestPendingComparison(t *testing.T) { + svc := shadowTestService(t) + base := svc.Replan(context.Background()) + if base == nil { + t.Fatal("no base plan") + } + svc.shadowBusy = true + first := *base + first.DecisionID = "first" + svc.last = &first + svc.startCoreDPShadow(first, svc.lastSlots, svc.lastParams, "first", 1) + second := *base + second.DecisionID = "second" + svc.last = &second + svc.startCoreDPShadow(second, svc.lastSlots, svc.lastParams, "second", 2) + if svc.pendingCoreShadow == nil || svc.pendingCoreShadow.champion.DecisionID != "second" { + t.Fatal("newest comparison was not retained") + } + svc.finishCoreDPShadow() + svc.shadowWG.Wait() + latest := svc.Latest() + if latest.DecisionID != "second" || latest.DPShadow == nil || latest.DPShadow.ComparedSlots == 0 { + t.Fatalf("newest comparison did not finish: %+v", latest.DPShadow) + } +} + +func TestCoreDPShadowCancellationPreservesPreviousComparison(t *testing.T) { + svc := shadowTestService(t) + slots, p := nativeBenchmarkFixture(true) + p.SoCLevels, p.ActionLevels = 101, 201 + previous := &ShadowPlan{TotalCostOre: 123} + champion := Plan{DecisionID: "same", DPShadow: previous} + svc.last = &champion + svc.startCoreDPShadow(champion, slots, p, "cancel", 0) + svc.mu.Lock() + svc.shadowCancel() + svc.mu.Unlock() + svc.shadowWG.Wait() + if svc.Latest().DPShadow != previous { + t.Fatal("cancellation replaced a comparison with rejection") + } +} diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index b0e1886b..e9ce10cd 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -706,6 +706,11 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { } totalLoadpointW += powerW } + // Dispatch currently uses a positive limit to activate PV curtailment. + // A true zero cap cannot execute yet, so it must not become a plan. + if a.PVCurtailActive && a.PVLimitW == 0 { + return fmt.Errorf("slot %d active zero PV cap cannot be dispatched", i) + } if a.PVLimitW < 0 || (a.PVLimitW > 0 && a.PVLimitW > -slot.PVW+2) { return fmt.Errorf("slot %d pv_limit_w %.3f exceeds forecast generation %.3f", i, a.PVLimitW, -slot.PVW) } diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index db303bb9..1605aaec 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -259,6 +259,7 @@ type Service struct { shadowWG sync.WaitGroup lastPythonShadow *ShadowPlan lastPythonShadowFor string + pendingCoreShadow *coreDPShadowRequest shadowErrWindows map[string]shadowErrWindow stop chan struct{} diff --git a/go/internal/mpc/validate_dp_test.go b/go/internal/mpc/validate_dp_test.go index 083e3f40..8f7d88da 100644 --- a/go/internal/mpc/validate_dp_test.go +++ b/go/internal/mpc/validate_dp_test.go @@ -94,7 +94,7 @@ func TestValidatePlanRejectsFuseViolatingIdle(t *testing.T) { } } -func TestValidatePlanAcceptsActiveZeroPVCap(t *testing.T) { +func TestValidatePlanRejectsUndispatchableZeroPVCap(t *testing.T) { slots := []Slot{{ StartMs: 1, LenMin: 60, PriceOre: 100, SpotOre: -100, Confidence: 1, LoadW: 0, PVW: -5000, @@ -109,8 +109,8 @@ func TestValidatePlanAcceptsActiveZeroPVCap(t *testing.T) { PVLimitW: 0, PVCurtailActive: true, }}, } - if err := ValidatePlan(slots, p, &plan); err != nil { - t.Fatalf("active zero cap: %v", err) + if err := ValidatePlan(slots, p, &plan); err == nil { + t.Fatal("accepted a zero PV cap that dispatch cannot execute") } plan.Actions[0].PVCurtailActive = false diff --git a/optimizer/ftw_optimizer/model.py b/optimizer/ftw_optimizer/model.py index 0250638f..2fc7675f 100644 --- a/optimizer/ftw_optimizer/model.py +++ b/optimizer/ftw_optimizer/model.py @@ -233,11 +233,7 @@ def _arbitrage_spread_ore_kwh(settings: dict[str, Any], mode: str) -> float: def _pv_charge_bonus_ore_kwh(settings: dict[str, Any], mode: str) -> float: - """Return the PV-charge bonus only for passive_arbitrage. - - Parse in every mode so a malformed value still fails at the contract - boundary. Go DP applies this bias only in passive_arbitrage. - """ + """Return the configured PV-charge bonus in every mode, matching Core.""" bonus = max( 0.0, @@ -246,8 +242,6 @@ def _pv_charge_bonus_ore_kwh(settings: dict[str, Any], mode: str) -> float: "settings.pv_charge_bonus_ore_kwh", ), ) - if mode != "passive_arbitrage": - return 0.0 return bonus diff --git a/optimizer/native/verify.py b/optimizer/native/verify.py index 8cd7ae89..1cd2acbb 100644 --- a/optimizer/native/verify.py +++ b/optimizer/native/verify.py @@ -66,9 +66,7 @@ def verify_bundle(root): def host_key(): machine = {"aarch64": "arm64", "arm64": "arm64", "x86_64": "amd64", "amd64": "amd64"}.get(platform.machine().lower()) key = f"{platform.system().lower()}-{machine}" - if key not in PLATFORMS: - raise ValueError(f"No bundled Energyplan worker for {key}") - return key + return key if key in PLATFORMS else None def check_public_tree(): @@ -90,7 +88,12 @@ def main(): check_public_tree() root = HERE / "bundle" manifest = verify_bundle(root) - binary = root / manifest["artifacts"][host_key()]["path"] + host = host_key() + if host is None: + if not args.host_binary: + print(f"Verified Energyplan {manifest['version']}: bundle integrity passed; no worker for this host, execution skipped") + return + binary = root / manifest["artifacts"][host]["path"] if args.host_binary: print(binary) return diff --git a/optimizer/native/verify_test.py b/optimizer/native/verify_test.py index e7d4ed4e..9127987f 100644 --- a/optimizer/native/verify_test.py +++ b/optimizer/native/verify_test.py @@ -4,8 +4,9 @@ import shutil import tempfile import unittest +from unittest.mock import patch -from verify import HERE, verify_bundle +from verify import HERE, verify_bundle, host_key class BundleBoundaryTest(unittest.TestCase): @@ -15,6 +16,11 @@ def setUp(self): self.root = Path(self.temp.name) / "bundle" shutil.copytree(HERE / "bundle", self.root) + def test_unsupported_host_keeps_integrity_checks(self): + with patch("verify.platform.system", return_value="Darwin"), patch("verify.platform.machine", return_value="x86_64"): + self.assertIsNone(host_key()) + self.assertEqual(verify_bundle(self.root)["product"], "energyplan") + def test_valid_bundle(self): self.assertEqual(verify_bundle(self.root)["product"], "energyplan") diff --git a/optimizer/tests/test_model.py b/optimizer/tests/test_model.py index a8edd2d4..fcef95a5 100644 --- a/optimizer/tests/test_model.py +++ b/optimizer/tests/test_model.py @@ -32,12 +32,12 @@ from ftw_optimizer.worker import handle, handshake -def test_pv_charge_bonus_matches_go_dp_mode_gate() -> None: +def test_pv_charge_bonus_matches_go_dp_in_every_mode() -> None: settings = {"pv_charge_bonus_ore_kwh": 30} assert _pv_charge_bonus_ore_kwh(settings, "passive_arbitrage") == 30 - assert _pv_charge_bonus_ore_kwh(settings, "arbitrage") == 0 - assert _pv_charge_bonus_ore_kwh(settings, "self_consumption") == 0 - assert _pv_charge_bonus_ore_kwh(settings, "cheap_charge") == 0 + assert _pv_charge_bonus_ore_kwh(settings, "arbitrage") == 30 + assert _pv_charge_bonus_ore_kwh(settings, "self_consumption") == 30 + assert _pv_charge_bonus_ore_kwh(settings, "cheap_charge") == 30 def test_pv_curtail_output_distinguishes_zero_cap_from_release() -> None: diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js index 579126aa..c4aebae4 100644 --- a/web/settings/tabs/planner.js +++ b/web/settings/tabs/planner.js @@ -49,6 +49,19 @@ return "σ right now ≈ " + sigma + " W → hedge = k·σ ≈ " + Math.round(kn * sigma) + " W"; } + function engineSelect(engine, help) { + var selected = String(engine == null ? "" : engine).trim().toLowerCase(); + if (selected === "go" || selected === "dp") selected = "core"; + var options = [["", "Automatic (release default)"], ["energyplan", "Energyplan"], + ["core", "Core DP"], ["python", "Python"]]; + return ''; + } + S.tabs.planner = { render: function (ctx) { var field = ctx.field, selectField = ctx.selectField, help = ctx.help, config = ctx.config; @@ -90,12 +103,12 @@ '' + '
The solver and scenario controls below apply to Python. Energyplan uses a fixed 500 ms solve limit.
' + '