Skip to content

fix: split signed power in sensor diagnostics, raise on unknown battery polarity (#542 follow-up) - #591

Merged
johanzander merged 3 commits into
mainfrom
fix/issue-542-signed-power-display
Aug 14, 2026
Merged

fix: split signed power in sensor diagnostics, raise on unknown battery polarity (#542 follow-up)#591
johanzander merged 3 commits into
mainfrom
fix/issue-542-signed-power-display

Conversation

@johanzander

Copy link
Copy Markdown
Owner

Summary

Root cause

Two latent defects left by #560, both recorded in TODO.md at the time:

  1. get_method_sensor_info reads /api/states/{entity_id} directly rather than going through the getters, so on a native SolaX discharging at 800 W it assigned -800 to current_value for both "Battery Charging Power" and "Battery Discharging Power". Identical defect on the Solis/Huawei grid pairing.
  2. get_battery_charge_power/get_battery_discharge_power applied max(0.0, ±raw) with a trailing # charge_positive comment rather than branching on battery_power_polarity — a typo'd entry in PLATFORM_BATTERY_POWER_POLARITY would 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_check calls the getter (method()) for rawValue/displayValue, so it has always rendered 0 W / 800 W correctly — which docs/SOFTWARE_DESIGN.md already stated. The defect was confined to current_value, which no consumer reads today (grepped health_check.py, backend/, frontend/src). Latent, not user-visible. TODO.md carries 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, raises ValueError on anything but charge_positive. Valid configurations are byte-identical.
  • _split_signed_grid_power(raw, *, importing) — same shape, deliberately keeps the existing lax semantics (anything not import_positive is treated as export_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_value was formatted with :g, which rounds 12345.678 to 12345.7 and switches to scientific notation above 1e6 — now .10g.
  • The new ValueError was swallowed by get_method_sensor_info's broad except (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.sh passes locally (🎉 All quality checks passed!)
  • .venv/bin/pytest -m slow passes locally (538 passed, 5 skipped, 1954 deselected in 322.33s)
  • Observed the real code path directly: with a shared signed entity reading -800, get_method_sensor_info returns current_value 0 / 800 on the two battery rows (was -800 / -800), while perform_health_check renders 0 W / 800 W as it always did.
  • Not run: the mock-HA docker stack. This diff changes a diagnostic field and an error branch — no optimizer, scheduler, or hardware-write path is reached — so the stack would exercise nothing the unit tests don't. Flagged rather than silently skipped.

Note on the fast suite: test_bsm_settings_and_lifecycle.py::TestQuietCycleReconcilesHardware (2 tests) fails on this branch. It fails identically on a clean origin/main checkout 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:

  • Reverted: current_value back to the raw statetest_battery_rows_report_the_split_not_the_raw_value and test_grid_rows_report_the_split_not_the_raw_value FAILED (2 failed, 2 passed)
  • Reverted: the polarity branch to max(0.0, raw if charging else -raw) → both TestUnknownBatteryPolarityRaises tests FAILED (2 failed)
  • Reverted: .10g back to :gtest_precision_is_not_degraded FAILED (1 failed)
  • Reverted: the inner except ValueError so the raise falls through to the broad handler → test_unknown_polarity_reports_the_real_reason FAILED (1 failed)
  • Restored after each: 94/94 passing in test_ha_api_controller.py, tree clean

Outcome-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, or R == P scenario 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 how get_method_sensor_info relates 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, and current_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

johanzander and others added 3 commits August 15, 2026 00:24
…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
@johanzander

Copy link
Copy Markdown
Owner Author

CI failure diagnosed — clock-dependent test from #568, not this diff

Fast tests failed on two tests this branch does not touch:

FAILED test_bsm_settings_and_lifecycle.py::TestQuietCycleReconcilesHardware::test_quiet_cycle_reconciles
       - AssertionError: Nothing looked at the inverter on a cycle that changed nothing
FAILED test_bsm_settings_and_lifecycle.py::TestQuietCycleReconcilesHardware::test_failed_reconciliation_is_retried_not_fatal
       - AssertionError: A failed re-assert was swallowed with nothing scheduled to retry it

Root cause. Both 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 collection raises Period 9 is still in progress or in the future, cannot collect complete data and the cycle aborts before reaching reconcile_hardware. The assertion then fails for a reason unrelated to reconciliation.

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 origin/main checkout, so main is red right now during this window.

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 1930 passed, 50 skipped.

Happy to split this into its own PR against main if you'd rather keep this branch single-purpose — it is test-only and cherry-picks cleanly.

@johanzander
johanzander marked this pull request as ready for review August 14, 2026 22:39
@johanzander
johanzander merged commit e81574b into main Aug 14, 2026
8 checks passed
@johanzander
johanzander deleted the fix/issue-542-signed-power-display branch August 14, 2026 22:43
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.

1 participant