fix: split signed power in sensor diagnostics, raise on unknown battery polarity (#542 follow-up) - #591
Conversation
…ry polarity Follow-up to #542 / PR #560, clearing items 2 and 3 of that PR's review. get_method_sensor_info reads /api/states/{entity_id} directly instead of going through the getters, so on a platform whose charge and discharge (or import and export) keys resolve to ONE signed entity it assigned the same raw signed state to current_value for both directional rows -- -800 for both "Battery Charging Power" and "Battery Discharging Power" on a native SolaX discharging at 800 W. It now routes that field through _signed_split_state(), which reuses the getters' own _is_shared_signed_* predicates rather than re-deriving polarity at the display site. Applied to the grid pairing as well as the battery one: the defect is identical there and fixing one copy would leave a known duplicate. This is latent, not user-visible. The health panel was never affected -- perform_health_check calls the getter for rawValue/displayValue, so it has always rendered 0 W / 800 W, as docs/SOFTWARE_DESIGN.md already stated. No consumer reads current_value today. TODO.md carries the correction to its own earlier claim rather than a silent deletion, because the distinction is what a future reader of that list needs. The battery split also moves into _split_signed_battery_power(), which branches on battery_power_polarity explicitly and raises ValueError on anything but charge_positive, instead of hardcoding max(0.0, +/-raw) behind a comment. A typo'd entry in PLATFORM_BATTERY_POWER_POLARITY would otherwise have silently inverted every battery reading. Valid configurations are unchanged. The raise is caught separately inside get_method_sensor_info so a configuration fault is not reported as a connectivity error, which would hide the very failure it exists to surface. The grid helper stays deliberately lax, matching its prior behaviour. No CHANGELOG entry: the #542 feature these defects live in is itself still under [Unreleased], so this is pre-release iteration, not a user-facing fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8Kyf8tver2jbB93ymB5XM
Both tests drive update_battery_schedule(current_period=10) while the rest of
the system reads the real clock. Before 02:30 local, period 9 is still in the
future, so data collection raises ("Period 9 is still in progress or in the
future") and the cycle aborts long before reaching reconcile_hardware. The
assertion then fails for a reason unrelated to reconciliation.
The window is real, not theoretical: these fail every day between local
midnight and 02:30. That is why they passed in CI on the way in with #568
(21:49 UTC = 23:49 local) and failed on the very next PR (22:30 UTC = 00:30
local) -- and why they reproduce locally right now, on a clean origin/main
checkout as well as on this branch.
Pins the time of day to 15:00 while keeping today's date, matching how the
neighbouring lifecycle tests in this file already control time. Verified by
running both test bodies unchanged under a pinned clock: they pass, while the
originals fail at the same moment.
Unrelated to this branch's own change; fixed here because it blocks its CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8Kyf8tver2jbB93ymB5XM
CI failure diagnosed — clock-dependent test from #568, not this diff
Root cause. Both drive The window is real and recurring: they fail every day between local midnight and 02:30. That is exactly why they were green in CI on the way in with #568 (21:49 UTC = 23:49 local) and red on the very next PR (22:30 UTC = 00:30 local). They also reproduce on a clean Proof it is time-of-day and nothing else: both test bodies, copied verbatim into a temp module with only the clock pinned to 15:00, pass — while the originals fail at the same moment. Fixed in 1ac1338 by pinning the time of day (keeping today's date), matching how the neighbouring lifecycle tests in the same file already control time. Fast suite now Happy to split this into its own PR against |
Summary
get_method_sensor_info'scurrent_valuenow reports the same value the getters return for platforms whose charge/discharge (or import/export) keys resolve to ONE signed entity, instead of the raw signed state on both directional rowsbattery_power_polarityexplicitly and raises on an unrecognised value, instead of hardcodingcharge_positivebehind a commentRoot cause
Two latent defects left by #560, both recorded in
TODO.mdat the time:get_method_sensor_inforeads/api/states/{entity_id}directly rather than going through the getters, so on a native SolaX discharging at 800 W it assigned-800tocurrent_valuefor both "Battery Charging Power" and "Battery Discharging Power". Identical defect on the Solis/Huawei grid pairing.get_battery_charge_power/get_battery_discharge_powerappliedmax(0.0, ±raw)with a trailing# charge_positivecomment rather than branching onbattery_power_polarity— a typo'd entry inPLATFORM_BATTERY_POWER_POLARITYwould have silently inverted every battery reading.Correction to TODO item 2's premise, verified by running it: the health panel was never affected.
perform_health_checkcalls the getter (method()) forrawValue/displayValue, so it has always rendered0 W/800 Wcorrectly — whichdocs/SOFTWARE_DESIGN.mdalready stated. The defect was confined tocurrent_value, which no consumer reads today (greppedhealth_check.py,backend/,frontend/src). Latent, not user-visible.TODO.mdcarries the correction rather than a silent deletion, because that distinction is what a future reader of the list needs.Fix
_split_signed_battery_power(raw, *, charging)— explicit polarity branch, raisesValueErroron anything butcharge_positive. Valid configurations are byte-identical._split_signed_grid_power(raw, *, importing)— same shape, deliberately keeps the existing lax semantics (anything notimport_positiveis treated asexport_positive)._signed_split_state(method_name, state)— reuses the getters' own_is_shared_signed_*predicates rather than re-deriving polarity at the display site. Returns the state untouched for other methods, two-entity platforms, and non-numeric states.Applied to the grid pairing as well as the battery one: the defect is identical there, and fixing only the battery copy would leave a known duplicate behind.
Code review
Both findings from the review were fixed in this branch, each with its own test:
current_valuewas formatted with:g, which rounds12345.678to12345.7and switches to scientific notation above 1e6 — now.10g.ValueErrorwas swallowed byget_method_sensor_info's broadexcept (requests.RequestException, ValueError, KeyError)and surfaced as a generic "Failed to check entity" connectivity error — hiding precisely the loud failure the raise exists to produce. Now caught separately and reported with the real reason.Test plan
./scripts/quality-check.shpasses locally (🎉 All quality checks passed!).venv/bin/pytest -m slowpasses locally (538 passed, 5 skipped, 1954 deselected in 322.33s)-800,get_method_sensor_inforeturnscurrent_value0/800on the two battery rows (was-800/-800), whileperform_health_checkrenders0 W/800 Was it always did.Note on the fast suite:
test_bsm_settings_and_lifecycle.py::TestQuietCycleReconcilesHardware(2 tests) fails on this branch. It fails identically on a cleanorigin/maincheckout at the same commit — pre-existing, unrelated to this diff, which touches no BSM code.Evidence the test discriminates
Four mutations, run separately, each reverted from a scratchpad copy:
current_valueback to the rawstate→test_battery_rows_report_the_split_not_the_raw_valueandtest_grid_rows_report_the_split_not_the_raw_valueFAILED (2 failed, 2 passed)max(0.0, raw if charging else -raw)→ bothTestUnknownBatteryPolarityRaisestests FAILED (2 failed).10gback to:g→test_precision_is_not_degradedFAILED (1 failed)except ValueErrorso the raise falls through to the broad handler →test_unknown_polarity_reports_the_real_reasonFAILED (1 failed)test_ha_api_controller.py, tree cleanOutcome-level coverage
None, deliberately — and that is the honest answer, not a gap. The change touches no DP, intent, or control-mapping path, so no fixture
expected_results, selector golden, VPP baseline, orR == Pscenario can reach it. The behaviour is fully characterised by the six diagnostics tests (split on battery and grid, precision, the error path, plus two guards that two-entity platforms and unrelated methods pass through untouched) and the two polarity-raise tests.Documentation check
docs/SOFTWARE_DESIGN.md— updated. Its signed-power section described howget_method_sensor_inforelates to the split; it now documents_signed_split_state(), the two shared split helpers, and the raise.docs/agents/bess-knowledge.md— grepped for the polarity maps, the getters, andcurrent_value. Mentions none of them. No change needed.CHANGELOG
Deliberately no entry. The #542 feature these two defects live in is itself still under
## [Unreleased](CHANGELOG.md), so this is pre-release iteration on unreleased code, not a user-facing fix.Refs #542