diff --git a/.changeset/stable-energyplan-default.md b/.changeset/stable-energyplan-default.md
new file mode 100644
index 00000000..3a30aa80
--- /dev/null
+++ b/.changeset/stable-energyplan-default.md
@@ -0,0 +1,5 @@
+---
+"ftw": patch
+---
+
+Use Energyplan by default on stable releases too, as betas already do. A beta box that moves to stable keeps the planner it has run, and a new stable install starts with Energyplan. Core DP still runs as shadow and fallback, and `planner.engine: core` still selects it.
diff --git a/config.example.yaml b/config.example.yaml
index e346fa3a..11b44b81 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -257,11 +257,11 @@ fleet_ping:
# ftw-drivers-2026-01: MX+j27UBkyM099hTyJlmMLK9qlTTDUJsaK/vH12fFKc=
# Refresh only updates the signed catalog. Install and activation stay explicit.
-# Energyplan ships with Core and runs first in supported beta builds.
+# Energyplan ships with Core and runs first in supported beta and stable builds.
# Core DP provides shadow comparisons and validated fallback plans.
# planner:
# enabled: true
-# # engine: energyplan # beta default; core selects the Go planner
+# # engine: energyplan # release default; core selects the Go planner
# mode: passive_arbitrage
# forecast_trust: balanced # cautious | balanced | bold (first boot; live value is SQLite)
# battery_export: unknown # unknown | not_allowed | allowed (unknown = no battery sale)
diff --git a/docs/architecture.md b/docs/architecture.md
index f0a681e4..8bfe7ff4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -222,14 +222,14 @@ remains explicit and atomic. See
## Optimizer
-Beta releases use the bundled Energyplan worker when `planner.engine` is unset
+Beta and stable releases use the bundled Energyplan worker when `planner.engine` is unset
on a supported host. It solves Core's downside PV forecast. Core validates its
plan before publishing it, then runs a bounded Core DP shadow on the same input.
A worker error, timeout or rejected plan invokes Core DP fallback. Core validates
fallback plans too; a failed validation leaves the prior plan in place.
`planner.engine: core` or `energyplan` selects an engine explicitly.
-Stable and development builds default to Core. Older `engine: python` values
+Development builds and other hosts default to Core. Older `engine: python` values
migrate to Energyplan; retired optimizer settings are ignored and omitted
when the configuration is saved.
Energyplan ships as compiled binaries with its own license; source and builds
diff --git a/go/cmd/ftw/energyplan_test.go b/go/cmd/ftw/energyplan_test.go
index 3fc90ebf..ddf70162 100644
--- a/go/cmd/ftw/energyplan_test.go
+++ b/go/cmd/ftw/energyplan_test.go
@@ -7,10 +7,11 @@ import (
"github.com/srcfl/ftw/go/internal/config"
)
-func TestEnergyplanBetaSelection(t *testing.T) {
+func TestEnergyplanReleaseSelection(t *testing.T) {
for _, tc := range []struct{ version, engine, want string }{
{"v2.15.0-beta.1", "", "energyplan"},
- {"v2.15.0", "", "core"},
+ {"v2.15.0", "", "energyplan"},
+ {"v0.140.1-dev.83867213", "", "core"},
{"dev", "", "core"},
{"dev-beta.invalid", "", "core"},
{"v2.15.0-beta.1", "core", "core"},
@@ -64,7 +65,7 @@ func TestBuildMPCBatterylessEngineAdmission(t *testing.T) {
want bool
}{
{"explicit Core in beta", "v3.1.0-beta.1", "core", false},
- {"stable default", "v3.1.0", "", false},
+ {"stable default", "v3.1.0", "", energyplanSupported(runtime.GOOS, runtime.GOARCH)},
{"development default", "dev", "", false},
{"invalid beta default", "dev-beta.invalid", "", false},
{"explicit Energyplan", "v3.1.0", "energyplan", true},
diff --git a/go/internal/api/api_mpc_unavailable_test.go b/go/internal/api/api_mpc_unavailable_test.go
index defd21ad..567020e3 100644
--- a/go/internal/api/api_mpc_unavailable_test.go
+++ b/go/internal/api/api_mpc_unavailable_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "runtime"
"testing"
"github.com/srcfl/ftw/go/internal/config"
@@ -12,6 +13,12 @@ import (
func TestMPCDisabledEndpointsNameTheSkipReason(t *testing.T) {
t.Parallel()
+ // Releases default to Energyplan where the worker ships, and Energyplan
+ // plans without a battery; elsewhere the default is Core DP.
+ stableDefault := mpc.ReasonNoBatteryCapacity
+ if config.SupportsBundledEnergyplan(runtime.GOOS, runtime.GOARCH) {
+ stableDefault = ""
+ }
cases := []struct {
name string
deps *Deps
@@ -47,7 +54,7 @@ func TestMPCDisabledEndpointsNameTheSkipReason(t *testing.T) {
Planner: &config.Planner{Enabled: true},
Price: &config.Price{Provider: "elprisetjustnu"},
}},
- want: mpc.ReasonNoBatteryCapacity,
+ want: stableDefault,
},
{
name: "batteryless development default",
diff --git a/go/internal/config/config.go b/go/internal/config/config.go
index 026e130e..6eb5f2e5 100644
--- a/go/internal/config/config.go
+++ b/go/internal/config/config.go
@@ -764,7 +764,7 @@ const (
PlannerEngineEnergyplan = "energyplan"
)
-// EngineName resolves an explicit engine. The launcher applies beta defaults
+// EngineName resolves an explicit engine. The launcher applies release defaults
// before calling this method; an unset value here keeps Core for other callers.
func (p *Planner) EngineName() string {
if p == nil {
diff --git a/go/internal/config/planner_engine.go b/go/internal/config/planner_engine.go
index cbb9110b..fbe37613 100644
--- a/go/internal/config/planner_engine.go
+++ b/go/internal/config/planner_engine.go
@@ -5,15 +5,17 @@ import (
"strings"
)
-var plannerBetaVersion = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$`)
+var plannerReleaseVersion = 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.
+// Beta and stable releases default to the bundled Energyplan worker on a
+// supported host; development builds default to Core.
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) {
+ if plannerReleaseVersion.MatchString(version) && SupportsBundledEnergyplan(goos, goarch) {
return PlannerEngineEnergyplan
}
return PlannerEngineCore
diff --git a/go/internal/config/planner_engine_test.go b/go/internal/config/planner_engine_test.go
index 7b510e93..27cb963b 100644
--- a/go/internal/config/planner_engine_test.go
+++ b/go/internal/config/planner_engine_test.go
@@ -12,7 +12,11 @@ func TestPlannerEngineForBuild(t *testing.T) {
{"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},
+ {"v3.1.0", "", "linux", "arm64", PlannerEngineEnergyplan},
+ {"v0.140.1", "", "linux", "amd64", PlannerEngineEnergyplan},
+ {"v0.140.1", "", "darwin", "arm64", PlannerEngineCore},
+ {"v0.140.1", "core", "linux", "arm64", PlannerEngineCore},
+ {"v0.140.1-dev.83867213", "", "linux", "arm64", PlannerEngineCore},
{"dev", "", "linux", "arm64", PlannerEngineCore},
{"dev-beta.invalid", "", "linux", "arm64", PlannerEngineCore},
{"v3.1.0-beta.", "", "linux", "arm64", PlannerEngineCore},
diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js
index e3fd11c8..daf961ef 100644
--- a/web/settings/tabs/planner.js
+++ b/web/settings/tabs/planner.js
@@ -102,7 +102,7 @@
var options = [["", "Automatic (release default)"], ["energyplan", "Energyplan"],
["core", "Core DP"]];
return '