fix: price the discharge gate off the cell the battery is actually in - #579
fix: price the discharge gate off the cell the battery is actually in#579johanzander wants to merge 2 commits into
Conversation
`_record_marginal_value` did its own index arithmetic, snapping a continuous SoE to the NEAREST value-function grid point with `round()` and taking a backward difference there. Every other consumer of the value function walks `_interpolate_value`, which floors. A state in the lower half of a cell was therefore priced off the cell below -- a region of V the battery is not in -- so the reported marginal value stepped half-way between grid points instead of at cell boundaries, and the sub-period discharge gate reads that number raw. For SOLAR_STORAGE the planned rate is 0, so `max(planned, gate)` makes the gate the entire hardware decision: a misread there shows up as one isolated period holding while the periods either side discharge, importing from a battery with headroom to spare. The new `_value_slope_below` returns the left one-sided derivative of that same interpolant. Left-sided because the gate authorizes energy *leaving* the battery, so what it must price is the value given up going down -- which is also why it is not `_local_value_slope`, the right-sided reading the tie detector uses for a noise magnitude. Reusing that one would over-open the gate. A one-sided derivative is discontinuous at each grid point, so it needs a tolerance the continuous interpolant does not: a battery resting exactly on its reserve floor yields idx = +1e-16 rather than 0.0 in several fixtures, and without the snap that ulp would price a kWh below the floor that does not exist and reopen the ceiling #526 closed. Goldens regenerated deliberately: 142 of 2168 gate booleans flip (141 opening a wrongly-closed gate, 1 closing), with actions, intents, SoE trajectories and cost bit-identical -- the fix moves no energy, it only stops the gate answering for the wrong state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017tGYJGUZax9nSQeDXZ36uj
The reporter's numbers do reproduce — and this PR does not change themPosting this so the finding isn't re-derived. It resolves the PR's first stated They reproduce at period 58, not 59against the bundle's recorded Inputs: horizon 134, This PR's sweep is on the period-59 fixture (horizon 133), which is a Run against this PR's head, the reported period is unchangedSame replay, checked out at Bit-identical to pre-fix It can't be otherwise: at And that generalises for this reporter. SoC arrives as whole percent, so SoE is is always an integer. Every optimization's entry SoE lands exactly on a grid What is actually wrong at that stateThe concavity violation #571 documents. On the production grid: One interior cell above both neighbours, which a concave Scored against that 0.005 reference across all 37 fixtures, split by whether the
Reading the slope off the upper concave envelope gives Also worth noting: the issue's suggested metric (gate-flip rate between adjacent Two smaller points
Suggested disposition
The reproduction is a ~20-line script on top of the existing |
…r each Follow-up on top of 8ab435f (#579); no behaviour change. _value_slope_below carried two concerns inline: the ulp-tolerant snap of a continuous SoE onto the value grid, and the rule for whether a cell exists below that state at all. Both are now named. - _snapped_grid_index owns the tolerance. A one-sided derivative is discontinuous at every grid point, so an index a single ulp off one answers for the wrong cell -- a battery resting exactly on its reserve floor yields idx = +1e-16 rather than 0.0 in several fixtures. - has_value_cell_below is public because tests need to select exactly the periods where the shadow price is computable. Re-deriving that rule test-side is what #571 broke: the selectors mirrored the DP's old round() snapping and kept passing while production had moved to the interpolant's cell. One owner means a future change to the rule cannot leave the tests asserting against a state the DP no longer produces. soe_levels became unused in _record_marginal_value once the index arithmetic moved, so it is dropped from the function and both call sites, along with the now-dead soe_levels construction in optimize_battery_schedule. models.py and dp_constants.py comments updated: both still described shadow_price as a backward difference between adjacent grid levels, which #579 replaced. ./scripts/quality-check.sh passes (exit 0, 0 errors, 0 warnings). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2NCCmmhjBxgFGQE6zZLTS
4a relocates intra_period_discharge_gate into core/bess/execution_model.py (D1) and is required to stay behaviour-neutral so 4b's delta stays readable. Two open gate fixes sit in exactly that code, and nothing in the plan said so -- both were only recorded on their own issues and in a commit message 4a's author has no reason to read. #579 is the one with a structural consequence for 4a: it adds _value_slope_below as a third peer dV/dSoE estimator and a new PUBLIC has_value_cell_below on dp_battery_algorithm.py, exposed so the gate tests stop mirroring the DP's index rule. Both are new surface on a module 4a is about to empty, so 4a should absorb them rather than inherit them at their current home. #571 is recorded because it is easy to assume #579 closed it. It does not: at the reported state idx = 324.0 sits exactly on a grid point, so round() and ceil()-1 pick the same cell and the half-cell correction is zero there. Its actual mechanism is np.round snapping making V locally non-concave. The reproduce-from-period-58 note is included because the period-59 fixture is a different run and matches nothing in the bundle -- that already cost one session a wrong "does not reproduce" conclusion. States plainly that neither fix depends on 4a technically, so the hold can later be overruled on its merits rather than mistaken for a hard dependency. ./scripts/quality-check.sh passes (exit 0, 0 errors, 0 warnings). Claude-Session: https://claude.ai/code/session_01Y2NCCmmhjBxgFGQE6zZLTS Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…el leaf (#586) * feat(optimizer): Phase 4a — platform capability model + execution_model 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 * fix(optimizer): separate "rate is a ceiling" from "load support covers 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 * fix(optimizer): make the no-rate/exact-cover contradiction unrepresentable 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 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
_value_slope_below, the left one-sided derivative of the interpolant_interpolate_valuedefines, and routes_record_marginal_valuethrough it.actions,intents,soe_trajectoryand cost bit-identical.Root cause
_record_marginal_valuedid its own index arithmetic (dp_battery_algorithm.py:908):round()snaps to the nearest grid point._interpolate_value(:1436) and_local_value_slope(:1461) both floor. So for any SoE in the lower half of a cell the gate was priced off the cell below — a region ofVthe battery is not in — while the policy walked a different curve.Visible directly on the reporter's data, sweeping
initial_soeat production resolution. The reported slope changes atfrac0.4→0.6, i.e. half-way between rungs, not at the rung boundaries:9.8800and9.8950are both inside cell[323, 324]and must price the same kWh identically. That is the plateau-centred-on-the-rung signature ofround().For SOLAR_STORAGE the planned rate is 0, so
max(planned, gate)(battery_system_manager.py:2688) makes the gate the entire hardware decision, which is why a misread surfaces as an isolated 0% period rather than a small cost wobble.Not reproduced: the issue's specific numbers (shadow 0.25554, gate CLOSED at 9.9000) do not reproduce in my replay — I get 0.20806 and an OPEN gate, presumably a horizon/terminal-value difference from the reporter's production run. The defect reproduces exactly; that one closed period does not, and periods 60/61 were never in the bundle. The mechanism and the corpus-wide effect below stand on their own evidence.
Fix
_value_slope_below(V_row, soe, battery_settings) -> float | Nonereturns the left one-sided derivative of the same piecewise-linear interpolant the policy walks. Left-sided because the gate authorizes energy leaving the battery, so it must price the value given up going down.Deliberately not
_local_value_slope: that is the right-sided reading, correct for the tie detector's noise magnitude and wrong here. Measured on the corpus it flips 395 decisions against this fix's 142, nearly all opening — a systematic over-open, not a fix.tie_detection/ the PWL re-solve are deliberately not wired in.epsilon_for_periodis a currency band on two candidate values, not a EUR/kWh level — converting means dividing bySOE_STEP_KWH, which is the hand-derived epsilon P5 forbids — and the PWL window'sVis solved with its exit SoE pinned (:2111), so its slope prices an artificial terminal constraint.One thing found while implementing, worth a reviewer's eye: a one-sided derivative is discontinuous at each grid point, so it needs a tolerance the continuous interpolant does not. A battery resting exactly on its reserve floor yields
idx = +1e-16rather than0.0in several fixtures; without_GRID_POINT_TOLERANCEthat ulp prices a kWh below the floor that does not exist and reopens the ceiling #526 closed. The existingtest_discharge_gate_authorization_526caught this on the first implementation (6 floor periods wrongly authorized).soe_levelsbecame unused in_record_marginal_valueand was dropped from it and its two call sites.Test plan
./scripts/quality-check.shpasses (1853 passed, 31 skipped; Black/Ruff/frontend/TS/ESLint clean).venv/bin/pytest -m slowpasses (537 passed, 6 skipped, 3m47s)realworld_2026_04_24_090423, period 47) and drove the decision through the realBatterySystemManager._apply_period_schedule:That is the reporter's symptom — the register that was writing 0% now writes 100%.
docker-compose.ci.yml, real backend + mock-HA) brought up and healthy; prices fetched, health checks green. The scheduler could not be driven to a fresh optimization there because the scenario pins a pastmock_timeand an accelerated container clock does not produce optimization runs in this project, so the observation above was taken through the real BSM path instead.Evidence the test discriminates
lo = int(np.ceil(...)) - 1→lo = min(round(idx), len(V_row) - 1) - 1, i.e. the pre-bug: discharge gate reads a raw snapped V grid difference while the policy uses _interpolate_value — shadow price 42% above the optimizer's own objective #571 nearest-grid-point snap.test_shadow_price_is_constant_within_one_grid_cellFAILED (obtained 0.19499, expected 0.20806), and 20 of 38test_action_selector_paritygolden tests FAILED.Stated plainly:
test_gate_slope_stays_on_the_policys_interpolantdoes not redden under that mutation — at the offsets it uses,round()andceil-1pick the same cell. It is a standing guard against the two estimators drifting apart again, not a reproduction of this bug. The real-data cell test and the goldens are what catch it.Outcome-level coverage
intra_period_discharge_allowedre-pinned across 21 fixtures. Measured delta: 142 of 2168 periods flip, 141 opening a wrongly-closed gate, 1 closing.actions,intents,soe_trajectoryand cost are bit-identical on all 35 existing fixtures; I verified the regeneration itself touched no other field.regression_2026_08_13_145213built from the reporter's debug bundle viafrom_debug_log.py, plus its golden and a VPP baseline entry (--add-newonly, no full re-baseline).run_scenario_realized/ R == P test, deliberately: the gate moves no planned energy, andinverter_simulator.py:194-196computesdeficit = max(0, home - solar)for the load_first modes SOLAR_STORAGE/SOLAR_EXPORT map to, which is structurally zero at point-forecast resolution for exactly the intents that select them (the file argues this itself at:118-126). An R == P test would pass identically with the gate open or closed and would be coverage theatre. Reviewed and agreed independently.Scope assessment
Local — stays inside
_record_marginal_value's existing contract (same inputs, same two fields written); the new helper is a peer of the two existing value estimators in their established owner file. No parameter, flag, default-fallback, second construction site, or extra trigger routing around an ordering/timing problem: the diff removes a mirrored value estimator, which is what P1 exists to prevent.Documentation
docs/agents/bess-knowledge.mdupdated — it describedshadow_priceas a "backward difference between adjacent SoE grid levels", the exact mechanism this replaces. Added how the slope is now read and the bundle signature of the old bug.docs/SOFTWARE_DESIGN.mdmentions neithershadow_pricenor the gate, so nothing to change there.Refs #571 — does not close it. The reported period (58,
soe = 9.9000)is unchanged by this PR:
idx = 324.0sits exactly on a grid point, soround()andceil()-1select the same cell and the half-cell correction isidentically zero there. Verified against this branch's head; see the
reproduction in the comments. #571's own mechanism is the concavity violation
at that cell, which is a separate fix.
Blocked on Phase 4a: this adds a third dV/dSoE estimator, which the parent
plan records as a deferred behaviour change, and 4a (approved D1) relocates
intra_period_discharge_gateintocore/bess/execution_model.py.