Skip to content

fix: stop fabricating TOU mode labels for VPP/period-list inverters (#415) - #424

Open
johanzander wants to merge 29 commits into
mainfrom
design/issue-415-control-model-display
Open

fix: stop fabricating TOU mode labels for VPP/period-list inverters (#415)#424
johanzander wants to merge 29 commits into
mainfrom
design/issue-415-control-model-display

Conversation

@johanzander

Copy link
Copy Markdown
Owner

Summary

Fixes #415: the Schedule Overview table (and other views) showed a fictional load_first/battery_first/grid_first TOU-register mode label for control models that don't have one. A VPP-controlled Growatt inverter's SOLAR_EXPORT period displayed as "Load First" when the hardware was actually doing a grid-first-style hold.

Rather than patching just the reported Growatt VPP case, this generalizes the fix across all 6 InverterController subclasses, since the same root cause (a static INTENT_TO_MODE lookup built for TOU-register hardware, applied unconditionally) affected native SolaX (VPP) and the three period-list controllers (Growatt SPH, Solis, Huawei) too — see the design spec for the full survey.

  • Adds a CONTROL_MODEL classification (tou_register | vpp_power | period_list) reflecting each controller's real hardware model.
  • Adds a shared _mode_display_fields() helper, wired into the three places that were independently fabricating a mode label: get_period_settings(), get_detailed_period_groups() (the actual data source behind the reported bug), and each controller's get_all_tou_segments().
  • SolaxController and SolaxModbusGrowattController's VPP behavior is deliberately not unified — their real hardware behavior has diverged (SolaX never received the Has it lost sense of battery wear cost? #355/VPP mode: LOAD_SUPPORT forces fixed grid_first discharge rate instead of releasing control #413 Growatt VPP fixes) and unifying them would misrepresent or silently change real SolaX hardware behavior. Tracked as a separate follow-up in TODO.md.
  • Backend API exposes controlModel/vppPowerPct/vppRemoteControl; three frontend components (InverterStatusDashboard, SystemStatusCard, PredictionAnalysisView) branch display on it instead of inferring TOU-vs-not from a platform-name string.

Design spec: docs/superpowers/specs/2026-07-29-control-model-display-design.md
Implementation plan: docs/superpowers/plans/2026-07-29-control-model-display.md

Implemented via subagent-driven-development: 15 plan tasks, each with its own implementer + task-scoped reviewer, plus a mid-plan regression caught by the full slow-test-suite run (a logging consumer of a renamed field that no task's scope covered), plus a final whole-branch review that caught one Critical + three Important cross-task findings (most notably: the Inverter page's "Current Mode" card still fabricated "Load First" via an untouched backend hourly-compat shim) — all fixed and re-reviewed clean.

Test plan

  • Backend fast suite: 1404 passed, 15 skipped
  • Backend slow/integration suite: 391 passed, 3 skipped
  • Frontend unit tests: 108/108 passed
  • TypeScript type-check: clean
  • ESLint: 0 errors (81 pre-existing warnings, unrelated)
  • Black/Ruff: clean
  • New Playwright e2e spec (e2e/tests/inverter-schedule-control-model.spec.ts) — could not run against the real chromium binary in the sandboxed dev environment (browser install extraction hangs after a successful download, confirmed reproducible 3x, not a network/code issue). Verified 3/3 passing via a manual system-Chrome substitution instead. Needs a real CI run before merge.

🤖 Generated with Claude Code

johanzander and others added 29 commits July 29, 2026 10:51
Generalizes the #415 root cause (TOU mode vocabulary forced onto
non-TOU control models) across all six inverter controllers instead of
a Growatt-VPP-only patch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up from #415 design work: SolaxController never received the
#355 SOLAR_EXPORT grid-first-hold or #413 LOAD_SUPPORT release fixes
that SolaxModbusGrowattController(vpp) has. Out of scope for the
display-only #415 fix, tracked here for future hardware validation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reading the actual source revealed SolaxController and
SolaxModbusGrowattController(vpp) have genuinely diverged behavior
(#355, #413 fixes are Growatt-only), not just duplicated code as the
initial draft assumed. Updates the design to keep them as separate
per-controller display methods behind a shared field shape, and
corrects the get_all_tou_segments() claim (6 independent overrides,
not one mergeable implementation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
get_detailed_period_groups() (core/bess/inverter_controller.py:635-760)
is the actual data source behind issue #415's screenshot -- a third,
previously-missed static INTENT_TO_MODE lookup on top of
get_period_settings()/get_all_tou_segments(). Being a single
un-overridden base-class method, fixing it is straightforward and now
folded into the same _mode_display_fields() mechanism.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
)

15-task TDD plan implementing docs/superpowers/specs/2026-07-29-control-model-display-design.md:
classifies all 6 InverterController subclasses by real hardware model
(tou_register/vpp_power/period_list), adds a shared _mode_display_fields()
helper wired into get_period_settings()/get_detailed_period_groups()/
get_all_tou_segments(), updates the API and three frontend components
(InverterStatusDashboard, SystemStatusCard, PredictionAnalysisView), and
adds regression tests throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds tou_register/vpp_power/period_list classification per design spec
docs/superpowers/specs/2026-07-29-control-model-display-design.md,
section 1. No behavior change yet — this is the foundation the display
fix (get_period_settings/get_detailed_period_groups/get_all_tou_segments)
branches on in the following tasks.
Own VPP display computation for SolaX, deliberately NOT shared with
SolaxModbusGrowattController._intent_to_vpp() -- the two controllers'
real hardware behavior has diverged (see design spec section 1
correction, and TODO.md's SolaX VPP gap note).
Single choke point for batt_mode/vpp_power_pct/vpp_remote_control field
computation, branching on CONTROL_MODEL. Not yet wired into
get_period_settings/get_detailed_period_groups/get_all_tou_segments --
next tasks.
Root-cause fix for issue #415: SOLAR_EXPORT on a VPP-controlled Growatt
install no longer reports batt_mode=load_first when the hardware is
actually doing a grid-first hold. Wires the CONTROL_MODEL-driven
_mode_display_fields() helper (Task 3) into the one shared
get_period_settings() implementation.
This is the actual data source behind issue #415's Schedule Overview
screenshot (backend/api.py period_groups/tomorrow_period_groups), not
get_all_tou_segments() as originally assumed -- see design spec section
1, 'Second correction'. Also renames the group field from 'mode' to
'batt_mode' for consistency with get_period_settings().
…es on control_mode

VPP-mode segments now carry vpp_power_pct/vpp_remote_control instead of
a fabricated batt_mode label. TOU mode unchanged.
Reflects SolaX's own real (self-use passthrough) SOLAR_EXPORT behavior
instead of a fabricated batt_mode label -- see TODO.md gap note for why
this differs from Growatt VPP's grid-first hold.
GrowattSphController, SolisModbusController, HuaweiController have no
per-period mode or power concept -- their static 'load_first' stub
(and Huawei's charge/discharge-sign approximation) is dropped entirely
rather than replaced, per design spec section 1.
/api/inverter/status and /api/inverter/schedule now surface controlModel
so the frontend can branch display logic instead of inferring TOU-vs-not
from the platform string (InverterStatusDashboard.tsx's isTouBased bug).
Plain interval["batt_mode"] indexing (no default) would KeyError once
Task 6-8 made batt_mode conditionally absent. Now branches on which
fields the interval actually carries.
Replaces the isTouBased platform-string heuristic (blind to VPP-mode
Growatt) with a proper three-way branch (tou_register/vpp_power/
period_list), adding a VPP Power column for vpp_power installs and
dropping TOU-only columns entirely for both non-tou_register cases.
…ister

The hard-throw on missing batteryMode now only applies when
controlModel === 'tou_register'; the metric itself is omitted from the
card (not shown as 'Unknown' or a stale value) for vpp_power/period_list
installs.

ControlModel moved to frontend/src/types.ts as a shared export;
InverterStatusDashboard.tsx (Task 11) now imports it from there instead
of defining its own local copy.
…label

Previously a binary battery_first/else-'Load First' ternary that would
mislabel any VPP interval. Now branches on whether the interval carries
battMode or vppPowerPct/vppRemoteControl.
…nalysisView

Task 13's review flagged that no test exercised the matchingA/hasChanges/
modeCell.changed path added by renderModeCell(), since both existing tests
left growattScheduleB empty. Add a test with populated growattScheduleA and
growattScheduleB sharing start/end times but differing vppPowerPct, asserting
the "Changed" badge and yellow highlight appear on the Snapshot B entry.

Verified the test fails if the VPP comparison logic in renderModeCell is
reverted to the pre-fix behavior (no vppPowerPct/vppRemoteControl
comparison), confirming it exercises the actual bug fix rather than being
vacuously true.
…h style

Wraps "Inverter schedule display no longer shows a fictional TOU mode label"
in bold to match the convention used by all other entries in the ### Fixed section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 5 (5695b88) renamed get_detailed_period_groups()'s 'mode' field to
'batt_mode' for tou_register controllers, but log_detailed_schedule() in
GrowattMinController still read group['mode'], raising KeyError('mode')
on every schedule update. This slipped past per-task review because the
method is a pure logging/debug consumer never exercised by unit tests -
it only surfaced during the plan's final full-suite (slow) quality gate,
where it caused 22 integration test failures.

Audited every other get_detailed_period_groups() consumer (solax_controller.py,
solax_modbus_growatt_controller.py, backend/api.py) for the same class of bug;
all other call sites already use the correct batt_mode/intent/vpp_power_pct
keys. The remaining group["mode"] hits in growatt_min_controller.py (lines
246, 279, 499) belong to the unrelated _group_periods_by_mode() internal
helper and are untouched.
- Remove unused pytest import from test_control_model_classification.py
- Remove unnecessary noqa: N802 comment from solax_modbus_growatt_controller.py

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… the stack

Fixes all four findings from the whole-branch final review of the
control-model-display work (issue #415):

1. Inverter page's "Current Mode" metric and both /api/inverter/status and
   /api/inverter/schedule backend endpoints still defaulted battery_mode to
   "load_first" for vpp_power/period_list installs. Gate the frontend metric
   on controlModel (VPP power/remote-control state for vpp_power, omitted
   for period_list) and make schedule_data/mode_distribution/current_battery_mode
   conditional on what get_period_settings() actually returns, mirroring the
   period_groups fix already in place.
2. Removed the leftover "batt_mode": "load_first" stub from
   SolaxController/SolaxModbusGrowattController's empty-groups
   get_all_tou_segments() default (only kept for SolaxModbusGrowattController
   in its tou_register mode, which does have a real register).
3. Replaced the hasattr()-based vpp display dispatch in
   _mode_display_fields() with a unified _vpp_display_state() interface
   implemented by both vpp_power controllers, and made the CONTROL_MODEL
   fallback raise ValueError instead of silently returning {}.
4. Restored the real touIntervals hardware panel for period_list installs
   (SPH/Solis/Huawei) -- only vpp_power now shows the "no stored schedule"
   placeholder; the mode badge is branched per-interval instead.

Also loosens e2e/tests/api-contracts.spec.ts's batteryMode assertions to be
controlModel-conditional, since they previously enforced the fabrication
being removed here.

Backend: 1404 passed, 15 skipped (was 1398/15; +6 new tests covering
findings 1 and 2). Frontend: 108 passed, 0 type errors, 0 lint errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…Mode test (#415)

CI (PR #424) failed api-contracts.spec.ts because /api/growatt/inverter_status
returned controlModel: 'tou_register' with batteryMode undefined.

Root cause: the endpoint derives batteryMode from
InverterController.get_period_settings(), which raises ValueError("No
strategic intents available") until the background startup optimization
thread has produced a schedule. backend/api.py correctly catches that and
omits the field rather than fabricating a value (the whole point of this
PR) -- but the CI workflow's startup wait only polls /api/settings, which
responds before startup_complete/schedule readiness, same race
/api/dashboard already guards against with its "initializing" response.

Reproduced live via podman-compose (docker-compose.ci.yml, SCENARIO=
ci-normal-day): while /api/dashboard returned {"error":"initializing"},
/api/growatt/inverter_status logged "Failed to get current battery mode:
No strategic intents available" and returned controlModel: 'tou_register'
with batteryMode absent -- confirming the race is real, not a code bug.

Relaxed the test to accept batteryMode being absent for tou_register when
a schedule isn't computed yet, consistent with the existing "initializing"
tolerance pattern used elsewhere in this same test file for /api/dashboard.
No backend behavior changed -- the backend's no-fabrication contract from
#415 stands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same root cause as #443/#436/#433: branch was cut behind main, before
the v10.0.0 release cut, so it missed the CHANGELOG.md condensation.
Resolves the same way: #415's entry moves into a new Unreleased
section as a one-liner per the updated CHANGELOG guidance.
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.

Confusing presentation (inverter table)

1 participant