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
12 changes: 12 additions & 0 deletions .changeset/native-energyplan-worker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"ftw": minor
---

Use the compiled Energyplan worker first in beta releases when no planner engine
is set. Core validates its plan, then runs Core DP as a background shadow on the
same downside PV input. Core DP remains the validated fallback, with a visible
reason when it takes over. The worker and its license ship and update with Core;
source stays private. Explicit core and python settings keep their roles.

Reject EV plans above the battery limit and clip DP power at the operating band
so fallback energy matches the power it schedules.
5 changes: 5 additions & 0 deletions .changeset/optimizer-grid-limit-residue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Accept sub-watt solver residue at a slot's grid limit so an optimizer plan at the configured fuse ceiling does not trigger Go planner fallback. Larger import and export violations still fail validation.
5 changes: 5 additions & 0 deletions .changeset/planning-physics-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Go fallback plans are replayed against the same site-power and battery-energy identities as the mathematical optimizer before they can become the live plan. A true zero PV cap is now a distinct `pv_curtail_active` flag, so full curtailment is no longer serialized as “no cap”. A trajectory that cannot be reconstructed from the request is kept off dispatch.
2 changes: 2 additions & 0 deletions .github/brand/compatibility-allowlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@
^Makefile:.*-C "\$\$stage" ftw ftw-backup forty-two-watts.*$
^scripts/restore-full-backup\.sh:for candidate in ftw forty-two-watts; do$
^README\.md:Existing Forty Two Watts or older FTW deployments must use the$
# Rust's upstream runtime notices describe third-party licenses, not FTW's license.
^optimizer/native/bundle/rust-runtime/COPYRIGHT-library\.html:[[:space:]]*This project is triple-licensed under the MIT License, the Apache$
31 changes: 31 additions & 0 deletions .github/workflows/native-solver.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: native energy worker

on:
pull_request:
paths:
- 'optimizer/native/**'
- 'go/internal/mpc/**'
- 'Makefile'
- '.github/workflows/native-solver.yml'
push:
branches: [master]
paths:
- 'optimizer/native/**'
- 'go/internal/mpc/**'
- 'Makefile'
- '.github/workflows/native-solver.yml'

permissions:
contents: read

jobs:
native-solver:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: '1.26'
cache-dependency-path: go/go.sum
- name: Verify the binary bundle and Core integration
run: make native-solver-test
4 changes: 2 additions & 2 deletions .github/workflows/release-assets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -329,15 +329,15 @@ jobs:
cp "bin/${BINARY}" "${STAGE}/forty-two-watts.exe"
(cd "${STAGE}" && zip -q "../../release/ftw-${PLATFORM}.zip" ftw.exe ftw-backup.exe forty-two-watts.exe)
zip -qr "release/ftw-${PLATFORM}.zip" \
drivers web optimizer/pyproject.toml optimizer/ftw_optimizer config.example.yaml LICENSE NOTICE
drivers web optimizer/native/bundle optimizer/pyproject.toml optimizer/ftw_optimizer config.example.yaml LICENSE NOTICE
cp "release/ftw-${PLATFORM}.zip" "release/forty-two-watts-${PLATFORM}.zip"
else
cp "bin/${BINARY}" "${STAGE}/ftw"
cp "bin/ftw-backup-${PLATFORM}" "${STAGE}/ftw-backup"
ln -s ftw "${STAGE}/forty-two-watts"
tar czf "release/ftw-${PLATFORM}.tar.gz" \
-C "${STAGE}" ftw ftw-backup forty-two-watts \
-C ../.. drivers web optimizer/pyproject.toml optimizer/ftw_optimizer config.example.yaml LICENSE NOTICE
-C ../.. drivers web optimizer/native/bundle optimizer/pyproject.toml optimizer/ftw_optimizer config.example.yaml LICENSE NOTICE
cp "release/ftw-${PLATFORM}.tar.gz" "release/forty-two-watts-${PLATFORM}.tar.gz"
fi
(
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ COPY --from=builder --chown=100:101 /out/ftw /app/ftw
COPY --from=builder --chown=100:101 /out/ftw-backup /app/ftw-backup
COPY --chown=100:101 drivers/ /app/drivers/
COPY --chown=100:101 web/ /app/web/
COPY --chown=100:101 optimizer/native/bundle/ /app/optimizer/native/bundle/
COPY LICENSE NOTICE /usr/share/doc/ftw/

RUN ln -s /app/ftw /app/forty-two-watts && \
Expand Down
15 changes: 12 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ ci-hw-pi:
# verify-all adds cross-compile checks for all release targets, catching
# platform-specific syscall/import mistakes before push.

verify: test compose-migration-test container-boundary-test release-workflow-test
verify: test compose-migration-test container-boundary-test release-workflow-test native-solver-test

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip native execution checks on unsupported hosts

Making native-solver-test an unconditional dependency means make verify now always runs verify.py, whose host_key() rejects any host outside Linux amd64/arm64 and Darwin arm64 before the integrity-only checks can finish; Darwin amd64, Windows, and other development hosts therefore cannot run the project's standard verification even though the application deliberately falls back to Core there. Keep bundle checksum/unit checks universal, but conditionally skip the host handshake and native Go integration tests when no bundled executable matches the host.

AGENTS.md reference: AGENTS.md:L114-L115

Useful? React with 👍 / 👎.

cd go && go vet ./...
cd go && go build ./...
@echo "verify: vet + test + build clean"
Expand Down Expand Up @@ -222,7 +222,7 @@ release: drivers-present build-arm64 build-amd64 build-windows-amd64
ln -sf ftw "$$stage/forty-two-watts"; \
tar czf release/ftw-linux-$$arch.tar.gz \
-C "$$stage" ftw ftw-backup forty-two-watts \
-C ../.. drivers web optimizer/pyproject.toml optimizer/ftw_optimizer config.example.yaml LICENSE NOTICE; \
-C ../.. drivers web optimizer/native/bundle optimizer/pyproject.toml optimizer/ftw_optimizer config.example.yaml LICENSE NOTICE; \
cp "release/ftw-linux-$$arch.tar.gz" "release/forty-two-watts-linux-$$arch.tar.gz"; \
printf "built release/ftw-linux-%s.tar.gz (%s bytes)\n" "$$arch" \
"$$(wc -c <release/ftw-linux-$$arch.tar.gz)"; \
Expand All @@ -235,7 +235,7 @@ release: drivers-present build-arm64 build-amd64 build-windows-amd64
@cp bin/ftw-windows-amd64.exe bin/stage-windows-amd64/forty-two-watts.exe
@rm -f release/ftw-windows-amd64.zip release/forty-two-watts-windows-amd64.zip
@cd bin/stage-windows-amd64 && zip -q ../../release/ftw-windows-amd64.zip ftw.exe ftw-backup.exe forty-two-watts.exe
@zip -qr release/ftw-windows-amd64.zip drivers web optimizer/pyproject.toml optimizer/ftw_optimizer config.example.yaml LICENSE NOTICE
@zip -qr release/ftw-windows-amd64.zip drivers web optimizer/native/bundle optimizer/pyproject.toml optimizer/ftw_optimizer config.example.yaml LICENSE NOTICE
@cp release/ftw-windows-amd64.zip release/forty-two-watts-windows-amd64.zip
@cd release && for f in \
ftw-linux-arm64.tar.gz forty-two-watts-linux-arm64.tar.gz \
Expand Down Expand Up @@ -286,3 +286,12 @@ clean:
docs:
@echo "see docs/ for:"
@ls -1 docs/

# Optional proprietary worker: verify bundled artifacts and the Core boundary.
.PHONY: native-solver-check native-solver-test
native-solver-check:
python3 optimizer/native/verify.py
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'
2 changes: 1 addition & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ fleet_ping:
# worker in either role, champion or shadow.
# planner:
# enabled: true
# engine: core # core (default) | python (legacy external champion)
# # engine: energyplan # beta default; core or python select those explicitly
# shadow_python: true # run the external optimizer after each Core replan on
# # the same inputs and log the terminal-corrected cost
# # difference. Measurement only — never dispatched.
Expand Down
26 changes: 12 additions & 14 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ make dispatch unsafe.
|---|---|---|---|
| Core | [`go/cmd/ftw`](../go/cmd/ftw), [`go/internal`](../go/internal), [`web`](../web) | One Go binary | Configuration, telemetry, state, API/UI, safety, control and fallback planning |
| Drivers | Editable source in [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers); bundled recovery in `drivers/*.lua`; host in [`go/internal/drivers`](../go/internal/drivers) | One sandboxed Lua VM per configured device | Vendor protocol, sign conversion and device commands |
| Optimizer | [`optimizer`](../optimizer), contract in [`go/internal/mpc`](../go/internal/mpc) | Optional Python service/process | Solve the long-horizon mathematical plan |
| Optimizer | [`optimizer`](../optimizer), contract in [`go/internal/mpc`](../go/internal/mpc) | Compiled worker or optional Python service/process | Solve the long-horizon mathematical plan |

Core can run without the optimizer. Hardware cannot be accessed without a
driver, but one failed driver is isolated from the others. Optional
Expand Down Expand Up @@ -96,19 +96,17 @@ artifact, while activation remains explicit and atomic. See

## Optimizer

Core plans. Its DP solves the same problem the Python/CVXPY optimizer does, in
process, against the per-slot PV downside — measured within öre per plan of the
external MILP on replayed site snapshots (#1020).

The Python/CVXPY optimizer is optional and separately deployable. By default it
runs behind Core as a comparison shadow: after each replan it solves the same
inputs, and the terminal-corrected cost difference is logged and recorded on
the diagnostic. Shadow output never reaches dispatch, never delays a replan and
cannot fail one. `planner.engine: python` restores it as the champion during
the transition; then core sends a versioned planning request, accepts only a
complete valid trajectory, and falls back to its own DP if the socket/process
fails, times out or returns invalid output.

Beta 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`, `python`, or `energyplan` selects an engine explicitly.
Stable and development builds default to Core. The optional Python worker runs
as a shadow behind Core unless `planner.shadow_python: false` disables it.
Energyplan ships as compiled binaries with its own license; source and builds
stay in the private Energyplan repository. It updates with the Core image.
The optimizer never reads hardware or issues commands, so its deployment and
dependency churn do not enlarge the safety-critical runtime.

Expand Down
42 changes: 42 additions & 0 deletions go/cmd/ftw/energyplan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

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 && runtime.GOOS != "windows" {
return config.PlannerEngineEnergyplan
}
return config.PlannerEngineCore
}

func resolveEnergyplanBinary() string {
name := "ftw-solver-" + runtime.GOOS + "-" + runtime.GOARCH
candidates := []string{
"optimizer/native/bundle/" + name,
"../optimizer/native/bundle/" + name,
}
if exe, err := os.Executable(); err == nil {
candidates = append([]string{filepath.Join(filepath.Dir(exe), "optimizer/native/bundle", name)}, candidates...)
}
for _, candidate := range candidates {
if st, err := os.Stat(candidate); err == nil && st.Mode().IsRegular() {
absolute, err := filepath.Abs(candidate)
if err == nil {
return absolute
}
}
}
// Keep an unavailable primary attached: its failure produces an explicit
// fallback reason instead of silently changing the configured engine.
return filepath.Join("/app/optimizer/native/bundle", name)
}
45 changes: 45 additions & 0 deletions go/cmd/ftw/energyplan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package main

import (
"runtime"
"testing"

"github.com/srcfl/ftw/go/internal/config"
)

func TestEnergyplanBetaSelection(t *testing.T) {
for _, tc := range []struct{ version, engine, want string }{
{"v2.15.0-beta.1", "", "energyplan"},
{"v2.15.0", "", "core"},
{"dev", "", "core"},
{"dev-beta.invalid", "", "core"},
{"v2.15.0-beta.1", "core", "core"},
{"v2.15.0-beta.1", "python", "python"},
{"dev", "Energyplan", "energyplan"},
} {
if runtime.GOOS == "windows" && tc.engine == "" {
tc.want = "core"
}
if got := plannerEngine(&config.Planner{Engine: tc.engine}, tc.version); got != tc.want {
t.Errorf("%s / %q: got %s, want %s", tc.version, tc.engine, got, tc.want)
}
}
}

func TestBuildMPCBetaStartsBundledEnergyplan(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("no Windows worker")
}
old := Version
Version = "v2.15.0-beta.1"
t.Cleanup(func() { Version = old })
// The old Python sidecar environment must not route Energyplan to it.
t.Setenv("FTW_OPTIMIZER_TRANSPORT", "unix")
t.Setenv("FTW_OPTIMIZER_SOCKET", "/missing/python.sock")
cfg, capacities := plannerEngineConfig(&config.Planner{Enabled: true})
svc := buildMPC(cfg, nil, nil, capacities)
if svc == nil || !svc.OptimizerBundledWithCore() || svc.ShadowOptimizer != nil || svc.EnableRecourseShadow {
t.Fatalf("wrong beta wiring: %+v", svc)
}
t.Cleanup(func() { svc.Optimizer.Close() })
}
64 changes: 37 additions & 27 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2252,31 +2252,33 @@ func main() {
Bus: bus,
}, st)
selfUpdater.Start(ctx)
// Empty means "not known yet", which is the honest answer when the
// optimizer is still starting or its handshake is rejected. Claiming
// "dev" here made the checker treat the optimizer as older than every
// release and light the update badge on an up-to-date stable site.
// /api/components calls SetCurrentVersion once a handshake succeeds.
optimizerCurrent := ""
if worker := mpcSvc.ConfiguredOptimizer(); worker != nil {
if health, ok := worker.(interface {
Health(context.Context) (mpc.OptimizerRuntimeInfo, error)
}); ok {
healthCtx, healthCancel := context.WithTimeout(ctx, 2*time.Second)
if runtime, err := health.Health(healthCtx); err == nil && runtime.Version != "" {
optimizerCurrent = runtime.Version
if !mpcSvc.OptimizerBundledWithCore() {
// Empty means "not known yet", which is the honest answer when the
// optimizer is still starting or its handshake is rejected. Claiming
// "dev" here made the checker treat the optimizer as older than every
// release and light the update badge on an up-to-date stable site.
// /api/components calls SetCurrentVersion once a handshake succeeds.
optimizerCurrent := ""
if worker := mpcSvc.ConfiguredOptimizer(); worker != nil {
if health, ok := worker.(interface {
Health(context.Context) (mpc.OptimizerRuntimeInfo, error)
}); ok {
healthCtx, healthCancel := context.WithTimeout(ctx, 2*time.Second)
if runtime, err := health.Health(healthCtx); err == nil && runtime.Version != "" {
optimizerCurrent = runtime.Version
}
healthCancel()
}
healthCancel()
}
optimizerUpdater = selfupdate.New(selfupdate.Config{
Repo: "srcfl/ftw", Image: "srcfl/ftw-optimizer",
ReleaseTagPrefix: "optimizer-", StoragePrefix: "optimizer.",
CurrentVersion: optimizerCurrent,
SocketPath: envOr("FTW_UPDATER_SOCKET", "/run/ftw-update/sock"),
StatusPath: envOr("FTW_UPDATER_STATUS", "/run/ftw-update/state.json"),
}, st)
optimizerUpdater.Start(ctx)
}
optimizerUpdater = selfupdate.New(selfupdate.Config{
Repo: "srcfl/ftw", Image: "srcfl/ftw-optimizer",
ReleaseTagPrefix: "optimizer-", StoragePrefix: "optimizer.",
CurrentVersion: optimizerCurrent,
SocketPath: envOr("FTW_UPDATER_SOCKET", "/run/ftw-update/sock"),
StatusPath: envOr("FTW_UPDATER_STATUS", "/run/ftw-update/state.json"),
}, st)
optimizerUpdater.Start(ctx)
slog.Info("selfupdate enabled",
"socket", envOr("FTW_UPDATER_SOCKET", "/run/ftw-update/sock"),
"channel", selfUpdater.Info().Channel)
Expand Down Expand Up @@ -3912,11 +3914,19 @@ func buildMPC(cfg *config.Config, st *state.Store, tel *telemetry.Store, capacit
}
svc := mpc.New(st, tel, zone, params)
svc.UpdateBatteryFleet(fleet, totalCap, maxChg, maxDis)
// Core is the champion (#1020). The external optimizer keeps two roles:
// planner.engine: python restores it as champion for the transition, and
// planner.shadow_python runs it behind Core as a measurement.
engine := pl.EngineName()
if engine == config.PlannerEnginePython || pl.ShadowPythonEnabled() {
// Release defaults select the beta worker. An explicit engine wins;
// the Python comparison only runs behind an explicit/default Core plan.
engine := plannerEngine(pl, Version)
if engine == config.PlannerEngineEnergyplan {
binary := resolveEnergyplanBinary()
ext, err := mpc.NewEnergyplanOptimizer(binary)
if err != nil {
slog.Error("mpc: configure Energyplan failed", "err", err)
} else {
svc.Optimizer = ext
slog.Info("mpc: Energyplan primary with Core DP shadow and fallback", "binary", binary)
}
} else if engine == config.PlannerEnginePython || pl.ShadowPythonEnabled() {
transportMode := pl.OptimizerTransport
if fromEnv := os.Getenv("FTW_OPTIMIZER_TRANSPORT"); fromEnv != "" {
transportMode = fromEnv
Expand Down
5 changes: 3 additions & 2 deletions go/internal/api/api_components.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func (s *Server) handleComponents(w http.ResponseWriter, r *http.Request) {
optimizer := map[string]any{
"configured": true,
"role": role,
"bundled_with_core": s.deps.MPC.OptimizerBundledWithCore(),
"protocol_version": components.OptimizerProtocolVersion,
"protocol_min_version": components.OptimizerProtocolMinVersion,
}
Expand All @@ -52,13 +53,13 @@ func (s *Server) handleComponents(w http.ResponseWriter, r *http.Request) {
} else {
optimizer["healthy"] = true
optimizer["runtime"] = info
if s.deps.OptimizerUpdate != nil {
if s.deps.OptimizerUpdate != nil && !s.deps.MPC.OptimizerBundledWithCore() {
s.deps.OptimizerUpdate.SetCurrentVersion(info.Version)
}
}
}
applyLatestOptimizerPlanStatus(optimizer, s.deps.MPC.Latest())
if s.deps.OptimizerUpdate != nil {
if s.deps.OptimizerUpdate != nil && !s.deps.MPC.OptimizerBundledWithCore() {
if r.URL.Query().Get("force") == "1" {
if info, err := s.deps.OptimizerUpdate.Check(r.Context(), true); err != nil {
info.Err = err.Error()
Expand Down
Loading