fix: apply the intra-period discharge gate to LOAD_SUPPORT on VPP (#520) - #537
fix: apply the intra-period discharge gate to LOAD_SUPPORT on VPP (#520)#537bess-agent wants to merge 3 commits into
Conversation
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
|
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 Worth recording an asymmetry between the two halves of #520 that I had not articulated when this was opened:
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 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 |
…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
|
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 Measured on the 36-fixture corpus:
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 Converting to draft pending redesign. |
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
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
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
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
…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
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
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
#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
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'sdischarge_rateis 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
vpp_power=0, remote disabledload_firstself-use — inverter covers the spike (#413, unchanged)vpp_power=+1, remote enabledbattery_firsthold — spike imported, battery preservedClosed maps to
+1, not0, and that is the whole subtlety. With remote control enabled the sign ofvpp_powerselects firmware priority, and<= 0isgrid_first— which per #118 still draws self-consumption from the battery. Only> 0releases the house to grid/solar. Writing0would 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 fromFalse("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_scheduleinto the VPP command mapping, soapply_period/_apply_period_vpp/_intent_to_vppeach take one new argument. Chosen owner: the same routeblock_passive_chargingandstrategic_intentalready travel, which exist for exactly this — VPP-only distinctions thatgrid_charge/discharge_ratecannot express (seeapply_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_apply_period_schedule, not just assertions:Verification gaps — stated, not glossed
implement-issuenormally requires for control-mapping changes.simulation/inverter_simulator.pyis "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 mirrorstest_vpp_discharge_gate_capability.py, which exists for the same reason.ci-growatt-vppscenario boots withselect.growatt_min_vpp_remote_controlunavailable, 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.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 withcontrol_mode=vpp. What is unvalidated is narrower: the two hold branches this builds on (battery_firstQuestion: How is IDLE used? #466,grid_firstHas it lost sense of battery wear cost? #355) ship pending confirmation, so thevpp_power=+1gate-closed command specifically wants one of those testers confirming the battery actually holds.Docs
INVERTER_PLATFORMS.mdgains a "LOAD_SUPPORT is now gated" section with the mapping table and the+1vs0reasoning.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