feat(optimizer): Phase 4a — platform capability model + execution_model leaf - #586
Conversation
…el leaf The optimizer could not see which platform would execute its plan. `discharge_rate_is_load_following` had zero occurrences in dp_battery_algorithm.py, pwl_window_dp.py and action_selector.py, so a SolaX or an SPH got the same action space as a Growatt MIN. 4a carries the platform into the DP, and moves the execution model to where both the selector and the simulator can reach it. D1 — new leaf `core/bess/execution_model.py`. Imports `settings` and `dp_constants` and nothing else (pinned by a test that parses its own import graph). Holds `intra_period_discharge_gate`, relocated out of `battery_system_manager`, so `simulation/inverter_simulator` no longer imports the orchestrator; also `INTENT_TO_MODE`, which `InverterController.INTENT_TO_MODE` now references rather than copies. D2 — `PlatformCapabilities`: discharge lattice, control model, mode vocabulary, minimum commandable gear, and discharge-rate semantics as the tri-state the design asked for -- ceiling / target / absent, because the period_list platforms have no per-period rate to interpret as either. `from_controller` derives it; BSM builds it from the live controller and passes it to `optimize_battery_schedule`, which threads it everywhere `discharge_resolution_kw` used to go. One object, one construction site. The #282 min-gear rule (floor(threshold/step)+1), previously restated in three places, now has one home. Behaviour: goldens and the 36-fixture corpus bit-identical (pytest -m slow: 538 passed, 5 skipped), as 4a requires so 4b's delta stays readable. The one intended change is #580: `_residual_cover_p` plans a *delivery*, which is only exact where the firmware delivers min(command, load), so it is now gated on ceiling semantics. Pinned by a plan-level test (chosen action + resulting grid import, not a candidate list); watched fail with the gate removed -- 2 failures, both parametrisations. Not absorbed, deliberately: #579/#571. #579 is unmerged so there was no code to move, and both sit in `_record_marginal_value`, which D1 does not relocate -- 4a moves the gate, not the value estimator that feeds it. Taking #579 would also have flipped 142 golden gate booleans inside the phase required to be behaviour-neutral. Recorded in the plan and design doc so the queue is not read as "done". ./scripts/quality-check.sh passes (0 errors, 0 warnings). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2NCCmmhjBxgFGQE6zZLTS
…s exactly" Three /code-review findings, all in 4a's semantic half. Verified against the controllers before acting; all three were real. 1. The cover gate was over-broad on solax-modbus VPP (medium). Gating `_residual_cover_p` on `discharge_rate_is_load_following` asks about the *rate register*, and on solax-modbus Growatt in VPP mode that register is a forced power -- but LOAD_SUPPORT never writes a rate there: #413 disables remote control for that intent and hands the period to the inverter's own load-following self-use (`_intent_to_vpp`). The cover IS delivered exactly, so 4a as first written silently withdrew #466's sunrise-crossover saving from that platform for no fidelity gain -- and contradicted the design's own platform table ("Growatt VPP -- no rate for load support since #413, natively load-following") and `_residual_cover_p`'s own docstring. The fix is a second, separately-declared capability rather than a cleverer derivation: `load_support_delivers_exact_cover` on the controllers, carried on `PlatformCapabilities`, gating the candidate. True on TOU-register platforms and on solax-modbus in both modes; False on native SolaX (never received #413 -- see the gap note in `_vpp_display_state`) and on the period-list platforms, which have no per-period control to deliver a partial cover with. `discharge_rate_is_load_following` keeps its own meaning for the intra-period gate, which does write a rate. New pin, watched fail with the gate keyed on the register's semantics: test_cover_candidate_survives_where_load_support_load_follows_without_a_ceiling. 2. Solis read two ways (low). It declares CONTROL_MODEL = "period_list" but inherited the base class's `discharge_rate_is_load_following = True`, so the optimizer saw "no per-period rate" while `_apply_period_schedule` raised its ceiling to 100 as if it load-followed. Solis now declares both explicitly (as SPH already did, for the same stated reason), and BSM reads both its planning and its apply-time gate through one `platform_capabilities` property -- one place the platform is interpreted, which is the phase's whole point. 3. Shared mutable mode vocabulary (low). `INTENT_TO_MODE` was one dict shared by the module constant, `InverterController.INTENT_TO_MODE` and every capability default, so one in-place write would rewrite the vocabulary for every platform at once -- and 4b is slated to build commands from it. Now a MappingProxyType; pinned by a test. Instances stay unhashable as a result, stated in the docstring rather than worked around; nothing keys on one. Goldens and the 36-fixture corpus remain bit-identical (pytest -m slow: 538 passed, 5 skipped). #580's scope narrows to native SolaX / SPH / Solis / Huawei -- changelog, plan and design doc updated to say so. ./scripts/quality-check.sh passes (0 errors, 0 warnings). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2NCCmmhjBxgFGQE6zZLTS
Code review (high) — three findings, all fixed in 9806f2c1. Cover-candidate gate was over-broad on solax-modbus VPP (medium). Gating Fixed with a second, separately-declared capability rather than a cleverer derivation: New pin, watched fail with the gate keyed on the register's semantics: 2. Solis read two ways (low). It declares 3. Shared mutable mode vocabulary (low). Re-verified after the fixes: goldens and the 36-fixture corpus still bit-identical ( Scope note: #580 now narrows to native SolaX / SPH / Solis / Huawei — Growatt keeps the candidate in both control modes. Changelog, plan and design doc updated to match. |
Code review — full diff passReviewed as a single diff pass, with source-context reads of every touched controller, the four Verified correct
No correctness defects in the capability threading itself; the refactor is behaviour-preserving on the default path and the one intended #580 change is correctly scoped. Findings1. Scenario: the next A three-line 2. MinorThe PR body's mutation-evidence block names |
…table Two findings from the PR #586 bot review. `PlatformCapabilities.__post_init__` now rejects `discharge_rate_semantics == "absent"` together with `load_support_delivers_exact_cover=True`. The invariant was upheld only by three hand-written ClassVars, and this branch exists partly because one of them was wrong -- Solis declared `period_list` while inheriting the base class's load-following True. The next period-list controller that repeats it now fails at construction instead of quietly planning an off-lattice delivery on hardware with no per-period rate (the #282/#580 shape). Pinned two ways: the pair directly, and every shipped controller read through `from_controller`. Verified the second one catches the real mistake -- reintroducing Solis's inherited True fails test_every_shipped_controller_satisfies_the_invariant with the ValueError, where the old assertion (semantics only) stayed green. `InverterController.INTENT_TO_MODE` is annotated `ClassVar[Mapping[str, str]]`, not `dict`: it binds a MappingProxyType now, so the dict annotation type-checked an in-place write that fails at import time. No mypy runs in CI, so nothing else would have flagged it. Every other combination stays legal, including target + exact cover -- that is solax-modbus VPP, and narrowing it would undo the previous commit. Fast suite 1868 passed / 31 skipped; slow suite 538 passed / 5 skipped (goldens still bit-identical); ./scripts/quality-check.sh 0 errors, 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2NCCmmhjBxgFGQE6zZLTS
Bot review — both findings fixed in 717c7b51. Cross-field invariant not enforced (medium) — agreed and fixed. Pinned two ways — the pair directly, and every shipped controller read through where the old semantics-only assertion stayed green — which was the finding's exact argument. Every other combination stays legal, including 2. Minor (stale test names in the PR body) — fixed. The body now names Re-verified: fast suite 1868 passed / 31 skipped, slow suite 538 passed / 5 skipped (goldens still bit-identical), Thanks for the |
What
Phase 4a of the optimizer target architecture (design doc §4/§4c, decisions D1 and D2). The optimizer could not see which platform would execute its plan:
discharge_rate_is_load_followinghad zero occurrences indp_battery_algorithm.py,pwl_window_dp.pyandaction_selector.py, so a SolaX or an SPH was handed the same action space as a Growatt MIN.D1 — new leaf
core/bess/execution_model.py. Importssettingsanddp_constantsand nothing else (pinned by a test that parses its own import graph). It holdsintra_period_discharge_gate, relocated out ofbattery_system_manager— sosimulation/inverter_simulatorno longer imports the orchestrator — andINTENT_TO_MODE, whichInverterController.INTENT_TO_MODEnow references instead of copying.D2 —
PlatformCapabilities. Discharge lattice, control model, mode vocabulary, minimum commandable gear, and discharge-rate semantics as the tri-state the design asked for:ceiling/target/absent— theperiod_listplatforms have no per-period rate to interpret as either.BatterySystemManagerbuilds it from the live controller and passes it tooptimize_battery_schedule, which threads it everywheredischarge_resolution_kwused to go. One object, one construction site. The #282 minimum-gear rule (floor(threshold / step) + 1), previously restated in three places, now has one home.Behaviour
Neutral, by requirement — goldens and the 36-fixture corpus are bit-identical, so 4b's delta stays readable.
pytest -m slow: 538 passed, 5 skipped.pytest -m "not slow": 1863 passed, 31 skipped.One intended change — #580.
_residual_cover_pplans a delivery, which is exact only where the inverter throttles to the real load. It was added unconditionally before, so on native SolaX / SPH / Solis / Huawei the DP planned a delivery the hardware will not produce (the #282 shape). It is now gated onload_support_delivers_exact_cover. Growatt keeps the candidate in both control modes — on solax-modbus VPP the rate register is a forced power, but #413 makes LOAD_SUPPORT release the period to native load-following, so the cover is delivered exactly (see the review thread below; the first cut of this PR got that wrong).Mutation evidence
The #580 test asserts the plan (chosen action and the resulting grid import), not a candidate list. With the capability gate removed from
_residual_cover_p, both parametrisations fail:Two further pins were watched fail the same way after review (details in the review thread): the VPP-mode pin with the gate keyed on the register's semantics, and
test_every_shipped_controller_satisfies_the_invariantwith Solis's inheritedTruereintroduced.Current: 19 passed in this file; fast suite 1868 passed / 31 skipped; slow suite 538 passed / 5 skipped.
Not absorbed, deliberately: #579 and #571
The plan asks 4a to take
_value_slope_belowand the new publichas_value_cell_belowwith it. It did not, for two reasons recorded in the plan and design doc:mainto move.dp_battery_algorithm._record_marginal_value. 4a moves the gate (the two-line ceiling function), not the value estimator that decides its input.Taking #579 would additionally have flipped 142 of 2168 golden gate booleans inside the phase that is required to be behaviour-neutral — exactly the confounding the sequencing exists to prevent. Land #579, then #571, each measuring its own delta.
Docs
optimizer-architecture.md(normative) gains an "as built, Phase 4a" note under P3 and listsexecution_model.pyamong the files it governs; the plan and design doc record 4a as built and restate the #579/#571 queue.🤖 Generated with Claude Code
https://claude.ai/code/session_01Y2NCCmmhjBxgFGQE6zZLTS