fix(optimizer): keep SoC-above-max plans replay-consistent - #773
Conversation
ed427e1 to
082717c
Compare
082717c to
d4ca608
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d4ca6084f8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| mip_gap = float(value) | ||
| break | ||
| solve_ms = (time.perf_counter() - started) * 1000.0 | ||
| _validate_storage_replay(actions, slots, [storage.spec for storage in storages]) |
There was a problem hiding this comment.
Retry replay failures in shared and recourse solvers
When formulation: relaxed encounters negative-price slots, the continuous shared model can simultaneously charge and discharge after reaching the maximum SoC, so this newly added validation raises ReplayConsistencyError; worker.handle then returns internal_error instead of an optimizer plan. This is reproducible in cheap_charge mode even from an in-bound initial SoC, and the analogous validation in recourse.py fails identically. Unlike the multistage path, neither solver retries with mutually exclusive charge/discharge constraints, so both should apply the same guarded retry before returning an error.
Useful? React with 👍 / 👎.
| float(spec["capacity_wh"]), | ||
| float(spec.get("min_energy_wh", 0)), | ||
| float(spec.get("max_energy_wh", spec["capacity_wh"])), | ||
| float(spec["initial_energy_wh"]), |
There was a problem hiding this comment.
Keep changing SoC out of the DPP cache key
Including initial_energy_wh in the model-shape key makes every normal replanning tick miss _MODEL_CACHE, because measured battery energy changes between requests and the one-entry cache immediately evicts the previous compiled model. The initial energy is already represented by CompiledMultistage.initial_energy and updated in assign(), so it does not affect model shape; this addition defeats the DPP cache and forces an expensive CVXPY rebuild on every changing-SoC solve.
Useful? React with 👍 / 👎.
What changed
Canonicalize storage state once before any shared, recourse, or multistage model, scenario, or service-level builder runs. Solver-scale initial energy above the operating maximum is replaced with the exact maximum value within the documented
1e-6 Whphysical-input tolerance plus float representation error. An internal guard marker preserves the original over-bound signal across repeated normalization, while material over-band starts still use the mutually exclusive charge/discharge formulation.The canonical storage specs are used for model parameters, multistage shared constraints, service-level solves, direct-HiGHS/CVXPY fallback paths, response construction, cache keys, and replay validation. Core validation and its tolerances are unchanged. Replay validation still rejects simultaneous cycling that causes an energy mismatch and forces the guarded retry.
Regression coverage
autoandcvxpywith cheap-charge, two 60-minute zero-load/zero-PV slots, and initial deltas0.1e-6,0.25e-6,0.5e-6,0.99e-6, and1e-6 Wh.9800 Wh.66 passed.Verification
8d39cd91c82095948663cbf4e3114da8f299e11bafter build: run one base across the whole stack (debian:trixie-slim) #731.Dockerfile.optimizer; it is present only in the master base, not in this diff.make verify-allpassed, including Linux arm64, Linux amd64, and Windows amd64 cross-compiles.d4ca6084f8aa140bc0b1913e6b46cb827abad0cais green: test run30897784909, repo hygiene30897784857, changeset-check30897784808, and brand-cleanup30897784955. The test run passed optimizer (Python + contract) and Go test + vet; unrelated jobs were path-skipped.d4ca6084f8aa140bc0b1913e6b46cb827abad0ca.Closes #768
The independent review reported no P0/P1/P2 findings. PR is ready for review and merge. Core validation was not weakened.