fix: gate grid_first on export materiality instead of intent classification - #354
fix: gate grid_first on export materiality instead of intent classification#354pookey wants to merge 2 commits into
Conversation
04182e9 to
0815695
Compare
…cation A BATTERY_EXPORT classification (battery_to_grid > 0.01 kWh since johanzander#253) committed the inverter to grid_first with a discharge rate sized to the 15-minute forecast. grid_first does not load-follow: the rate is a forced power command, so any intra-period load spike above it imported at buy price for the rest of the period to protect the planned export. Measured on a live UK system: 17 periods in 5 days where grid_first delivered zero export while the house imported 3.2 kWh with battery energy available. Classification and hardware-mode selection were the same decision, so tightening the classification threshold in johanzander#253 (correctly, for accounting honesty) silently routed marginal periods into a hardware commitment whose downside (import at buy price) is asymmetric to its upside (pennies of export at sell price). Decouple them: InverterController._export_demoted() keeps the BATTERY_EXPORT label for accounting but only writes grid_first when the planned export is material — above the 0.1 kWh flow-resolution floor (matching the observed-side fold from johanzander#350/johanzander#351) AND either export-dominant (> planned battery_to_home) or larger than the load-following headroom the commitment forfeits (max_discharge x 15 min - planned discharge; a near-full-rate spike export leaves no headroom to protect, so demoting it would forgo revenue without reducing exposure — observed live in mock-HA E2E). Demoted periods write load_first + discharge_rate 100, LOAD_SUPPORT semantics: spike-immune, forgoing the small export. The gate consumes the per-period planned flows added as preparatory plumbing in johanzander#320 (no consumer until now) and only applies where discharge_rate_is_load_following (johanzander#324) — VPP-style platforms, where rate 100 would force a full-power discharge, are untouched. The simulator's derive_control_command intentionally keeps the plain intent mapping: like the johanzander#187/johanzander#318 intra-period discharge gate, demotion is a sub-period hardware-robustness behavior invisible to the 15-minute plan/sim. Closes johanzander#352 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… apply_intents Mechanical follow-up to upstream's create_schedule -> apply_intents rename; this test file predated that rename.
0815695 to
19d1fd8
Compare
|
Hi @pookey , how is the testing going? do you feel comfortable merging this so others can test it as part of next beta? |
…parked not superseded (#518) - Phase 2 row 3 no longer owns the sunrise/sunset crossover. #517 showed that period was never a tie: the action set was empty (every lattice candidate overshot the 86 W residual, #497 excluded the unexecutable ones), so IDLE won by default. P3 subsumed the case P2 was to handle. Row 3's acceptance is now the genuine near-ties (#466 evening, #393). - Phase 4 gains the crossover regression cover and #352's residual band. - #354 reclassified: NOT 'already fixed'. #511 removed sub-material exports from the plan; #354's own 0.1-0.5 kWh home-dominant band survives. Its mechanism is the wrong layer (post-hoc demotion = P>R optimism), but its two-sided materiality test is carried into Phase 4 as domain knowledge. - Phase 0 items ticked with what actually happened, including that the fold-scoping premise check FAILED and redirected the design to #511. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Hi @pookey — an update on this one, and an apology for how long it has sat. Your diagnosis holds and the materiality test is being kept. What changes is the layer it lives at. Since this PR opened, the optimizer has been through three refactor phases (#521, #525, #534) that consolidated the planning side: one action selector, one preference table for tie resolution, one flow record per candidate. Phase 4 is next, and it changes what a candidate is — a candidate stops being an abstract kW figure and becomes an executable inverter command (mode plus rate on the platform's real lattice), scored by simulating that command against the forecast. That is what makes this PR's layer the wrong one now. Gating So the plan for #352 now records your two-sided test — dominance OR forfeited headroom — as candidate scoring inside Phase 4 rather than as a demotion after the fact. That is your analysis, in the place where it can also prevent the plan rather than only suppress the command. Two things worth saying plainly:
I will close this PR when Phase 4 covers the band, and link the commit that does, so the trail from your work to the fix stays visible. If you would rather pick up the Phase 4 candidate-scoring piece yourself, say so and I will hold it for you — you have the most context on the materiality question of anyone. Thanks for the patience, and for the original analysis. |
|
Update from the #352 work, since this PR's rule was measured directly. Your two-sided test was independently re-derived and re-confirmed. A session working #352 arrived at the same predicate from the other direction — "is the period mostly about exporting, or covering the house with a spill?" — and then measured what happens without your second clause. Bare dominance costs +15.24 SEK across the fixture corpus; with the "nothing left to protect" exemption it costs +3.67 SEK. The entire difference is 8 full-rate export periods carrying 31.5 SEK that have no load-following headroom to defend. That is exactly the case you learned from live E2E and wrote the second clause for, and it holds up as load-bearing rather than defensive. What changed is the diagnosis underneath it. At the reproduction period the optimizer's action space offers 2.70 kW or 3.30 kW and nothing between, while the house needs 2.80 kW — exact cover is off the percent lattice, the near-cover steps are removed by #497's band, and the existing exact-cover candidate only fires below the smallest lattice step. So the DP chooses between under-covering (importing at the buy price) and over-covering (exporting at the sell price). Over-covering is the cheaper error, and it is what turns the period into a The tiny export you identified as not worth defending was never being defended. It is a by-product of an action the planner could not express. Adding the missing candidate measures −3.12 SEK (i.e. cheaper than today), takes committing exports from 52 to 19, and fixes the reproduction — against +3.67 SEK for the materiality rule. So the fix lands as an addition rather than a refusal, and this PR's rule stays on the shelf as optional cleanup for the residual. One measurement worth passing on, because it bears on your original numbers: every materiality figure — yours and the re-derivation's — was taken on that impoverished action space, where each non-exporting alternative carries a forced sub-lattice import at the buy price. That is the very buy≫sell asymmetry your report is about, and it systematically overstates how much the exports are worth. With exact cover present, the reproduction period's export margin collapses from 0.0388 SEK to 0.0067 — inside the optimizer's own tie threshold. Most of these periods are near-ties, not decisions. Full detail on #352. This PR stays open and unmerged; it will be closed by the commit that lands the fix, with the trail intact. |
Phase 4's two non-code preconditions for 4b, both now settled. The 22/16 figure reproduces bit-exactly on today's main. Its criterion is a BATTERY_EXPORT period whose planned discharge is below the period's home consumption: 22 periods, 16 with planned export in the 0.1-0.5 kWh band -- unchanged from 2026-08-10, so nothing regressed or was silently fixed in between. The 2026-08-11 counter-measurement of "0 periods" was vacuous. It read "below the house deficit" literally, and that count is 0 by construction: EnergyData._calculate_detailed_flows sets battery_to_home = min(discharged, home - solar), so battery_to_grid > 0 requires discharge above the deficit. It measured the flow derivation's own identity, not the corpus. The design doc now records the full criterion table so the next reader cannot repeat the mistake -- including which criterion is the exposure metric (forfeited load-following headroom: 102 of 226 export periods forfeit more headroom than the export they defend; 49 fail #354's two-sided test). regression_2026_08_12_202906 comes from the 2026-08-12 Growatt MIN bundle that reproduced the bug four times on real hardware. Period 99 carries the shape: 0.825 kWh discharged against 0.700 kWh home, 0.125 kWh of planned export defended at the cost of 2.925 kWh of forfeited headroom -- a 22% rate command, the same gear as the live period 78. expected_results is pinned PRE-fix deliberately, so 4b's change to the candidate space is measurable rather than invisible. Golden and VPP baseline regenerated for the new fixture only (--add-new; no existing entry touched, and every other golden re-captured identical). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GCgzMFLUwjhH7NdZXCVp7C
D3 was parked on "the bug reproduces on no fixture", which the previous
commit retracts as a vacuous measurement. It is answerable now, so this
answers it with numbers rather than leaving 4b blocked on prose.
The predicate: a grid_first export command is admissible iff
sell * export >= buy * min(D_ref * dt, headroom)
with D_ref = 2 kW. The export revenue must cover the import the
commitment exposes the house to at a reference load excursion. Three
properties a one-sided rule does not have:
- a full-rate export (headroom 0) has harm 0 and is always admitted.
That is #354's live-E2E lesson -- a near-full-rate export has
nothing left to protect -- arriving as arithmetic instead of as a
special case. 69 of 232 corpus export periods are full-rate and
every variant admits all of them.
- it is price-aware, so buy>>sell tariffs (#352's UK reporter, #393's
Belgian one) demote more readily. No corpus fixture has such a
tariff, so the predicate carries behaviour the corpus cannot show.
- it is structural, not stochastic (P7): a fixed reference deviation,
no distribution over load.
Measured over all 232 corpus grid_first periods: rejects 101, giving up
4.2% of planned export revenue to keep 168 of 191 kWh of load-following
headroom, and rejects the #352 reproduction period (0.329 SEK of revenue
against 1.959 SEK of exposure). It strictly subsumes #354's own test --
all 54 of its rejects, plus 47 more, #354-only = 0.
D_ref = 2 kW sits inside a flat region (1-3 kW moves 94->102 periods and
18->26 SEK) rather than on a cliff. Deriving it from HomeSettings'
connection capacity was measured and rejected: at 17.2 kW it is
indistinguishable from infinity and costs 17.6 SEK more across 5 periods
-- genuine 1.0-1.4 kWh exports refused because the battery is 15 kW, not
because the house could draw 6.9 kW above forecast for a quarter-hour.
scripts/measure_export_commitment.py re-derives every number here and in
the previous commit, because both have been mis-measured once already.
Marked PROPOSED, not approved -- D1/D2/D4 carry the owner's approval and
this needs the same before 4b is written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCgzMFLUwjhH7NdZXCVp7C
…porting
Settles the last open Phase 4 decision, and replaces the predicate this
doc proposed yesterday. The owner refuted two candidate rules with
constructed cases before any code existed; both are now acceptance
criteria rather than lessons someone has to rediscover.
The rule: an export command is admissible iff
battery_to_grid > battery_to_home # the export IS the period
or headroom <= one rate step # already flat out
The question it answers is whether the export is the point of the period
or a by-product of covering the house. Two periods that look identical
by rate separate cleanly by share: the #352 reproduction sends 0.125 kWh
to the grid against 0.700 to the house (15%, refused), while a genuine
arbitrage remainder sends 4.5 against 0.5 (90%, admitted).
No invented constants. "Mostly" is the 50/50 split, "flat out" is the
platform's own percent lattice. That is the substantive difference from
the D_ref = 2 kW harm/benefit predicate proposed yesterday, which needed
a household-appliance figure nobody could derive.
Six rules were measured as real candidate filters with the DP
re-optimising, not scored on a plan the filter would have changed:
dominance | flat out (chosen) +3.67 SEK exposure 437.5 -> 242.9
#354 two-sided +2.66 SEK exposure -> 252.9
dominance, bare +15.24 SEK exposure -> 243.4
harm/benefit 2 kW +4.36 SEK exposure -> 171.6
top gear only (REFUTED) +2.47 SEK loses the remainder case
+3.67 SEK is 0.14% of the corpus's 2600.8 SEK of savings.
Two findings worth more than the rule itself:
The flat-out exemption is load-bearing, not a caveat. Without it the
same rule costs +15.24 SEK, because 8 of the 60 periods bare dominance
refuses are at full rate and carry 31.5 of the 50.3 SEK at stake. A
full-rate command has no load-following capacity left, so demoting it
forfeits revenue and buys nothing -- #354's live-E2E lesson, re-derived
independently here.
A corpus average is not a substitute for a constructed adversarial case.
"Top gear only" measured -0.095% corpus-wide and looked parameter-free
and clean. On a 25 kWh / 10 kW-cap / 2-4-5 SEK case it moved 4.5 kWh
from the 2.00 SEK hour into a 0.50 SEK hour, lost 7%, and produced a new
partial-rate export while doing so -- it did not even achieve its own
goal. The corpus hid this because it holds few energy-rich, cap-limited
export days.
scripts/measure_export_commitment.py re-derives the static figures; the
re-optimised deltas came from a throwaway spike and get re-measured in
4b, where the filter will exist in the selector rather than in a
monkeypatch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCgzMFLUwjhH7NdZXCVp7C
… fixture (#582) * docs: Phase 4 design starting point, with the blocker the plan did not anticipate Phase 4's plan entry requires a design doc before code and says rules.md's new-class approval applies. This is that document at the point where the evidence is gathered and the decisions are identified — not an approved design. Re-ran the plan's two verification greps on main. Phase 4 stands: the planner still assumes nominal charge power, and charging_power_rate still has zero occurrences across dp_battery_algorithm/pwl_window_dp/action_selector while battery_system_manager writes it to the inverter. One figure was stale — 6 hardcoded rate_throughput sites, not 7; Phase 3 moved them. Found a constraint the plan did not account for. It says to evaluate candidates by reusing inverter_simulator.derive_control_command/simulate in the selector. Taken literally that makes the optimizer core import battery_system_manager, since the simulator imports the gate from there and InverterController too — the DP would import the thing that runs the DP. The graph is already strained: dp_battery_algorithm:1201 defers an action_selector import specifically because action_selector imports it back. Per rules.md's workaround check the answer is not a second deferred import but a leaf execution module both sides depend on, which means moving intra_period_discharge_gate. That needs approval, so it is recorded as decision D1 rather than assumed. Also records that #352 does not currently reproduce: 0 sub-load BATTERY_EXPORT periods on the corpus against the plan's recorded 22/16 from 2026-08-10. The corpus plans have not moved in between, so the measurements count different things — most likely the corpus lacks a fixture from the reporter's configuration. A reproduction fixture and that reconciliation both gate 4b. Four decisions need the maintainer: where the execution model lives, whether capabilities belong in BatterySettings, the concrete materiality predicate under P7's structural-not-stochastic constraint, and how far 4d reaches into strategic_intent's consumers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgSpN77kPHSqMFa2FPewhS * docs: unstale Phase 4 and Phase 3 in the optimizer plan The Phase 4 design doc (83c8195) recorded corrections but left the plan itself carrying the stale figures, and the plan is what a session reads first. - Phase 4's nominal-power evidence: six hardcoded rate_throughput sites at current line numbers, not seven at pre-Phase-3 ones. The load-bearing half (charging_power_rate absent from the planner, written by BSM) is unchanged, so Phase 4 still stands. - Phase 4 gating: #526/#520/#524 are all merged, so the gate that section says to settle first is settled, and #541 supplied the VPP baseline #540 required. Replaced with what actually gates it now — the four open design decisions, including that the selector cannot import inverter_simulator without the optimizer core depending on battery_system_manager, and sequencing behind the beta. - #352: recorded that the 22/16 figure does not reproduce (0 on the fixtures) and that reconciliation plus a real reproduction fixture gate 4b. Recorded the two-shape split, so Phase 4 targets Shape B only. - Phase 3 status: MERGED via #534 and closeable, rather than 're-scoped'. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgSpN77kPHSqMFa2FPewhS * docs: define 'the corpus' once, and what a zero measured on it means The term carries a lot of weight in this plan, the audit and the test docstrings without ever being defined: the 36 scenario fixtures in core/bess/tests/unit/data/, 2168 periods, which every pin iterates. Defined at first use rather than renamed — 'corpus' is the established word in the code comments too, so changing it here alone would put the docs out of step with the source. The part worth writing down is the bound. These are Swedish and Belgian systems at 15-minute point forecasts, so a sub-period effect is arithmetically zero by construction and no fixture represents a configuration nobody has sent a bundle for. Both of 2026-08-11's 'does not reproduce' findings — #393's overnight residual and #352 Shape B — are that, not evidence the bugs are gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgSpN77kPHSqMFa2FPewhS * docs: record the approved Phase 4 decisions; split intent-as-input into Phase 5 D1, D2 and D4 approved 2026-08-11; D3 left open deliberately. D1 — new leaf module core/bess/execution_model.py holds command derivation, the platform lattice mapping and the intra-period discharge gate, depended on by both action_selector and inverter_simulator. This relocates intra_period_discharge_gate out of battery_system_manager, which is the substance of the decision: it is what lets the selector score a real command without the optimizer core importing the orchestrator, and without a third inverter model. Rejected putting it in inverter_controller (still inverts the layering) and a narrow selector-side subset (the third implementation by the back door). D2 — a separate PlatformCapabilities rather than extra fields on BatterySettings, which is 17 fields of physical-battery facts with a different lifetime and source. D4 — intent-as-input removed from Phase 4 and promoted to Phase 5. Measured blast radius: 25 non-test Python modules plus 10 frontend files, and it is pinned per period in the goldens since #544. That is a vocabulary migration, not candidate-space work, and bundling it would make 4b's and 4c's measured deltas unreadable. D3 — the materiality predicate stays open until the #352 reproduction exists. The bug does not reproduce on any fixture today, so a predicate chosen now would be fitted to no observable failure. Per Global constraints, naming the modules here is the rules.md new-class approval. Phase 4 is now three PRs; 4a is startable, 4b/4c wait for the beta. Also fixes a second stale 'seven hardcoded rate_throughput sites' in the split section. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgSpN77kPHSqMFa2FPewhS * docs: give Phase 5 its user-visible driver (#330) Phase 5 (intent-as-input) had no user-facing motivation recorded, which makes it look like tidying that can be deferred indefinitely. It has one: #330. A recorded period's strategic_intent is the PLANNED intent, not the observed one — the same caveat #536 records for cost attribution. So whenever actual diverges from forecast the label describes what was intended, not what happened. That is #330 ('schedule says selling to grid, energy flow does not') and Frank-Leysen's matching #126 evidence ('Solar Exporting' while grid-balanced and charging; IDLE while charging 3.3-3.5 kW after a solar-forecast undershoot). Recorded because the fix is structural: reducing planned-flow classification to observed-data use. A labelling patch in the UI would only move the disagreement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgSpN77kPHSqMFa2FPewhS * test: reproduce #352 Shape B and reconcile the 22/16 figure Phase 4's two non-code preconditions for 4b, both now settled. The 22/16 figure reproduces bit-exactly on today's main. Its criterion is a BATTERY_EXPORT period whose planned discharge is below the period's home consumption: 22 periods, 16 with planned export in the 0.1-0.5 kWh band -- unchanged from 2026-08-10, so nothing regressed or was silently fixed in between. The 2026-08-11 counter-measurement of "0 periods" was vacuous. It read "below the house deficit" literally, and that count is 0 by construction: EnergyData._calculate_detailed_flows sets battery_to_home = min(discharged, home - solar), so battery_to_grid > 0 requires discharge above the deficit. It measured the flow derivation's own identity, not the corpus. The design doc now records the full criterion table so the next reader cannot repeat the mistake -- including which criterion is the exposure metric (forfeited load-following headroom: 102 of 226 export periods forfeit more headroom than the export they defend; 49 fail #354's two-sided test). regression_2026_08_12_202906 comes from the 2026-08-12 Growatt MIN bundle that reproduced the bug four times on real hardware. Period 99 carries the shape: 0.825 kWh discharged against 0.700 kWh home, 0.125 kWh of planned export defended at the cost of 2.925 kWh of forfeited headroom -- a 22% rate command, the same gear as the live period 78. expected_results is pinned PRE-fix deliberately, so 4b's change to the candidate space is measurable rather than invisible. Golden and VPP baseline regenerated for the new fixture only (--add-new; no existing entry touched, and every other golden re-captured identical). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GCgzMFLUwjhH7NdZXCVp7C * docs: propose D3, the export-commitment materiality predicate D3 was parked on "the bug reproduces on no fixture", which the previous commit retracts as a vacuous measurement. It is answerable now, so this answers it with numbers rather than leaving 4b blocked on prose. The predicate: a grid_first export command is admissible iff sell * export >= buy * min(D_ref * dt, headroom) with D_ref = 2 kW. The export revenue must cover the import the commitment exposes the house to at a reference load excursion. Three properties a one-sided rule does not have: - a full-rate export (headroom 0) has harm 0 and is always admitted. That is #354's live-E2E lesson -- a near-full-rate export has nothing left to protect -- arriving as arithmetic instead of as a special case. 69 of 232 corpus export periods are full-rate and every variant admits all of them. - it is price-aware, so buy>>sell tariffs (#352's UK reporter, #393's Belgian one) demote more readily. No corpus fixture has such a tariff, so the predicate carries behaviour the corpus cannot show. - it is structural, not stochastic (P7): a fixed reference deviation, no distribution over load. Measured over all 232 corpus grid_first periods: rejects 101, giving up 4.2% of planned export revenue to keep 168 of 191 kWh of load-following headroom, and rejects the #352 reproduction period (0.329 SEK of revenue against 1.959 SEK of exposure). It strictly subsumes #354's own test -- all 54 of its rejects, plus 47 more, #354-only = 0. D_ref = 2 kW sits inside a flat region (1-3 kW moves 94->102 periods and 18->26 SEK) rather than on a cliff. Deriving it from HomeSettings' connection capacity was measured and rejected: at 17.2 kW it is indistinguishable from infinity and costs 17.6 SEK more across 5 periods -- genuine 1.0-1.4 kWh exports refused because the battery is 15 kW, not because the house could draw 6.9 kW above forecast for a quarter-hour. scripts/measure_export_commitment.py re-derives every number here and in the previous commit, because both have been mis-measured once already. Marked PROPOSED, not approved -- D1/D2/D4 carry the owner's approval and this needs the same before 4b is written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GCgzMFLUwjhH7NdZXCVp7C * docs: decide D3 -- grid_first only when the period is mostly about exporting Settles the last open Phase 4 decision, and replaces the predicate this doc proposed yesterday. The owner refuted two candidate rules with constructed cases before any code existed; both are now acceptance criteria rather than lessons someone has to rediscover. The rule: an export command is admissible iff battery_to_grid > battery_to_home # the export IS the period or headroom <= one rate step # already flat out The question it answers is whether the export is the point of the period or a by-product of covering the house. Two periods that look identical by rate separate cleanly by share: the #352 reproduction sends 0.125 kWh to the grid against 0.700 to the house (15%, refused), while a genuine arbitrage remainder sends 4.5 against 0.5 (90%, admitted). No invented constants. "Mostly" is the 50/50 split, "flat out" is the platform's own percent lattice. That is the substantive difference from the D_ref = 2 kW harm/benefit predicate proposed yesterday, which needed a household-appliance figure nobody could derive. Six rules were measured as real candidate filters with the DP re-optimising, not scored on a plan the filter would have changed: dominance | flat out (chosen) +3.67 SEK exposure 437.5 -> 242.9 #354 two-sided +2.66 SEK exposure -> 252.9 dominance, bare +15.24 SEK exposure -> 243.4 harm/benefit 2 kW +4.36 SEK exposure -> 171.6 top gear only (REFUTED) +2.47 SEK loses the remainder case +3.67 SEK is 0.14% of the corpus's 2600.8 SEK of savings. Two findings worth more than the rule itself: The flat-out exemption is load-bearing, not a caveat. Without it the same rule costs +15.24 SEK, because 8 of the 60 periods bare dominance refuses are at full rate and carry 31.5 of the 50.3 SEK at stake. A full-rate command has no load-following capacity left, so demoting it forfeits revenue and buys nothing -- #354's live-E2E lesson, re-derived independently here. A corpus average is not a substitute for a constructed adversarial case. "Top gear only" measured -0.095% corpus-wide and looked parameter-free and clean. On a 25 kWh / 10 kW-cap / 2-4-5 SEK case it moved 4.5 kWh from the 2.00 SEK hour into a 0.50 SEK hour, lost 7%, and produced a new partial-rate export while doing so -- it did not even achieve its own goal. The corpus hid this because it holds few energy-rich, cap-limited export days. scripts/measure_export_commitment.py re-derives the static figures; the re-optimised deltas came from a throwaway spike and get re-measured in 4b, where the filter will exist in the selector rather than in a monkeypatch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GCgzMFLUwjhH7NdZXCVp7C * docs: #352's root cause is a missing candidate, not a bad one Supersedes D3 the day it was decided. A bess-analyst second opinion, sought before amending P7, inverted the diagnosis; the finding was then verified independently against the code and re-measured. At the field-evidenced period the DP's action space offers 2.70 kW or 3.30 kW and nothing between. The house needs 2.80 kW. 2.80 is off the percent lattice (15 kW battery, 1% = 0.15 kW), 2.85/3.00/3.15 are removed by #497's unexecutable band, and _residual_cover_p offers exact cover only BELOW the smallest lattice step. So the DP picks between under-covering (2.70, planning a 0.025 kWh import at buy 3.917) and over-covering (3.30, exporting 0.125 kWh at sell 2.634). Over-covering is the cheaper error -- and it is what makes the period a BATTERY_EXPORT and puts the inverter in the committing mode. The export was never the goal. It is the least-bad option in a choice where the right one is absent. Exact cover is executable on load-following hardware: load_first is a ceiling, so commanding the step above delivers min(2.85, 2.80) = 2.80 exactly -- the same argument _residual_cover_p already makes below the lattice, not applied above it. Measured over the 37-fixture corpus with the DP re-optimising: + exact-cover candidate 1792.99 (-3.12) 52 -> 19 committing + cover + eps preference 1793.47 (-2.64) -> 8 + cover + D3 1794.47 (-1.64) -> 0 D3 alone (what was decided) 1799.78 (+3.67) -> 0, phantom import Three consequences: No P7 amendment. The case for amending rested on Shape B being decisively better under a point forecast, hence unreachable by P2's within-epsilon preference. With cover injected the p99 margin collapses from 0.0388 SEK to 0.0067 -- inside its own epsilon. The decisiveness was an artifact of the missing candidate. P3 already claims #352; the architecture was right and the code was not implementing it. Every D3 measurement was taken on that impoverished action space, where non-committing alternatives carry a forced sub-lattice import at buy price -- the exact tariff asymmetry #352 is about. They overstate export margins and must be re-derived post-4a. 4a becomes a prerequisite rather than a parallel track. Ceiling semantics are per-platform (Growatt TOU and VPP yes; SolaX native maps LOAD_SUPPORT to a fixed power target; Huawei/SPH have no per-period rate at all), and discharge_rate_is_load_following never reaches the DP -- grepped, zero occurrences in the three optimizer modules. The cover candidate has to be gated on it, which is 4a's capability work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GCgzMFLUwjhH7NdZXCVp7C * docs: keep the fix on the hardware lattice, and clear the beta gate Two owner decisions from the same session. **The #352 fix stays on the lattice.** The first draft added an off-lattice candidate at the house deficit (2.80 kW) and planned the delivery. The owner's formulation keeps candidates on the hardware's own steps -- 2.85 kW IS a real command, 19% -- and makes the candidate's FLOWS honour what that command does: under a ceiling it delivers min(2.85, deficit) and exports nothing. Same delivered energy, same percent written, two properties the off-lattice form lacks: - No conversion back to a percent, so no rounding failure. Measured: 539 of 1377 house deficits (39%) round DOWN under _scale_to_percent's round-to-nearest, where a commanded ceiling under-delivers the plan. The off-lattice form needs a second fix (round ceilings up) to reach 1792.99 / 19 committing; without it it reaches only 1793.26 / 25. The lattice-preserving form has no such step. - It explains #497's band instead of working around it. 2.85/3.00/3.15 are excluded because the flow model assumes each dribbles a sub-resolution export -- true only if the number is a target. With mode-aware flows they are exact cover, and the exclusion is unnecessary on ceiling platforms while still earning its keep on target platforms. This is P3 as literally written, so #352's fix and 4b's discharge half are one piece of work rather than a patch now and a rewrite later. **Beta gate cleared.** The beta ran without reported issues and is being released to main, so 4b/4c no longer wait on it -- only on 4a. The original reasoning is kept as the test to re-apply if it recurs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GCgzMFLUwjhH7NdZXCVp7C * docs: write 4a's entry points down instead of leaving them in a session Everything 4a needs that this session established by grep or measurement: where discharge_rate_is_load_following is defined and consumed (and that it has zero occurrences in the three optimizer modules), the discharge_resolution_kw plumbing path to copy, the measured ceiling-vs-target value per platform, the four call sites of the unconditional exact-cover candidate that #580 covers, and the requirement that 4a stay behaviour-neutral so 4b's delta stays readable. Also flags a question 4a has to answer rather than inherit: the period_list platforms have no per-period rate at all, so "load-following" is not a clean boolean for them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GCgzMFLUwjhH7NdZXCVp7C * docs: state the P7 conclusion forward, not as a retraction The amendment was only ever proposed in a working session, so framing the note as a withdrawal explains a decision no reader saw made. What is worth keeping is the trap and the measurement that springs it: the "decisively better under a point forecast, so P2 cannot reach it" argument is inviting, and the margin collapsing to inside its own epsilon once the missing candidate exists is the cheap check that refutes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GCgzMFLUwjhH7NdZXCVp7C --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
BATTERY_EXPORTperiod keeps its label for accounting but only commits the inverter togrid_firstwhen the planned export is material.battery_to_grid> 0.1 kWh (the flow-resolution floor from fix: cross-sensor noise misattributed to battery flows corrupts observed intent (BATTERY_EXPORT/GRID_CHARGING) #350/fix: sub-resolution battery_to_grid residual corrupts observed_intent #351) AND (export > plannedbattery_to_homeOR export > the load-following headroom the commitment forfeits,max_discharge × 15 min − planned discharge).load_first+discharge_rate=100(LOAD_SUPPORT semantics): spike-immune, forgoing the small export. Applies only wheredischarge_rate_is_load_following(Vpp battery dump b17 #324) — VPP-style platforms untouched. Consumes the per-period planned flows added as preparatory plumbing in bug: DP discharge-candidate search bakes in inverter-specific percent-of-max resolution instead of exact kWh, causing frequent unnecessary Growatt MIN mode flips #320 (previously no consumer).Root cause
From the issue (confirmed by @johanzander against source):
classify_strategic_intent's 0.01 kWh threshold (#253) routes periods planning a few Wh of export intoINTENT_TO_MODE'sBATTERY_EXPORT → grid_first, with_map_intent_to_ratesscaling the discharge rate from the forecast action.grid_firsthas no load-following, so any load spike above the written rate imports at buy price to protect pennies of export — measured live as 17/184 periods over 5 days with zero export delivered and 3.2 kWh imported with battery energy available (13–23 % of realized savings given back). #253 was a legitimate accounting fix; the gap is that classification fidelity and hardware-mode materiality were one decision.One finding made during implementation, worth knowing: #351's sub-0.1 kWh fold also runs on DP-planned flows (
EnergyData._calculate_detailed_flowsis shared between planned and observed data), so since75a9f0ethe sub-0.1-with-home-deficit case already classifiesLOAD_SUPPORTbefore this PR. This PR still matters because (a) the 0.1–0.5 kWh home-dominant band remained exposed, (b) the fold's counter-noise rationale doesn't apply to predictions — if it is ever scoped to actual data (relevant to the separate "pointless flips" investigation), #352 would silently reopen without an explicit gate, and (c) a sub-0.1 export with no home deficit still classifiedBATTERY_EXPORT.Fix
InverterControllergains_export_demoted()/mode_for_period();compute_rates_for_period,get_period_settings,get_detailed_period_groups, the Growatt MIN TOU grouping, and both solax-modbus TOU write paths route through them.backend/api.py's two period-group calls pass the matching per-periodPeriodDataso the dashboard shows the real hardware mode/rate for demoted periods (intent label unchanged). The simulator'sderive_control_commandintentionally keeps the plain mapping — like the #187/#318 intra-period discharge gate, demotion is a sub-period hardware-robustness behavior invisible to the 15-minute plan/sim (noted in both design docs).Why the two-sided materiality test: the harm of
grid_firstis bounded by the load-following headroom given up. A near-full-rate spike export (e.g. 1.24 of 1.25 kWh max into a 0.95 kWh/period house) has ~no headroom left to protect, so demoting it would only forgo revenue — observed live in mock-HA E2E with a 6 SEK/kWh sell spike, where a home-dominance-only rule would have permanently demoted every export on any high-consumption system. Conversely, low-rate smeared exports (0.02 kWh export vs ~1 kWh headroom) are exactly the measured failure mode and demote.Docs: updated
docs/agents/bess-knowledge.md(hardware mapping + new gate section) anddocs/SOFTWARE_DESIGN.md(intent→mode table + gate paragraph; also corrected the staleBATTERY_EXPORTclassification threshold claim, 0.1 → 0.01 per #253, and noted the #350 fold there).Test plan
core/bess/tests/unit/test_export_mode_materiality.py(demotion, materiality via dominance and via headroom, legacy no-period-data fallback, non-load-following platform exclusion, intent-label/hardware decoupling, TOU segment behavior)./scripts/quality-check.shpasses locally; full slow suite passes (380 passed)docker-compose.ci.yml, synthetic Growatt MIN + Nordpool): a home-dominant partial-rate export period (planned b2g 0.288 vs b2h 0.95 at low rate) renderedBATTERY_EXPORT/load_first/dis 100with nogrid_firstTOU segment; an export-dominant period (b2g 0.588 vs b2h 0.15) keptgrid_first/dis 59with a real TOU segment; full-rate spike exports (b2g 0.29, rate 99–100 %) keptgrid_firstwith agrid_firstTOU segment 10:30–11:29 under the final predicate.Closes #352
🤖 Generated with Claude Code