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
11 changes: 11 additions & 0 deletions .changeset/energyplan-beta-gates.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion go/cmd/ftw/energyplan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
}
4 changes: 2 additions & 2 deletions go/cmd/ftw/energyplan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
34 changes: 30 additions & 4 deletions go/internal/mpc/core_dp_shadow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions go/internal/mpc/energyplan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
5 changes: 5 additions & 0 deletions go/internal/mpc/external_optimizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions go/internal/mpc/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ type Service struct {
shadowWG sync.WaitGroup
lastPythonShadow *ShadowPlan
lastPythonShadowFor string
pendingCoreShadow *coreDPShadowRequest
shadowErrWindows map[string]shadowErrWindow

stop chan struct{}
Expand Down
6 changes: 3 additions & 3 deletions go/internal/mpc/validate_dp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
8 changes: 1 addition & 7 deletions optimizer/ftw_optimizer/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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


Expand Down
11 changes: 7 additions & 4 deletions optimizer/native/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion optimizer/native/verify_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")

Expand Down
8 changes: 4 additions & 4 deletions optimizer/tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 17 additions & 4 deletions web/settings/tabs/planner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<label for="planner-engine">Engine ' +
help("Automatic uses Energyplan in supported beta builds and Core DP elsewhere. Energyplan runs Core DP as a shadow and uses it as fallback. Changing the engine requires a restart.") +
'</label><select id="planner-engine" data-path="planner.engine">' +
options.map(function (option) {
return '<option value="' + option[0] + '"' + (selected === option[0] ? ' selected' : '') + '>' + option[1] + '</option>';
}).join("") + '</select>';
}

S.tabs.planner = {
render: function (ctx) {
var field = ctx.field, selectField = ctx.selectField, help = ctx.help, config = ctx.config;
Expand Down Expand Up @@ -90,12 +103,12 @@
'</label>' +
'<div id="planner-active-strategy" style="font-family:var(--mono);margin:2px 0 12px">—</div>' +
'<div class="field-row"><div>' +
selectField("Engine", "planner.engine", ["python", "dp"], "python",
"Python runs the CVXPY mathematical optimizer. DP is the emergency rollback engine.") +
engineSelect(planner.engine, help) +
'</div><div>' +
selectField("Solver", "planner.optimizer_solver", ["HIGHS", "CLARABEL"], "HIGHS",
selectField("Python solver", "planner.optimizer_solver", ["HIGHS", "CLARABEL"], "HIGHS",
"HiGHS handles LP and MILP. CLARABEL is available only for continuous convex formulations.") +
'</div></div>' +
'<p style="color:var(--text-dim);font-size:0.8rem">The solver and scenario controls below apply to Python. Energyplan uses a fixed 500 ms solve limit.</p>' +
'<div class="field-row"><div>' +
selectField("Formulation", "planner.optimizer_formulation", ["auto", "milp", "relaxed"], "auto",
"Auto introduces integer variables only when physics or discrete asset steps require them.") +
Expand Down Expand Up @@ -225,5 +238,5 @@
};

// Escape hatch for node --test (planner.test.mjs); not a public API.
S.tabs.planner._pure = { strategyLabel: strategyLabel, hedgeLine: hedgeLine };
S.tabs.planner._pure = { strategyLabel: strategyLabel, hedgeLine: hedgeLine, engineSelect: engineSelect };
})();
22 changes: 18 additions & 4 deletions web/settings/tabs/planner.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import assert from "node:assert/strict";
globalThis.window = {};
await import("./planner.js");
const tab = globalThis.window.FTWSettings.tabs.planner;
const { strategyLabel, hedgeLine } = tab._pure;
const { strategyLabel, hedgeLine, engineSelect } = tab._pure;

describe("strategyLabel", () => {
it("maps every planner mode via the local fallback", () => {
Expand Down Expand Up @@ -99,13 +99,13 @@ describe("render", () => {
assert.ok(top.includes('data-checkbox-path="planner.enabled"'));
assert.ok(top.includes("[field:planner.soc_min]"));
assert.ok(top.includes("[field:planner.soc_max]"));
assert.ok(!top.includes("[select:planner.engine]"));
assert.ok(!top.includes('data-path="planner.engine"'));
assert.ok(!top.includes("CLARABEL"));
assert.ok(!top.includes("[select:planner.optimizer_solver]"));
assert.match(rest, /<details class="engine-details">/);
assert.doesNotMatch(html, /<details[^>]*\sopen\b/);
assert.ok(rest.includes("Engine controls — leave these unless you are debugging."));
assert.ok(rest.includes("[select:planner.engine]"));
assert.ok(rest.includes('data-path="planner.engine"'));
assert.ok(rest.includes("[select:planner.optimizer_solver]"));
assert.ok(rest.includes("[field:planner.optimizer_cvar_weight]"));
});
Expand All @@ -125,7 +125,7 @@ describe("render", () => {

it("renders mathematical optimizer controls", () => {
const html = tab.render(stubCtx());
assert.ok(html.includes("[select:planner.engine]"));
assert.ok(html.includes('data-path="planner.engine"'));
assert.ok(html.includes("[select:planner.optimizer_solver]"));
assert.ok(html.includes("[select:planner.optimizer_formulation]"));
assert.ok(html.includes("[field:planner.optimizer_timeout_s]"));
Expand Down Expand Up @@ -165,3 +165,17 @@ describe("render", () => {
assert.equal(ctx.config.planner.soc_max, 0.92);
});
});

// Exercise the rendered values that captureCurrentTab saves, including old aliases.
describe("engine selection", () => {
for (const engine of [undefined, null, "", " ", "core", "go", "dp", "python", "energyplan"]) {
it("preserves the configured choice on save: " + String(engine), () => {
const html = engineSelect(engine, () => "");
const selected = [...html.matchAll(/<option value="([^"]*)" selected>/g)].map(m => m[1]);
const expected = ["go", "dp"].includes(engine) ? "core" : String(engine ?? "").trim();
assert.deepEqual(selected, [expected]);
assert.match(html, /value="energyplan"/);
assert.match(html, /Automatic \(release default\)/);
});
}
});