Skip to content

fix: apply the intra-period discharge gate to LOAD_SUPPORT on VPP (#520) - #537

Draft
bess-agent wants to merge 3 commits into
mainfrom
fix/issue-520-vpp-load-support-gate
Draft

fix: apply the intra-period discharge gate to LOAD_SUPPORT on VPP (#520)#537
bess-agent wants to merge 3 commits into
mainfrom
fix/issue-520-vpp-load-support-gate

Conversation

@bess-agent

@bess-agent bess-agent commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Root cause

The gate was scoped by and self._inverter_controller.discharge_rate_is_load_following, which is False in VPP mode (solax_modbus_growatt_controller.py:159_is_tou_control). VPP's discharge_rate is an immediate forced power command rather than a load-following ceiling (#324), so the rate path is correctly excluded — but the decision was never applied at all, and LOAD_SUPPORT released control unconditionally (#413).

Fix

DP decision VPP command Effect
gate open (or no decision) vpp_power=0, remote disabled load_first self-use — inverter covers the spike (#413, unchanged)
gate closed vpp_power=+1, remote enabled battery_first hold — spike imported, battery preserved

Closed maps to +1, not 0, and that is the whole subtlety. With remote control enabled the sign of vpp_power selects firmware priority, and <= 0 is grid_first — which per #118 still draws self-consumption from the battery. Only > 0 releases the house to grid/solar. Writing 0 would read as a hold and quietly keep discharging. #466 established the same mapping for IDLE for the same reason, so this is not a new claim about the hardware.

None ("no decision for this period") stays distinct from False ("the DP decided against it") — the three-state convention #526 established. The retry and discharge-inhibit paths carry no decision and keep #413's unconditional release rather than inventing one.

Scope assessment

Structural, not local. The gate value has to cross from _apply_period_schedule into the VPP command mapping, so apply_period / _apply_period_vpp / _intent_to_vpp each take one new argument. Chosen owner: the same route block_passive_charging and strategic_intent already travel, which exist for exactly this — VPP-only distinctions that grid_charge/discharge_rate cannot express (see apply_period's docstring).

Workaround check: passes. The new parameter carries a decision the VPP mapping genuinely needs and cannot derive; it is not routing around an ordering, timing, or dependency problem. The one structural change beyond plumbing — hoisting the authorization lookup so both platforms read one value — removes a re-derivation rather than adding one.

Test plan

  • ./scripts/quality-check.sh — 0 errors
  • 1693 fast + 458 slow passed
  • RED test verified to fail against the pre-fix mapping, not pass vacuously
  • Observed real commands from _apply_period_schedule, not just assertions:
VPP  gate OPEN   : vpp_power=+0%  remote_control=DISABLED
VPP  gate CLOSED : vpp_power=+1%  remote_control=ENABLED
TOU  both        : no VPP write (unchanged)

Verification gaps — stated, not glossed

  1. No plan-faithfulness (R == P) test, which implement-issue normally requires for control-mapping changes. simulation/inverter_simulator.py is "Growatt MIN / cloud, execution-only" — it has no VPP mode, so an R == P scenario would exercise the load-following path and prove nothing about the branch changed here. This mirrors test_vpp_discharge_gate_capability.py, which exists for the same reason.
  2. Full-stack E2E could not write VPP registers. The ci-growatt-vpp scenario boots with select.growatt_min_vpp_remote_control unavailable, so no VPP write occurs. The in-process observation above is the substitute. Root cause filed as E2E never exercises a Growatt VPP register write — control scenario has no VPP entities #538 — the control scenario carries no VPP entities at all, so no E2E has ever observed a Growatt VPP register write.
  3. The gate-closed command is not real-hardware-validated. To be precise about scope: this is Growatt VPP (solax_modbus_growatt_min/_sph — Growatt GEN3/GEN4 via the solax_modbus integration), not the separate SolaX VPP platform. The control mode is marked experimental, but the hardware is not untested — Correction to Solax-Growatt integration: Growatt MIN-inverters now have functional VPP registers, and do not need TOU programming #118's LOAD_SUPPORT behaviour came from two real-hardware testers running Growatt MIN with control_mode=vpp. What is unvalidated is narrower: the two hold branches this builds on (battery_first Question: How is IDLE used? #466, grid_first Has it lost sense of battery wear cost? #355) ship pending confirmation, so the vpp_power=+1 gate-closed command specifically wants one of those testers confirming the battery actually holds.

Docs

INVERTER_PLATFORMS.md gains a "LOAD_SUPPORT is now gated" section with the mapping table and the +1 vs 0 reasoning. bess-knowledge.md's "VPP platforms are still excluded" paragraph was false after this change and is rewritten.

Does not close #520 — per repo policy an intermediate PR should not, and #520 auto-closed once already on #524's merge with the VPP half undone.

🤖 Generated with Claude Code

johanzander and others added 2 commits August 11, 2026 00:31
The VPP half of #520. #524 landed the TOU half; VPP was left releasing
control unconditionally, so the same house, same prices and same plan
behaved differently depending on which inverter you own -- the asymmetry
#520 exists to remove.

TOU spends the DP's authorization as a rate ceiling. VPP has no ceiling to
raise (its discharge_rate is an immediate forced power command, #324), so it
spends the same decision as a *mode*:

- gate open  -> vpp_power=0, remote control DISABLED (load_first self-use,
  the inverter covers a within-period spike itself -- #413, unchanged)
- gate closed -> vpp_power=+1, remote control ENABLED (battery_first hold,
  self-consumption comes from grid/solar and the spike is imported)

**Closed maps to +1, not 0, and that is the whole subtlety.** With remote
control enabled the sign of vpp_power selects firmware priority, and <= 0 is
grid_first -- which per #118 still draws self-consumption from the battery.
Only > 0 releases the house to grid/solar. Writing 0 would read as a hold and
quietly keep discharging. #466 established the same mapping for IDLE for the
same reason; this is not a new claim about the hardware.

The authorization is now read once in `_apply_period_schedule` and handed to
whichever path can spend it, rather than each platform re-deriving it -- they
must agree, since being the same economic decision is the point.

`None` ("no decision for this period") stays distinct from `False` ("the DP
decided against it"), the three-state convention #526 established. The retry
and discharge-inhibit paths carry no decision and keep #413's unconditional
release rather than inventing one.

Observed, not just asserted -- real commands from `_apply_period_schedule`:

    VPP  gate OPEN   : vpp_power=+0%  remote_control=DISABLED
    VPP  gate CLOSED : vpp_power=+1%  remote_control=ENABLED
    TOU  both        : no VPP write (unchanged)

Docs updated: `INVERTER_PLATFORMS.md` gains a "LOAD_SUPPORT is now gated"
section, and `bess-knowledge.md`'s "VPP platforms are still excluded" claim
was false after this change and is rewritten.

Suites: 1693 fast + 458 slow passed. The RED test was verified to fail
against the pre-fix mapping rather than pass vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYRyY3MYjN4dnFEWnxbGyW
The previous commit's note called `solax_modbus_growatt_min` "SolaX VPP".
Those are two different platforms -- `INVERTER_PLATFORMS.md` lists them as
separate entries on the SM-Ephemeral row -- and the distinction changes who
can validate this and how untested it really is.

This is Growatt GEN3/GEN4 hardware driven through the solax_modbus
integration. The control mode is marked experimental, but it is not untested
hardware: #118's LOAD_SUPPORT behaviour was reported by two real-hardware
testers running Growatt MIN with `control_mode=vpp`. Calling it "SolaX VPP,
experimental" implied nobody runs it, which is the opposite of the truth and
would have sent a validation request to the wrong people.

What is genuinely unvalidated is narrower: the two hold branches this builds
on (`battery_first` #466, `grid_first` #355) ship pending confirmation, so the
gate-closed command is the part wanting a real-hardware check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYRyY3MYjN4dnFEWnxbGyW
The 'ships experimental pending confirmation' notes in
solax_modbus_growatt_controller.py date from when those branches shipped and
were never revisited once they proved out. Reading them as a current maturity
statement is wrong: Growatt VPP control mode is in real production use and
well exercised in the field.

The genuinely new thing here is the gate-closed command itself, which has not
run in the field yet -- ordinary new-behaviour risk, not a change stacked on
an unvalidated foundation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYRyY3MYjN4dnFEWnxbGyW
@bess-agent

Copy link
Copy Markdown
Collaborator Author

Blocked on #539 — do not merge yet.

Under the "no regression from beta for Growatt VPP" requirement, this PR changes behaviour on 172 of 603 LOAD_SUPPORT periods (28.5%, 26 of 36 fixtures) and we have no instrument to measure the effect. The trigger count is platform-independent — it comes from the DP's own intra_period_discharge_allowed — so it is how often the new branch fires. Whether firing is good or bad is unmeasurable today, because the simulator is Growatt MIN/cloud TOU only.

Worth recording an asymmetry between the two halves of #520 that I had not articulated when this was opened:

gate closed does regression risk
TOU (#524, merged) max(plan_scaled, 0) = plan_scaled — identical to pre-#524 none, by construction
VPP (this PR) writes vpp_power=+1, remote enabled — a new forced command active change on 28.5% of periods

They are not "the same fix on two platforms". #524's gate-closed branch can only ever fail to raise something; this one actively commands something new — and it pushes against the field reports that produced #413, where forcing VPP commands instead of releasing control caused unnecessary grid imports/exports.

#539 adds VPP simulation pinned against v10.0.2, which turns this from "effect unknown" into a measured delta. That delta will be pessimistically biased against this change — at 15-minute point forecasts the simulator can model the gate's cost but never its benefit — so it answers "did behaviour change", not "is it worse". Still far better than the current position.

Nothing here says the change is wrong. It says the blast radius is broad and unmeasured, which is not something to ship into a platform with zero known bugs on beta.

Also corrected on this branch since opening: the "SolaX VPP / experimental / unvalidated" framing in the original PR body and in bess-knowledge.md. This is Growatt VPP on real production hardware; the ships experimental pending confirmation notes in the controller are stale comments from when those branches landed, not a current maturity statement.

johanzander added a commit that referenced this pull request Aug 11, 2026
…s the corpus cannot reach (#541 review)

Six review findings, one medium and five low.

**A forced-export command at the SoE floor charged the battery.** When
nothing is deliverable -- SoE at min, or the AC stage full of PV -- the
discharge branches returned `-0.0`, which is not below `POWER_TOLERANCE_KW`,
so `_state_transition` took its IDLE branch and absorbed solar surplus into
the battery. A *charge*, under a forced-export command, contradicting both
BATTERY_EXPORT's `charge_rate = 0` and this file's own grid_first hold.

Same defect class as the `+1%`-hold bug the previous commit fixed, and
reachable: `realworld_2026_04_29_220919` period 37 commands (-1, True) at
SoE 2.5 (== min) with 1.1 kWh of surplus and charged to 3.567 kWh. Both
discharge branches now return None, the file's own hold convention.
Regenerating the baseline moves that fixture -91.77 -> -93.39 SEK, matching
the ~1.63 the reviewer predicted.

**grid_first's load-serving branch was dead across the whole corpus.**
Replacing its condition with `if False` left all 74 tests green: no
SOLAR_EXPORT period in any of the 36 fixtures has `home > solar`. So the
#118/#466-vs-#355 modelling choice -- argued at length in the PR description
-- had no test behind it at all. `test_vpp_simulator_branches.py` now pins
every branch of `vpp_command_to_power` directly, including that one, and the
code says which reading it encodes and why, so revisiting it (#537, or a
hardware confirmation) has to be deliberate.

Verified by regression, per the rule this session just added to rules.md:
killing the zero-delivery guard fails 4 tests; killing the grid_first
load-serving branch fails the test written for it. Neither failed before.

Also from the review:

- `test_drift_from_the_released_version_is_recorded` compared two fields of
  the same JSON file, so it executed no production code and could only fail
  if someone hand-edited the artefact -- documentation shaped like a test. It
  now recomputes the plans, which makes it a real check on a sloppy
  re-baseline.
- `test_current_plan_is_pinned` omitted `soe_trajectory`, so an
  execution-model change that shifted the SoE path while netting out in
  commands and total cost escaped the stricter half of the pin. Added.
- The same test raised a bare KeyError for a fixture with no baseline entry
  instead of the regeneration guidance its siblings emit. Guarded.
- `capture_fixture()` was dead and its schema no longer matched the baseline,
  so anyone reaching for it as the entry point would produce a baseline the
  tests reject. Removed.

Suites: 1701 fast + 532 slow passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYRyY3MYjN4dnFEWnxbGyW
@bess-agent

Copy link
Copy Markdown
Collaborator Author

Design defect — this must not merge as written. Found via @ridax67's comment on #520 ("it should cover load in all modes except IDLE").

The gate governs the discharge above the plan, not the planned discharge itself. TOU expresses that as a ceiling: gate closed leaves discharge_rate = plan_scaled, so the plan is still delivered, the ceiling just isn't raised to 100. VPP has no ceiling — this PR maps gate-closed to (1, True) battery_first hold, which delivers nothing.

Measured on the 36-fixture corpus:

LOAD_SUPPORT periods 603
gate closed 172 (28.5%)
...carrying a planned discharge 172 — every one
planned energy this PR abandons 118.11 kWh

Not a partial-overlap case: every gate-closed LOAD_SUPPORT period in the corpus has a real planned discharge. TOU delivers those; this PR would drop all of them and import instead.

The three states are not two. VPP can express "release" and "hold" but not "deliver exactly the plan" — which is precisely the state the gate needs. ridax's load-tracking proposal on #520 (adjust vpp_power against measured house load) is the mechanism that makes it expressible, and it is also the fix for #352. That is Phase 4 work.

Converting to draft pending redesign.

@bess-agent
bess-agent marked this pull request as draft August 11, 2026 07:48
johanzander added a commit that referenced this pull request Aug 11, 2026
Written after #537 reached review with a design defect: it mapped the
#520 discharge gate's closed state onto Growatt VPP as a battery_first
hold, which delivers nothing, where TOU's closed state delivers the
planned discharge and merely declines to raise the ceiling. Measured on
the corpus, that abandons 118.11 kWh across 172 periods -- every
gate-closed LOAD_SUPPORT period has a real planned discharge behind it.

The inference that produced it was that VPP can express "discharge, but
only this much". It can, for BATTERY_EXPORT, whose power_pct is the
plan-scaled rate negated. It cannot for LOAD_SUPPORT, which maps to
release-control -- one command for every planned rate. LOAD_SUPPORT is
the only intent where that is true, so the two sit side by side and the
wrong one generalises.

Nothing in the suite could see this. The golden corpus, test_scenarios
and the R == P checks all pin what the DP *plans*; executable fidelity
was never measured, so a change making a platform less able to follow
the plan stayed invisible. The VPP simulator cannot adjudicate it either
-- by its own docstring it scores gate-closed changes as a loss whether
or not they are one.

Sweeps the full planned-action range per intent and counts distinct
commands out. 101 rates -> 1 command means the magnitude is discarded at
execution. LOAD_SUPPORT is declared lossy; any other intent becoming
lossy now fails a test that says so. Verified discriminating: clearing
VPP_LOSSY_INTENTS fails 2 of 8.

Does not itself fail on #537 -- that parameter does not exist on this
branch, so the sweep cannot reach the gate-closed path. It pins the
asymmetry that caused the wrong inference, and needs extending to sweep
gate states when #537 is redesigned.

The LOAD_SUPPORT lossiness is pre-existing on main and beta, not new
here: 522 of 603 LOAD_SUPPORT periods already carry a partial planned
rate that VPP discards. Reported by ridax67 on #520 from the other
direction -- he has compensated for it with a load-tracking automation
since day one on VPP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYRyY3MYjN4dnFEWnxbGyW
johanzander added a commit that referenced this pull request Aug 11, 2026
Every reporter fix was reverted in the working tree and a named test
observed to fail. 17 held. Two did not, and both are now closed with
tests verified to fail without their fix:

- #399: the guard asserted _vpp_status_confirmed is seeded from the
  hardware read-back -- the mechanism, not the write count ridax
  reported. #329's write-count test never reaches the read-back path,
  because that instance already set the flag from its own first write,
  so the restart case had no outcome coverage at all.
- #302: no guard whatsoever. Deleting the DST end-time cap left all 1714
  fast tests green while the interval is emitted as "24:59". The
  fall-back day comes once a year, so this could be dropped in a
  September refactor and first surface on a user's inverter at the
  changeover.

Both defects share a shape with the third this audit found earlier
(test_real_day_has_charge_neither_source_explains): the guard asserts
what the fix changed rather than what the reporter measured. Three
instances is a pattern, now a rule rather than a habit.

Also records what Pass 2 disproved. The going-in assumption was that VPP
was the exposed platform because inverter_simulator is TOU-only. That
holds for behavioural coverage, but the one completely unguarded fix is
on the TOU side and is a crash, which no simulator would have caught.

Replaces the #537 standing-risk paragraph with the withdrawal. It
abandons 118.11 kWh across all 172 gate-closed LOAD_SUPPORT periods,
because a VPP hold delivers nothing where TOU's gate-closed still
delivers the plan. Cause: BATTERY_EXPORT carries its planned magnitude
faithfully and LOAD_SUPPORT does not -- 101 rates to 1 command -- and
generalising from the first to the second is the wrong direction.
test_platform_mapping_fidelity.py now pins that asymmetry.

Worth stating plainly in the record: what caught it was not a test, and
not the VPP simulator built for this exact question, but ridax's comment
on #520.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYRyY3MYjN4dnFEWnxbGyW
johanzander added a commit that referenced this pull request Aug 11, 2026
This PR recorded #537 as BLOCKED on #539 -- awaiting an instrument to
measure its effect. That is no longer the status and the difference is
material: #539's harness now exists, and the design is wrong
independently of it.

#537 mapped #520's closed discharge gate onto VPP as a battery_first
hold. On TOU, gate-closed still delivers the planned discharge and only
declines to raise the ceiling; on VPP a hold delivers nothing. All 172
gate-closed LOAD_SUPPORT periods carry a planned discharge, totalling
118.11 kWh, every one of which the PR would abandon.

The root cause belongs in this doc because Phase 4 rests on it: VPP
carries BATTERY_EXPORT's planned magnitude faithfully, but collapses
LOAD_SUPPORT's 101 distinct rates to a single command. "Deliver the
plan, no more" is therefore inexpressible on VPP today, and any design
assuming otherwise is wrong before it is written.

Note the harness this PR gates on would not have caught it -- by its own
docstring it scores gate-closed changes as a loss whether or not they
are one. ridax67's #520 comment caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYRyY3MYjN4dnFEWnxbGyW
johanzander added a commit that referenced this pull request Aug 11, 2026
Growatt VPP had no simulation, so "no regression from beta" was
unenforceable for it -- a requirement with no instrument behind it.
`inverter_simulator` is Growatt MIN/cloud **TOU** only, so the scenario
corpus, `run_scenario_realized` and the whole R == P harness cover the TOU
path and say nothing about VPP.

Three pieces:

- `simulation/vpp_simulator.py` -- the VPP execution model. Firmware
  priority follows the documented sign rule (remote enabled: `> 0`
  battery_first, `<= 0` grid_first; remote disabled: load_first self-use).
- `tests/unit/vpp_capture.py` + `scripts/capture_vpp_baseline.py` -- one
  definition of "run this fixture and record what VPP would do", shared by
  the writer and the test, the same arrangement as `golden_capture.py`.
- `test_vpp_regression_baseline.py` -- the pin.

**The command mapping is not re-implemented.** `derive_vpp_command` calls
the real `SolaxModbusGrowattController._intent_to_vpp`, so the simulator
cannot describe a command production would not send. That is the P1/P4
lesson from Phases 1 and 3, where hand-mirrored copies of one piece of logic
were the entire bug class.

**The baseline is v10.0.2's plans executed through today's VPP model**, not
today's plans. The split holds the execution model fixed so a failure
isolates a planner change, and it is the only way to reach the tag at all --
`vpp_simulator` needs `_period_flows`, which arrived with Phase 3, so the
tagged code cannot run today's simulator.

Measured while building it: plans have already moved on **35 of 36
fixtures** since v10.0.2, from Phase 2's preference table, #512's finer
grid, #524's TOU gate and #526's authorization. So the pin does not assert
equality with the tag -- it asserts the *set* of moved fixtures is the one
recorded, and a new divergence appears as a new name rather than hiding in
an aggregate. `historical_2025_01_05_no_spread_no_solar` is the one fixture
still planning exactly as the release does.

Verified the harness actually detects a VPP change rather than passing
vacuously: applying #537's mapping (`LOAD_SUPPORT` -> `1, True`) fails **32
of 36** fixtures. That is the instrument #537 was blocked for.

**A delta from this means "behaviour changed", never "behaviour got
worse."** At 15-minute point forecasts there is no within-period spike, so
it models the intra-period gate's cost but never its benefit -- the same
structural blindness that makes the TOU simulator deliberately not mirror
the gate (27/36 fixtures, +25.67 SEK of pure artefact). Stated in both
module docstrings, because quoting such a delta as an economic verdict is
the misuse this repo has already had to retract figures for.

The baseline lives in `data/baselines/` deliberately:
`golden_capture.fixture_names()` globs `data/*.json`, and a file dropped in
the parent silently becomes a 37th scenario and changes what the bit-parity
gate means. Caught during development -- the corpus did briefly read 37.

Suites: 1690 fast + 496 slow passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYRyY3MYjN4dnFEWnxbGyW
johanzander added a commit that referenced this pull request Aug 11, 2026
…s the corpus cannot reach (#541 review)

Six review findings, one medium and five low.

**A forced-export command at the SoE floor charged the battery.** When
nothing is deliverable -- SoE at min, or the AC stage full of PV -- the
discharge branches returned `-0.0`, which is not below `POWER_TOLERANCE_KW`,
so `_state_transition` took its IDLE branch and absorbed solar surplus into
the battery. A *charge*, under a forced-export command, contradicting both
BATTERY_EXPORT's `charge_rate = 0` and this file's own grid_first hold.

Same defect class as the `+1%`-hold bug the previous commit fixed, and
reachable: `realworld_2026_04_29_220919` period 37 commands (-1, True) at
SoE 2.5 (== min) with 1.1 kWh of surplus and charged to 3.567 kWh. Both
discharge branches now return None, the file's own hold convention.
Regenerating the baseline moves that fixture -91.77 -> -93.39 SEK, matching
the ~1.63 the reviewer predicted.

**grid_first's load-serving branch was dead across the whole corpus.**
Replacing its condition with `if False` left all 74 tests green: no
SOLAR_EXPORT period in any of the 36 fixtures has `home > solar`. So the
#118/#466-vs-#355 modelling choice -- argued at length in the PR description
-- had no test behind it at all. `test_vpp_simulator_branches.py` now pins
every branch of `vpp_command_to_power` directly, including that one, and the
code says which reading it encodes and why, so revisiting it (#537, or a
hardware confirmation) has to be deliberate.

Verified by regression, per the rule this session just added to rules.md:
killing the zero-delivery guard fails 4 tests; killing the grid_first
load-serving branch fails the test written for it. Neither failed before.

Also from the review:

- `test_drift_from_the_released_version_is_recorded` compared two fields of
  the same JSON file, so it executed no production code and could only fail
  if someone hand-edited the artefact -- documentation shaped like a test. It
  now recomputes the plans, which makes it a real check on a sloppy
  re-baseline.
- `test_current_plan_is_pinned` omitted `soe_trajectory`, so an
  execution-model change that shifted the SoE path while netting out in
  commands and total cost escaped the stricter half of the pin. Added.
- The same test raised a bare KeyError for a fixture with no baseline entry
  instead of the regeneration guidance its siblings emit. Guarded.
- `capture_fixture()` was dead and its schema no longer matched the baseline,
  so anyone reaching for it as the entry point would produce a baseline the
  tests reject. Removed.

Suites: 1701 fast + 532 slow passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYRyY3MYjN4dnFEWnxbGyW
johanzander added a commit that referenced this pull request Aug 11, 2026
Written after #537 reached review with a design defect: it mapped the
#520 discharge gate's closed state onto Growatt VPP as a battery_first
hold, which delivers nothing, where TOU's closed state delivers the
planned discharge and merely declines to raise the ceiling. Measured on
the corpus, that abandons 118.11 kWh across 172 periods -- every
gate-closed LOAD_SUPPORT period has a real planned discharge behind it.

The inference that produced it was that VPP can express "discharge, but
only this much". It can, for BATTERY_EXPORT, whose power_pct is the
plan-scaled rate negated. It cannot for LOAD_SUPPORT, which maps to
release-control -- one command for every planned rate. LOAD_SUPPORT is
the only intent where that is true, so the two sit side by side and the
wrong one generalises.

Nothing in the suite could see this. The golden corpus, test_scenarios
and the R == P checks all pin what the DP *plans*; executable fidelity
was never measured, so a change making a platform less able to follow
the plan stayed invisible. The VPP simulator cannot adjudicate it either
-- by its own docstring it scores gate-closed changes as a loss whether
or not they are one.

Sweeps the full planned-action range per intent and counts distinct
commands out. 101 rates -> 1 command means the magnitude is discarded at
execution. LOAD_SUPPORT is declared lossy; any other intent becoming
lossy now fails a test that says so. Verified discriminating: clearing
VPP_LOSSY_INTENTS fails 2 of 8.

Does not itself fail on #537 -- that parameter does not exist on this
branch, so the sweep cannot reach the gate-closed path. It pins the
asymmetry that caused the wrong inference, and needs extending to sweep
gate states when #537 is redesigned.

The LOAD_SUPPORT lossiness is pre-existing on main and beta, not new
here: 522 of 603 LOAD_SUPPORT periods already carry a partial planned
rate that VPP discards. Reported by ridax67 on #520 from the other
direction -- he has compensated for it with a load-tracking automation
since day one on VPP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYRyY3MYjN4dnFEWnxbGyW
johanzander added a commit that referenced this pull request Aug 11, 2026
Seven findings from the review of PR #541.

- vpp_simulator: the released-control branch returned -delivered/dt
  unconditionally, so zero delivery yielded -0.0 -- the exact value the
  two enabled-remote branches return None for, because -0.0 misses
  _state_transition's discharge tolerance and falls into IDLE's solar
  absorption. Safe today only because the branch needs home > solar, so
  no surplus exists; that invariant was incidental and #537 wiring a
  planned rate in would break it. Now handled the same way in all three.
- vpp_simulator: derive_vpp_commands iterates actions_kw and indexes
  intents, so a short actions_kw silently priced part of a day, which
  reads as a plan change against the baseline. Rejected at the input.
- growatt controller: one confirmation flag per flash register instead
  of one for both. A drifted register is now repaired without rewriting
  the healthy one, and a read that returns None (transient API error or
  an unavailable entity, not just "not configured") is logged as unknown
  and repaired on its own rather than costing both registers a write.
- vpp baseline: a fixture added after v10.0.2 has no released plan and
  never will, but its only remedy was the full re-baseline the capture
  script warns against. --add-new records plan: null plus a pinned
  current plan; the replay test skips those and the drift count is taken
  over v10.0.2-referenced fixtures only.
- dp guardrail test: the all-IDLE bound compared a curtailment-aware
  solve against a baseline with no such adjustment, at real prices,
  while production judges that guardrail at reward_sell_price. Skipped
  with the reason, since neither outcome said anything about it.
- CHANGELOG: the AC-charging repair is user-visible -- GRID_CHARGING
  drew nothing from the grid on a half-drifted install.

Tests: full suite 2252 passed, 18 skipped. --add-new verified end to end
with a throwaway fixture, then reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BntZEhfm9Zqt8wgxJpbyYT
johanzander added a commit that referenced this pull request Aug 11, 2026
#540 and #541 merged, which invalidated part of this document. Merging
main in and re-checking rather than letting it rot:

Stale claim 1: "Growatt VPP has no execution simulation until #541
merges." It has merged. Replaced with what the closure actually buys,
plus the two limits that matter for Pass 3 -- the harness reads
"changed" not "worse", and it would not have caught #537, because that
defect was in what a platform can execute rather than in what the plan
costs.

Stale claim 2: the framing that VPP was the exposed platform because
inverter_simulator is TOU-only. True for behavioural coverage, but Pass
2's one completely unguarded fix (#302) is on the TOU side and is a
crash, which no simulator would have caught. Corrected in place instead
of left as the document's premise.

Both defect fixes re-verified against merged main as a reader would find
them -- fix reverted, suite run, named test observed to fail, tree
restored:

  #302, delete the DST end-time cap      -> 1 test fails (unchanged)
  #399, blank the hardware read-back     -> 4 tests fail (was 1)

The #399 count went up because review improved the fix after this audit
wrote its guard (9b65b82, merged with #541): the two flash registers
are now confirmed per register and both are read back, since they can
drift apart and rewriting the healthy one is exactly the wear #399 asked
to remove.

That is worth recording as the counter-example to this audit's main
finding. Three guards here asserted what the fix changed rather than
what the reporter measured; this one got stronger between being written
and being merged, because a reviewer asked what else could drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYRyY3MYjN4dnFEWnxbGyW
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.

Apply the intra-period discharge gate to LOAD_SUPPORT on both platforms (settles #384/#393)

2 participants