Skip to content

fix: gate grid_first on export materiality instead of intent classification - #354

Draft
pookey wants to merge 2 commits into
johanzander:mainfrom
pookey:fix/issue-352-export-mode-materiality
Draft

fix: gate grid_first on export materiality instead of intent classification#354
pookey wants to merge 2 commits into
johanzander:mainfrom
pookey:fix/issue-352-export-mode-materiality

Conversation

@pookey

@pookey pookey commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

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 into INTENT_TO_MODE's BATTERY_EXPORT → grid_first, with _map_intent_to_rates scaling the discharge rate from the forecast action. grid_first has 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_flows is shared between planned and observed data), so since 75a9f0e the sub-0.1-with-home-deficit case already classifies LOAD_SUPPORT before 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 classified BATTERY_EXPORT.

Fix

InverterController gains _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-period PeriodData so the dashboard shows the real hardware mode/rate for demoted periods (intent label unchanged). The simulator's derive_control_command intentionally 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_first is 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) and docs/SOFTWARE_DESIGN.md (intent→mode table + gate paragraph; also corrected the stale BATTERY_EXPORT classification threshold claim, 0.1 → 0.01 per #253, and noted the #350 fold there).

Test plan

  • 10 new behavioral tests in 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.sh passes locally; full slow suite passes (380 passed)
  • Observed live via the mock-HA E2E stack (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) rendered BATTERY_EXPORT/load_first/dis 100 with no grid_first TOU segment; an export-dominant period (b2g 0.588 vs b2h 0.15) kept grid_first/dis 59 with a real TOU segment; full-rate spike exports (b2g 0.29, rate 99–100 %) kept grid_first with a grid_first TOU segment 10:30–11:29 under the final predicate.

Closes #352

🤖 Generated with Claude Code

Ian P. Christian and others added 2 commits July 30, 2026 08:40
…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.
@pookey
pookey force-pushed the fix/issue-352-export-mode-materiality branch from 0815695 to 19d1fd8 Compare July 30, 2026 07:42
@johanzander

Copy link
Copy Markdown
Owner

Hi @pookey , how is the testing going? do you feel comfortable merging this so others can test it as part of next beta?

johanzander added a commit that referenced this pull request Aug 9, 2026
…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>
@johanzander

Copy link
Copy Markdown
Owner

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 grid_first on export materiality in the controller is a post-hoc demotion: the planner still proposes an action the hardware won't deliver, and the controller catches it afterwards. Under Phase 4 the unexecutable action is never a candidate in the first place, so a controller-side gate would be a second place where the same rule lives — and if both exist they can disagree, which is precisely the mirrored-implementation class the refactor exists to remove.

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.

@bess-agent

Copy link
Copy Markdown
Collaborator

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 BATTERY_EXPORT and puts the inverter into grid_first.

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.

johanzander added a commit that referenced this pull request Aug 14, 2026
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
johanzander added a commit that referenced this pull request Aug 14, 2026
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
johanzander added a commit that referenced this pull request Aug 14, 2026
…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
johanzander added a commit that referenced this pull request Aug 14, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: low-rate BATTERY_EXPORT writes grid_first at a forced sub-load discharge rate, causing avoidable grid import on any load spike

3 participants