Problem
The optimizer's core equations and Core-side replay are sound, but the full path from config and solver output to device dispatch does not preserve several decisions and hard limits. Further solver tuning cannot fix plans that runtime changes or metrics that cannot attribute optimizer value.
This issue records a read-only audit of checkout 8ca77904 and fetched origin/master c9c7919e. The relevant logic was the same on both.
Confirmed failures
1. Per-storage plans are validated, then discarded
The worker returns storage_power_w and storage_energy_wh, and Core validates each storage. mpc.SlotDirective then carries only aggregate fleet energy. Control creates a new split by mode, capacity, limits and PV locality.
A deterministic reproduction planned a feasible 100 W + 1900 W = 2000 W split. Runtime produced 100 W + 1000 W = 1100 W.
Impact:
- planned and delivered SoC paths differ;
- cost and terminal value use energy runtime may not deliver;
- a heterogeneous fleet may hit driver clamps although the optimizer plan passed validation.
2. Explicit zero power limits change meaning
MPC treats batteries.<id>.max_charge_w: 0 as "forbid charge". driverLimitsFrom only copies positive battery limits. Control treats zero as unset and falls back to MaxCommandW = 5000 W.
A config-to-control reproduction produced a +5000 W charge target from an explicit zero charge limit. Discharge has the same fault.
Related: #816 covers a separate slew/non-following-device failure that also exposes inconsistent zero/default handling.
3. PV-only export can exceed the declared hard ceiling
The fuse/export guard can scale battery discharge. If PV alone causes the overage, it returns unchanged targets and depends on a planner curtail directive. ComputePVCurtail does not react directly to fresh meter export above min(fuse, MaxExportW).
4. Full PV curtailment cannot be represented
pv_limit_w = 0 means both:
- no curtailment; and
- an active cap of zero watts.
Python, Go fallback, Core validation and driver dispatch all collide on this sentinel.
Reproduction: PV -5000 W, load 0 W, full battery, export price -100 ore/kWh. Python chooses zero grid flow and serializes a zero PV limit. Core interprets that as no cap, replays -5000 W grid flow and rejects the plan. Go fallback also cannot emit the full-curtail action.
5. An older replan can replace newer operator intent
Service.replan takes a params snapshot and always publishes its result. It has no request generation or latest-wins guard.
A deterministic test started a blocked self_consumption solve, completed a later SetMode(arbitrage) solve, then released the old solve. The old plan became active last.
6. Solver deadlines and worker queue ownership disagree
Core sends 0.8 * optimizer timeout. Python gives that full limit separately to service and economic solves. Total solver time can approach 1.6 * Core timeout, before model build and queue time.
The Unix worker starts one thread per connection but serializes solves with one global lock. A request that Core has already abandoned can finish and block newer requests.
7. USER_LIMIT is accepted as an optimal status
CVXPY USER_LIMIT can return ok: true without a proven service optimum. Core catches many physical inconsistencies, but a physically valid incumbent may still miss a feasible EV target or be much more expensive.
8. Mode-specific economic terms leak across modes
The Go contract applies min_arbitrage_spread_ore_kwh only to arbitrage modes. Python adds it to discharge cost in every mode.
Reproduction in self_consumption, 2 kW load and 80% SoC:
- spread 0: about 2050 W discharge;
- spread 100: no discharge.
Explicit relaxed can also allow simultaneous charge/discharge under unsafe tariffs and then fail replay.
9. Planner config can give Python and Go fallback different physics
Config validation does not reject:
soc_min_pct >= soc_max_pct;
- efficiency above 1;
- invalid per-battery SoC windows;
- invalid weights.
Python can reject the request while Go DP accepts and uses the same bad values. batteries.<id>.weight is currently not wired to control.
10. Current savings and backtest output cannot prove optimizer value
/api/savings/daily compares actual grid cost with a no-PV/no-battery baseline. It combines PV value, battery value, export revenue and EV timing. It is not incremental optimizer value.
The production cost integrator also applies the next sample across arbitrarily long telemetry gaps. A separate, currently unused savings integrator already rejects gaps over 20 minutes and reports coverage.
The optimizer backtest:
- sums overlapping plan horizons;
- may select a plan produced after a measured bucket;
- assumes planned battery power was delivered;
- ignores PV curtailment in realized grid replay;
- does not link plan revision, clamps, command results and measured per-device power.
Input and model gaps to measure after correctness fixes
- forecast.solar uses the maximum sample in an hour as the full-hour power;
- every forecast price gets fixed confidence
0.6 although the model stores sample counts and MAE;
- PV scenarios use fixed probabilities
0.60/0.25/0.15, one absolute spread, identical load and no price paths;
- local household and Nordic market time features use UTC buckets across CET/CEST;
- per-device efficiency, degradation cost and capability are not fed through the live planner contract.
Order of work
PR 1: preserve explicit zero battery limits
PR 2: make export protection and curtail state explicit
- React to fresh PV-only export above the effective ceiling.
- Separate
curtail active from limit_w.
- Validate and dispatch an active zero cap without converting it to release.
- Define driver support for a true zero cap or a documented minimum safe cap.
- Test partial cap, full cap, release, unsupported PV and residual uncontrollable export.
PR 3: make replans and solver work latest-wins
- Add a monotonic replan generation and commit only the newest request.
- Snapshot reason and mode with the request.
- Use one absolute deadline across queue, model build, service solve, economic solve and response.
- Drop expired/superseded work before it enters the solve lock.
- Do not accept
USER_LIMIT without a validated feasible incumbent and service proof.
Follow-up: execute the per-storage plan
- Carry per-device Wh budgets through
SlotDirective.
- Use runtime redistribution only for a device failure or hard clamp.
- Feed delivered per-device energy back into the next replan.
Follow-up: prove realized value
Persist and link:
- plan and model revision;
- forecast issue/source/revision;
- Core-validated target;
- clamp reason;
- driver command result;
- measured grid, PV, load, EV, battery power and SoC;
- data coverage.
Run leak-free walk-forward scoring one executed first action at a time. Compare no battery, simple self-consumption, Go DP, active Python/challenger and perfect-foresight lower bound with the same exogenous data and terminal energy.
Acceptance gates
- No configured hard limit changes meaning across layers.
- The plan Core validates is the plan control attempts to deliver.
- Older work cannot replace newer operator intent.
- Timed-out work cannot block a newer solve.
- Every accepted plan passes independent physical replay and required service checks.
- Optimizer gain is reported separately from PV and hardware value.
- Missing data lowers coverage instead of creating energy.
- A deterministic full-stack test runs optimizer -> validator -> dispatch -> driver simulator -> measured SoC/cost.
Current evidence
- 66 optimizer tests passed.
go test -race ./internal/mpc ./internal/control passed outside the sandbox.
- 611 golden dispatch records passed.
- Temporary targeted tests reproduced the fleet split, explicit-zero and stale-replan faults.
- No existing test runs the complete optimizer-to-realized-cost path.
Existing work with right of way
Implementation PRs must stack on or wait for those owners rather than replace their changes.
Problem
The optimizer's core equations and Core-side replay are sound, but the full path from config and solver output to device dispatch does not preserve several decisions and hard limits. Further solver tuning cannot fix plans that runtime changes or metrics that cannot attribute optimizer value.
This issue records a read-only audit of checkout
8ca77904and fetchedorigin/masterc9c7919e. The relevant logic was the same on both.Confirmed failures
1. Per-storage plans are validated, then discarded
The worker returns
storage_power_wandstorage_energy_wh, and Core validates each storage.mpc.SlotDirectivethen carries only aggregate fleet energy. Control creates a new split by mode, capacity, limits and PV locality.A deterministic reproduction planned a feasible
100 W + 1900 W = 2000 Wsplit. Runtime produced100 W + 1000 W = 1100 W.Impact:
2. Explicit zero power limits change meaning
MPC treats
batteries.<id>.max_charge_w: 0as "forbid charge".driverLimitsFromonly copies positive battery limits. Control treats zero as unset and falls back toMaxCommandW = 5000 W.A config-to-control reproduction produced a
+5000 Wcharge target from an explicit zero charge limit. Discharge has the same fault.Related: #816 covers a separate slew/non-following-device failure that also exposes inconsistent zero/default handling.
3. PV-only export can exceed the declared hard ceiling
The fuse/export guard can scale battery discharge. If PV alone causes the overage, it returns unchanged targets and depends on a planner curtail directive.
ComputePVCurtaildoes not react directly to fresh meter export abovemin(fuse, MaxExportW).4. Full PV curtailment cannot be represented
pv_limit_w = 0means both:Python, Go fallback, Core validation and driver dispatch all collide on this sentinel.
Reproduction: PV
-5000 W, load0 W, full battery, export price-100 ore/kWh. Python chooses zero grid flow and serializes a zero PV limit. Core interprets that as no cap, replays-5000 Wgrid flow and rejects the plan. Go fallback also cannot emit the full-curtail action.5. An older replan can replace newer operator intent
Service.replantakes a params snapshot and always publishes its result. It has no request generation or latest-wins guard.A deterministic test started a blocked
self_consumptionsolve, completed a laterSetMode(arbitrage)solve, then released the old solve. The old plan became active last.6. Solver deadlines and worker queue ownership disagree
Core sends
0.8 * optimizer timeout. Python gives that full limit separately to service and economic solves. Total solver time can approach1.6 * Core timeout, before model build and queue time.The Unix worker starts one thread per connection but serializes solves with one global lock. A request that Core has already abandoned can finish and block newer requests.
7.
USER_LIMITis accepted as an optimal statusCVXPY
USER_LIMITcan returnok: truewithout a proven service optimum. Core catches many physical inconsistencies, but a physically valid incumbent may still miss a feasible EV target or be much more expensive.8. Mode-specific economic terms leak across modes
The Go contract applies
min_arbitrage_spread_ore_kwhonly to arbitrage modes. Python adds it to discharge cost in every mode.Reproduction in
self_consumption, 2 kW load and 80% SoC:Explicit
relaxedcan also allow simultaneous charge/discharge under unsafe tariffs and then fail replay.9. Planner config can give Python and Go fallback different physics
Config validation does not reject:
soc_min_pct >= soc_max_pct;Python can reject the request while Go DP accepts and uses the same bad values.
batteries.<id>.weightis currently not wired to control.10. Current savings and backtest output cannot prove optimizer value
/api/savings/dailycompares actual grid cost with a no-PV/no-battery baseline. It combines PV value, battery value, export revenue and EV timing. It is not incremental optimizer value.The production cost integrator also applies the next sample across arbitrarily long telemetry gaps. A separate, currently unused savings integrator already rejects gaps over 20 minutes and reports coverage.
The optimizer backtest:
Input and model gaps to measure after correctness fixes
0.6although the model stores sample counts and MAE;0.60/0.25/0.15, one absolute spread, identical load and no price paths;Order of work
PR 1: preserve explicit zero battery limits
PowerLimitsand every clamp.PR 2: make export protection and curtail state explicit
curtail activefromlimit_w.PR 3: make replans and solver work latest-wins
USER_LIMITwithout a validated feasible incumbent and service proof.Follow-up: execute the per-storage plan
SlotDirective.Follow-up: prove realized value
Persist and link:
Run leak-free walk-forward scoring one executed first action at a time. Compare no battery, simple self-consumption, Go DP, active Python/challenger and perfect-foresight lower bound with the same exogenous data and terminal energy.
Acceptance gates
Current evidence
go test -race ./internal/mpc ./internal/controlpassed outside the sandbox.Existing work with right of way
go/cmd/ftw/main.goandgo/internal/control/dispatch.go.optimizer/ftw_optimizer/model.py; its active review thread covers shared post-curtailment baseline parity.go/internal/mpc/external_optimizer.go; they are draft/dirty.config.gowork.Implementation PRs must stack on or wait for those owners rather than replace their changes.