Skip to content

Add per-battery distribution weight for fair-share load balancing#413

Merged
tomquist merged 4 commits into
developfrom
claude/affectionate-sagan-ncftQ
Jun 1, 2026
Merged

Add per-battery distribution weight for fair-share load balancing#413
tomquist merged 4 commits into
developfrom
claude/affectionate-sagan-ncftQ

Conversation

@tomquist

@tomquist tomquist commented Jun 1, 2026

Copy link
Copy Markdown
Owner

Adds a Distribution Weight control that lets users bias how the load balancer splits demand across multiple batteries. Each battery gets a weight (default 1.0, range 0.0 < w ≤ 10.0) that scales its proportional share of the total load—e.g., setting weights to 1.5 and 1.0 produces roughly a 60:40 split instead of 50:50.

Key Changes

MQTT & Home Assistant Integration:

  • Refactored consumer command topics from a single JSON endpoint ({base}/ct002/{dev}/consumer/{cid}/set) to per-field scalar topics ({base}/ct002/{dev}/consumer/{cid}/{field}/set), where field is one of active, auto_target, manual_target, or distribution_weight
  • Each field topic is retained, allowing Home Assistant to persist control values across AstraMeter restarts without local state storage
  • Added register_distribution_weight_handler() to the MQTT service and corresponding Home Assistant discovery entity
  • Updated command parsing to handle scalar payloads (booleans as "true"/"false"/"on"/"off"/"1"/"0", floats as decimal strings) instead of JSON objects

Load Balancer:

  • Introduced _report_weight() helper to extract per-battery weight from reports (defaults to 1.0 if missing)
  • Modified fair-share calculation to fold user weights into the effectiveness map, so the weighted ratio becomes the steady-state target
  • Updated balance correction to pull each battery toward its weight-proportional share rather than the plain average
  • Backup weight calculation in probe targeting now includes the user weight factor

Data Model:

  • Added distribution_weight: float = 1.0 field to Consumer (Python) and Consumer struct (C++)
  • Added weight: float field to ConsumerReport (C++) and report dict (Python)
  • Added distribution_weight to consumer snapshots and MQTT state publications

Testing:

  • Added test_distribution_weight_command_via_mqtt() integration test
  • Added test_handle_consumer_field_command_dispatch() unit test for per-field parsing
  • Added tests/test_balancer_distribution_weight.py with three scenarios: fair-share honouring weight, neutral weight matching equal split, and balance correction targeting weighted share
  • Added C++ host test LoadBalancer.AutoSplitHonoursDistributionWeight

Documentation:

  • Updated README and example YAML to mention the new distribution-weight control
  • Updated CHANGELOG with user-facing summary

Implementation Details

The per-field topic design leverages MQTT's retained message semantics: Home Assistant publishes each control value to its own retained topic, so the broker automatically redelivers it on re-subscribe. This eliminates the need for AstraMeter to persist control state locally.

At the neutral default (all weights 1.0), the weighted fair-share calculation is mathematically identical to the unweighted behaviour, ensuring backward compatibility.

https://claude.ai/code/session_01PufmKr2LPyXP7mKSEWxcbp

Summary by CodeRabbit

  • New Features

    • Per-battery "Distribution Weight" (default 1.0) to bias load splitting; per-battery retained MQTT command topics so settings are restored after restarts
    • Distribution Weight inputs validated and bounded for safe operation
  • Documentation

    • Updated docs and examples to cover Distribution Weight, per-battery controls, and retained-MQTT behavior
  • Tests

    • Added unit and integration tests for weight-aware distribution and MQTT command handling

claude added 2 commits June 1, 2026 12:04
Add a per-battery "Distribution Weight" Home Assistant number entity
(default 1.0) that biases how the load balancer splits demand across
multiple batteries. For unequal batteries this avoids the smaller one
saturating first every day -- e.g. set 1.5 and 1.0 for a ~60:40 split
(discussion #412).

The weight rides along in each consumer's report dict and folds into the
balancer's existing weight-normalised fair-share math: the pure
health/saturation map (eff_part) still decides participation and probing,
while a derived share map (eff_part * weight) drives the proportional
split, and balance_correction targets the weight-proportional share
instead of the plain average. Neutral weights (all 1.0) reproduce the
previous behaviour exactly.

Persist the per-consumer controls via retained MQTT commands instead of a
local store: manual_target, auto_target, active and the new
distribution_weight each move onto their own dedicated command sub-topic
with retain=true, so Home Assistant restores the values across an
AstraMeter restart when the broker redelivers the retained command on
re-subscribe.

Mirrored across the Python and ESPHome (C++) CT002 stacks per the parity
rule; docs, examples, changelog and Python + C++ host tests updated.
Collapse the three identical `float(report.get("weight", 1.0) or 1.0)`
expressions in the balancer into a single `_report_weight` helper, so the
falsy-guard is documented once. Pure refactor — no behavior change.
@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ef948e90-b346-480d-86d3-547525abe6cf

📥 Commits

Reviewing files that changed from the base of the PR and between 89af032 and 0dc81d0.

📒 Files selected for processing (11)
  • README.md
  • esphome/components/ct002/ct002.cpp
  • esphome/components/ct002/ha_discovery.cpp
  • esphome/components/ct002/mqtt_insights.cpp
  • src/astrameter/ct002/balancer.py
  • src/astrameter/ct002/ct002.py
  • src/astrameter/mqtt_insights/discovery.py
  • src/astrameter/mqtt_insights/mqtt_insights_test.py
  • src/astrameter/mqtt_insights/service.py
  • tests/components/ct002/host_balancer_test.cpp
  • tests/test_balancer_distribution_weight.py
✅ Files skipped from review due to trivial changes (2)
  • tests/components/ct002/host_balancer_test.cpp
  • README.md
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/astrameter/ct002/ct002.py
  • esphome/components/ct002/ha_discovery.cpp
  • esphome/components/ct002/ct002.cpp
  • src/astrameter/mqtt_insights/service.py
  • src/astrameter/mqtt_insights/discovery.py
  • esphome/components/ct002/mqtt_insights.cpp

Walkthrough

Adds a per-battery Distribution Weight (float, default 1.0, range [0.0,10.0]) used by the load-balancer to bias fair-share splits; exposes a new HA number entity and retained per-field MQTT command topics; updates balancer logic in Python/C++; and adds tests and docs.

Changes

Per-battery Distribution Weight Feature

Layer / File(s) Summary
Data model and setter interface
src/astrameter/ct002/ct002.py, esphome/components/ct002/ct002.h, esphome/components/ct002/ct002.cpp, esphome/components/ct002/balancer.h
Adds distribution_weight to consumer state and snapshots (default 1.0) and implements validated setters (set_consumer_distribution_weight) that require finite values within [0.0, 10.0].
Snapshot, reports and publish plumbing
src/astrameter/ct002/ct002.py, esphome/components/ct002/ct002.cpp, esphome/components/ct002/mqtt_insights.cpp, src/astrameter/ct002/balancer.py
Threads distribution_weight into ConsumerSnapshot/ConsumerReport, includes it in published consumer JSON, and ensures reports passed to LoadBalancer carry each consumer's weight.
Balancer weight-proportional logic
src/astrameter/ct002/balancer.py, esphome/components/ct002/balancer.cpp
Integrates per-consumer weight into probe seeding, auto-target fair-share computation (share_part), and balance-correction target calculation (weighted shares with unweighted fallback).
MQTT Insights service & dispatch
src/astrameter/mqtt_insights/service.py, src/astrameter/mqtt_insights/mqtt_insights_test.py, esphome/components/ct002/mqtt_insights.cpp, esphome/components/ct002/mqtt_insights.h, src/astrameter/main.py
Refactors command subscription to per-field topics (.../consumer/<cid>/<field>/set), adds distribution-weight handler registry and register_distribution_weight_handler, implements scalar parsing/validation (booleans and strict floats), treats empty retained payloads as clears (ignored), and wires the setter via main.py. Adds tests for field-command parsing and MQTT E2E command behavior.
Home Assistant discovery and ESPHome payloads
src/astrameter/mqtt_insights/discovery.py, esphome/components/ct002/ha_discovery.cpp, esphome/components/ct002/*, esphome.example.yaml
Updates HA discovery payloads so each controllable per-consumer setting (manual_target, auto_target, active, distribution_weight) uses its own retained command_topic (e.g., .../distribution_weight/set) with appropriate payload formats and a numeric slider for distribution_weight. Mirrors those changes in ESPHome discovery documentation/examples.
Unit and integration tests
tests/test_balancer_distribution_weight.py, tests/components/ct002/host_balancer_test.cpp, src/astrameter/mqtt_insights/mqtt_insights_test.py
Adds pytest and GoogleTest cases validating weight-proportional fair-share splits (including zero-weight behavior), E2E discovery/publish/command assertions for per-field retained topics, and unit tests for field-command dispatch and validation.
User-facing documentation
CHANGELOG.md, README.md, config.ini.example, esphome.example.yaml
Documents the new Distribution Weight HA entity, explains how to use weights to intentionally split load unevenly, and notes retained per-field MQTT commands preserve values across restarts.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • tomquist/AstraMeter#360: Modifies CT002 LoadBalancer target computation; related to balancer logic changes in this PR.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly and clearly summarizes the main change: adding a per-battery distribution weight mechanism for load balancing across multiple batteries.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/affectionate-sagan-ncftQ

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@tomquist
tomquist marked this pull request as ready for review June 1, 2026 13:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
esphome/components/ct002/ct002.cpp (1)

645-651: 💤 Low value

Intentional silent-ignore behavior differs from Python's ValueError.

The C++ setter silently ignores invalid weights while Python raises a ValueError. This is a reasonable embedded-friendly design choice (avoiding exceptions), but callers have no feedback when an out-of-range or non-finite value is rejected. Consider logging a warning for debuggability.

♻️ Optional: add debug log for rejected values
 void CT002Component::set_consumer_distribution_weight(const std::string &consumer_id,
                                                       float weight) {
   // Clamp to the same (0, 10] range the Python setter enforces; ignore
   // non-finite or out-of-range values rather than corrupting the split.
-  if (!std::isfinite(weight) || weight <= 0.0f || weight > 10.0f) return;
+  if (!std::isfinite(weight) || weight <= 0.0f || weight > 10.0f) {
+    ESP_LOGD(TAG, "Ignoring invalid distribution_weight %.2f for %s", weight, consumer_id.c_str());
+    return;
+  }
   this->get_consumer_(consumer_id).distribution_weight = weight;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@esphome/components/ct002/ct002.cpp` around lines 645 - 651, The setter
CT002Component::set_consumer_distribution_weight currently silently returns on
invalid input; modify it to log a warning before returning so callers can debug
rejected values: when !std::isfinite(weight) or weight <= 0.0f or weight >
10.0f, emit a warning that includes the consumer_id and the rejected weight (and
optionally the reason) and then return; keep the clamping/behavior unchanged
otherwise and locate the change in
CT002Component::set_consumer_distribution_weight (and use the same logging
facility used elsewhere in this component).
src/astrameter/mqtt_insights/mqtt_insights_test.py (1)

941-951: ⚡ Quick win

Assert distribution_weight on the actual MQTT payload, not a reconstructed dict.

This test only repeats data.get("distribution_weight", 1.0), so it still passes if on_ct002_response() stops publishing the field or renames it. Please put distribution_weight into SAMPLE_CT002_DATA and verify it in test_publishes_state_on_ct002_event so the wire-format contract is actually covered.

Suggested test adjustment
 SAMPLE_CT002_DATA = {
     "grid_power": {"l1": 100.0, "l2": 200.0, "l3": 300.0, "total": 600.0},
@@
     "manual_target": None,
     "auto_target": True,
+    "distribution_weight": 1.5,
     "smooth_target": 500.0,
     "active_control": True,
     "consumer_count": 2,
 }
         payload = json.loads(received[0].payload)
         assert payload["grid_power"]["total"] == 600.0
         assert payload["phase"] == "A"
@@
         assert payload["active"] is True
         assert payload["poll_interval"] == 5.0
+        assert payload["distribution_weight"] == 1.5
         assert str(received[0].topic) == f"{base}/ct002/dev1/consumer/consumer1"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/astrameter/mqtt_insights/mqtt_insights_test.py` around lines 941 - 951,
The test is asserting distribution_weight from a locally reconstructed dict
instead of the actual MQTT payload; update SAMPLE_CT002_DATA to include an
explicit distribution_weight value and change
test_publishes_state_on_ct002_event to assert that the published MQTT payload
(as emitted by on_ct002_response or the MQTT publish spy used in tests) contains
distribution_weight with the expected value, and remove the redundant
reconstruction and local assertion in
test_consumer_state_includes_manual_target_fields so the wire-format contract is
actually validated against on_ct002_response's output.
esphome/components/ct002/mqtt_insights.cpp (1)

341-351: 💤 Low value

parse_float_payload accepts trailing non-numeric characters.

strtof stops parsing at the first non-numeric character and sets end to point there. The current check end == begin only catches completely unparseable strings, but "1.5abc" would parse as 1.5 successfully. This differs from Python's float() which would reject "1.5abc".

Consider whether this permissiveness is acceptable. If not:

Proposed stricter validation
 static bool parse_float_payload(const std::string &payload, float &out) {
   const char *begin = payload.c_str();
   char *end = nullptr;
   errno = 0;
   const float v = std::strtof(begin, &end);
   if (end == begin || errno != 0)
     return false;
+  // Reject trailing non-whitespace characters
+  while (*end != '\0') {
+    if (!std::isspace(static_cast<unsigned char>(*end)))
+      return false;
+    ++end;
+  }
   out = v;
   return true;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@esphome/components/ct002/mqtt_insights.cpp` around lines 341 - 351, The
function parse_float_payload currently accepts inputs with trailing non-numeric
characters because it only checks end == begin; modify parse_float_payload to
ensure the entire payload is numeric (allowing leading/trailing whitespace).
After calling std::strtof(begin, &end) and checking end==begin and errno,
advance end over any whitespace (use isspace) and then require *end == '\0'
(otherwise return false). Keep setting out = v only when the full-string
validation passes; reference parse_float_payload, begin, end, std::strtof, errno
in your change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/astrameter/mqtt_insights/mqtt_insights_test.py`:
- Around line 125-131: The test currently asserts the Home Assistant numeric
entity minimum is 0.1 which incorrectly narrows the allowed range; update the
assertion in mqtt_insights_test.py where weight = comps["distribution_weight"]
to expect the contract's lower bound (0.0) instead of 0.1 (i.e., assert
weight["min"] == 0.0) so the HA control doesn't forbid valid positive values
<0.1; if 0.1 was intentional, instead update the public spec/docs to reflect the
stricter lower bound and keep the test in sync.

---

Nitpick comments:
In `@esphome/components/ct002/ct002.cpp`:
- Around line 645-651: The setter
CT002Component::set_consumer_distribution_weight currently silently returns on
invalid input; modify it to log a warning before returning so callers can debug
rejected values: when !std::isfinite(weight) or weight <= 0.0f or weight >
10.0f, emit a warning that includes the consumer_id and the rejected weight (and
optionally the reason) and then return; keep the clamping/behavior unchanged
otherwise and locate the change in
CT002Component::set_consumer_distribution_weight (and use the same logging
facility used elsewhere in this component).

In `@esphome/components/ct002/mqtt_insights.cpp`:
- Around line 341-351: The function parse_float_payload currently accepts inputs
with trailing non-numeric characters because it only checks end == begin; modify
parse_float_payload to ensure the entire payload is numeric (allowing
leading/trailing whitespace). After calling std::strtof(begin, &end) and
checking end==begin and errno, advance end over any whitespace (use isspace) and
then require *end == '\0' (otherwise return false). Keep setting out = v only
when the full-string validation passes; reference parse_float_payload, begin,
end, std::strtof, errno in your change.

In `@src/astrameter/mqtt_insights/mqtt_insights_test.py`:
- Around line 941-951: The test is asserting distribution_weight from a locally
reconstructed dict instead of the actual MQTT payload; update SAMPLE_CT002_DATA
to include an explicit distribution_weight value and change
test_publishes_state_on_ct002_event to assert that the published MQTT payload
(as emitted by on_ct002_response or the MQTT publish spy used in tests) contains
distribution_weight with the expected value, and remove the redundant
reconstruction and local assertion in
test_consumer_state_includes_manual_target_fields so the wire-format contract is
actually validated against on_ct002_response's output.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f7ef22d5-f8db-4ffc-a10b-28e8430639e5

📥 Commits

Reviewing files that changed from the base of the PR and between 1e105e0 and 443fd06.

📒 Files selected for processing (19)
  • CHANGELOG.md
  • README.md
  • config.ini.example
  • esphome.example.yaml
  • esphome/components/ct002/balancer.cpp
  • esphome/components/ct002/balancer.h
  • esphome/components/ct002/ct002.cpp
  • esphome/components/ct002/ct002.h
  • esphome/components/ct002/ha_discovery.cpp
  • esphome/components/ct002/mqtt_insights.cpp
  • esphome/components/ct002/mqtt_insights.h
  • src/astrameter/ct002/balancer.py
  • src/astrameter/ct002/ct002.py
  • src/astrameter/main.py
  • src/astrameter/mqtt_insights/discovery.py
  • src/astrameter/mqtt_insights/mqtt_insights_test.py
  • src/astrameter/mqtt_insights/service.py
  • tests/components/ct002/host_balancer_test.cpp
  • tests/test_balancer_distribution_weight.py

Comment thread src/astrameter/mqtt_insights/mqtt_insights_test.py
claude added 2 commits June 1, 2026 13:47
- C++ parse_float_payload now rejects trailing non-whitespace (e.g.
  "1.5abc"), matching Python's float() so the two command paths stay
  behaviourally identical.
- Cover distribution_weight on the actual published MQTT payload: add it to
  SAMPLE_CT002_DATA and assert it in test_publishes_state_on_ct002_event,
  dropping the redundant reconstructed-dict assertion.
Widen the per-battery weight contract from (0, 10] to [0, 10] across both
stacks. A weight of 0.0 now parks a battery at 0 W while leaving it in the
pool (distinct from the Active switch's hard pause), and the Home Assistant
slider min becomes 0 so the UI matches the backend.

Fixes the _report_weight helper to preserve an explicit 0.0 instead of
folding it back to 1.0 via the old `or 1.0` guard; missing/None still maps
to the neutral 1.0. Updates Python + C++ setters, both command-path range
checks, both discovery payloads, docs, and adds zero-weight tests on both
stacks.
@tomquist
tomquist merged commit 7dedb97 into develop Jun 1, 2026
45 of 46 checks passed
@tomquist
tomquist deleted the claude/affectionate-sagan-ncftQ branch June 1, 2026 14:43
tomquist pushed a commit that referenced this pull request Jun 1, 2026
Two test-only follow-ups after rebasing onto the per-battery weight feature
(develop #413):

- test_shared_e2e.py: disable saturation detection on both stacks in
  test_python_esphome_wire_identical. The saturation score is a long-running
  EMA carried in float64 (Python) vs float32 (ESPHome); over a long poll
  sequence the two drift by sub-ULP amounts that can straddle a participation
  threshold and amplify into a whole-watt target difference. That is the
  documented float-vs-double artifact, not a wire-format divergence, so the
  byte-for-byte handler comparison runs with the EMA off. The EMA path itself
  stays covered (at integer-watt tolerance) by the differential fuzzer. Adds a
  saturation_detection flag to PythonBackend and toggles the binary via the
  existing cfg control command.

- test_balancer_parity.py: compare raw target magnitudes against a
  half-watt tolerance instead of rounding each side and checking integer
  equality. A value near an X.5 boundary could round to X on one stack and
  X+1 on the other despite a <0.001 W true difference, which could flake in
  CI (CodeRabbit review on PR #406).

https://claude.ai/code/session_01X6DBrU9twwv92ZG9aAUMjN
tomquist added a commit that referenced this pull request Jun 1, 2026
* test(ct002): add cross-stack LoadBalancer parity harness and phase-routing E2E

Increase Python <-> ESPHome parity coverage for the CT002 balancer:

- New differential parity test (test_balancer_parity.py + fixtures/
  balancer_parity_harness.cpp): compiles the real esphome/components/ct002/
  balancer.cpp with host g++ and drives the same inactive / manual / auto
  scenarios (import, export, multi-phase, asymmetric reports, mixed AC/DC
  device types) through both the C++ port and the canonical Python
  LoadBalancer, asserting identical per-phase targets at wire granularity.
  Skips cleanly when no C++ compiler is present.

- New shared E2E scenario test_phase_routing in test_shared_e2e.py: verifies
  a battery's target lands only on the phase it reports (zero on the others),
  parametrized over A/B/C and run against both backends.

Both stacks agree across all added scenarios; no behavioral divergence found.

* Expand CT002 Python<->ESPHome E2E parity coverage and align fair-share guard

Broaden the cross-stack parity tests until they exercise the drift-prone
paths, and fix the one divergence they surfaced.

Tests:
- Rework test_balancer_parity.py into a stateful differential harness: the
  host-gcc fixture now links the real balancer.cpp and drives a single
  LoadBalancer through a command stream (cfg/clock/advance/target/sat/last),
  replayed identically against the canonical Python LoadBalancer. Adds
  multi-poll efficiency/probe/rotation/saturation scenarios plus 40 seeds of
  randomized differential fuzzing, so the time-dependent machinery is compared
  poll-by-poll, not just one-shot phase splits.
- test_shared_e2e.py: add cross-talk scenarios (a charging/discharging battery
  shows up with the right sign in another battery's per-phase chrg/dchrg
  fields) and a direct dual-backend wire comparison that replays a seeded
  multi-battery poll sequence through both the Python emulator and the live
  ESPHome binary and asserts the response fields match byte-for-byte. Factor
  the esphome-binary launch into a reusable context manager.

Fix:
- balancer.py compute_auto_target: guard fair_share against a zero/negative
  total effective weight, mirroring the existing C++ guard in
  compute_auto_target_. Previously Python could raise ZeroDivisionError where
  C++ fell back to an even split; both now use grid_total / num_consumers.
  Defensive (unreachable via compute_target today) but keeps the two stacks
  structurally identical.

Verified with esphome 2026.5.1 installed: full tests/components/ct002 suite
green (host gtest, host e2e, shared e2e on both backends, wire comparison)
plus tests/test_balancer.py; ruff format/check and mypy clean.

https://claude.ai/code/session_01X6DBrU9twwv92ZG9aAUMjN

* Make wire-identical parity deterministic and tolerate half-watt rounding

Two test-only follow-ups after rebasing onto the per-battery weight feature
(develop #413):

- test_shared_e2e.py: disable saturation detection on both stacks in
  test_python_esphome_wire_identical. The saturation score is a long-running
  EMA carried in float64 (Python) vs float32 (ESPHome); over a long poll
  sequence the two drift by sub-ULP amounts that can straddle a participation
  threshold and amplify into a whole-watt target difference. That is the
  documented float-vs-double artifact, not a wire-format divergence, so the
  byte-for-byte handler comparison runs with the EMA off. The EMA path itself
  stays covered (at integer-watt tolerance) by the differential fuzzer. Adds a
  saturation_detection flag to PythonBackend and toggles the binary via the
  existing cfg control command.

- test_balancer_parity.py: compare raw target magnitudes against a
  half-watt tolerance instead of rounding each side and checking integer
  equality. A value near an X.5 boundary could round to X on one stack and
  X+1 on the other despite a <0.001 W true difference, which could flake in
  CI (CodeRabbit review on PR #406).

https://claude.ai/code/session_01X6DBrU9twwv92ZG9aAUMjN

* test(ct002): add subprocess timeouts and dump command stream on parity failure

Address CodeRabbit nitpicks on PR #406:
- Add timeout=120 to the harness compile and timeout=30 to the harness run so
  a hung compiler or runaway binary fails fast instead of stalling CI.
- Use the previously-unused `lines` argument in _compare: dump the command
  stream alongside the mismatch list so a failing (randomized) scenario is
  reproducible straight from the assertion output.

https://claude.ai/code/session_01X6DBrU9twwv92ZG9aAUMjN

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

2 participants