Skip to content

Fix uneven multi-battery charging from deadband concentration (#523)#526

Merged
tomquist merged 3 commits into
developfrom
claude/stoic-planck-2r133k
Jun 24, 2026
Merged

Fix uneven multi-battery charging from deadband concentration (#523)#526
tomquist merged 3 commits into
developfrom
claude/stoic-planck-2r133k

Conversation

@tomquist

@tomquist tomquist commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Fixes #523. The share_imbalance_w metric this relies on landed in #525 (merged), so this PR targets develop directly and the CI steering-eval comparison shows the balance change against a base that already has the metric.

Root cause

Deadband concentration (concentrate_deadband, added in 2.2.0 via #480) hands the whole small grid correction to the single most-active battery near steady state so it clears the firmware input deadband, and bypasses balance correction for that tick. But it fired whenever the grid error was small regardless of how the pool was split. An out-of-balance pair (two Venus E3 at ~88 W and ~890 W) produces no grid error — the sum is correct — so the only thing that could equalize them is balance correction, which concentration was suppressing. Result: the imbalance was pinned forever. This is absent in 2.1.2, which is why rolling back fixed it.

Fix (two parts)

  1. Gate concentration on the pool already being balanced — skip it (and let balance correction equalize) whenever any battery is at least balance_deadband off its weight-proportional fair share.
  2. Make equalization grid-neutral — the grid-direction sign clamp used to zero any residual opposing the grid. fair_share always carries the grid's sign, so that clamp only ever fired on the balance-correction term — near steady state, exactly the over-served battery's "back off" half of the swap. That left equalization one-sided (only the under-served battery moved), pushing the pool's net output around and disturbing the grid. Now only the grid-tracking half is clamped; the balance redistribution (≈zero-sum across the phase) goes through, so the over-served battery backs off by exactly what the under-served one takes on. Inert during load steps; only engages near steady state.

Mirrored on the C++/ESPHome side per the parity rule. New regression tests cover both the gate and the two-sided (grid-neutral) swap.

Measured effect (CI steering-eval, seed-averaged across 31 scenarios)

Metric Base Head Δ
share_imbalance_w 157.7 63.7 −60%
overshoot_max_w (guardrail) 91.4 86.7 −5%
overshoot_mean_w 51.0 44.4 −13% ✅
settle_mean_s 30.9 36.1 +17%
grid_p2p_w (guardrail) 191.0 203.1 +6% ⚠️
battery_travel_w_per_h 19619 21414 +9%
cost_regret_ct / grid_rms / mean_abs ~flat ~flat +0–4%

Priority-weighted verdict: −1.4% (better) — vs +3.2% worse with the gate alone (which tripped overshoot_max_w +24%). The grid-neutral swap turned the fix from a net regression into a net improvement: #523's imbalance is fixed (−60%, per-scenario −22% to −86%), overshoot now improves, and what remains is the inherent cost of equalizing two batteries (you have to move power between them): battery_travel +9%, settle +17%, and grid_p2p_w +6% (a smaller guardrail flag, traded down from the +24% overshoot one). All confined to multi-battery same-phase setups; single-battery scenarios are untouched.

This effectively restores 2.1.2-style balancing (what the reporter rolled back to and prefers) without 2.1.2's overshoot.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved battery balancing so multiple batteries on the same phase stay more evenly split during steady-state charging and discharging.
    • Tightened near-zero correction behavior to avoid hiding real imbalances and reduce one-sided “sticking” in edge cases.
    • Made concentration behavior safer by applying it only when the battery pool is already balanced.

@coderabbitai

coderabbitai Bot commented Jun 24, 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: 4527555e-c9eb-45fd-897a-7c433ab19c04

📥 Commits

Reviewing files that changed from the base of the PR and between c0c41ac and 1e3bc2d.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • esphome/components/ct002/balancer.cpp
  • esphome/components/ct002/balancer.h
  • src/astrameter/ct002/balancer.py
  • tests/test_balancer.py

Walkthrough

CT002 balancer now requires a concentration pool to be balanced before deadband concentration runs, and it preserves balance-correction residuals when grid-tracking signs disagree. The Python mirror, tests, and changelog were updated to match.

Changes

CT002 deadband balance fix

Layer / File(s) Summary
Concentration gate and helper
esphome/components/ct002/balancer.h, esphome/components/ct002/balancer.cpp, src/astrameter/ct002/balancer.py, CHANGELOG.md
Adds a pool-balance check for concentration, wires it into the deadband gate in both implementations, and records the imbalance regression in the changelog.
Residual split
esphome/components/ct002/balancer.cpp, src/astrameter/ct002/balancer.py
Changes residual handling so sign disagreement clamps only the grid-tracking part while keeping the balance-correction portion intact.
Deadband concentration tests
tests/test_balancer.py
Updates concentration and equalization coverage for balanced pools, imbalanced pools, and zero-weight participant behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% 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 title is concise and accurately describes the main fix to uneven multi-battery charging.
Linked Issues check ✅ Passed The changes gate concentration on balanced pools and preserve balance correction, matching #523's goal to restore even charging/discharging.
Out of Scope Changes check ✅ Passed The changelog, code, and tests all support the balancing fix; no unrelated changes are indicated.

✏️ 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/stoic-planck-2r133k

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.

Base automatically changed from claude/balance-eval-metric to develop June 24, 2026 19:16
tomquist and others added 2 commits June 24, 2026 19:17
Deadband concentration (added in 2.2.0, concentrate_deadband) hands the whole
small grid correction to the single most-active battery near steady state so it
clears the firmware input deadband, and bypasses balance correction for that
tick. But it fired whenever the grid error was small regardless of how the pool
was split, so an out-of-balance pair (issue #523: two Venus E3 at ~88 W and
~890 W) was pinned: the equalizing balance correction never ran and the busiest
battery kept absorbing every correction.

Gate concentration on the pool already being balanced — skip it (and let
balance correction equalize) whenever any participating battery deviates from
its weight-proportional fair share by at least balance_deadband. This keeps the
steady-state self-consumption benefit of #480 while never suppressing
equalization of a real imbalance. Mirrored on the C++/ESPHome side per the
parity rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AiZvgThQbNq5hUfGHARuWB
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AiZvgThQbNq5hUfGHARuWB
@tomquist tomquist force-pushed the claude/stoic-planck-2r133k branch from 894138d to ee48b26 Compare June 24, 2026 19:18
@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Steering evaluation (base vs head)

Overall: 4 improved, 11 regressed, 0 unchanged across 15 metrics — mean +3.9% (worse).

Priority: priority-weighted -1.7% (better) — ⚠️ do-no-harm guardrail regressed: grid_p2p_w +5%.

Lower is better for every metric. See src/astrameter/simulator/evaluation.py for definitions.

Metrics are the per-scenario mean of 5 seeds.

Aggregate — mean across 31 scenarios

Metric Base Head Δ
settle_mean_s 30.6 36.3 +19%
settle_p95_s 49.6 63.5 +28%
unsettled_events 0.3 0.5 +67%
overshoot_mean_w 51.6 45.4 -12%
overshoot_max_w 105.0 97.7 -7%
band_crossings_per_h 306.4 306.8 +0%
grid_p2p_w 251.1 263.9 +5%
grid_rms_w 246.4 246.3 -0%
steady_rms_w 113.9 114.3 +0%
mean_abs_grid_w 96.8 97.8 +1%
share_imbalance_w 157.2 62.7 -60%
avoidable_import_wh 35.0 36.2 +3%
avoidable_export_wh 25.9 26.2 +1%
cost_regret_ct 0.81 0.85 +5%
battery_travel_w_per_h 21636.2 23435.9 +8%

📊 Interactive grid-power charts (zoom / hover / toggle series) are in the self-contained steering-eval-report.html report — see the link below (it opens directly in the browser).

What do these metrics mean?
Metric Meaning
settle_mean_s Mean seconds after a load/PV step for grid power to return inside the ±25 W settle band and hold for 10 s (reaction speed).
settle_p95_s 95th-percentile settle time — the slow tail of reactions.
unsettled_events Number of disturbance events that never settled within the 10-minute measurement window.
overshoot_mean_w Mean overshoot (W): how far grid power swings past zero to the opposite sign after an event.
overshoot_max_w Worst-case overshoot (W) across all events.
band_crossings_per_h Sign flips per hour across the ±20 W hysteresis band — oscillation / hunting frequency.
grid_p2p_w Sustained peak-to-peak grid swing (95th - 5th percentile) over the whole run — oscillation amplitude. Non-zero whenever the loop keeps hunting, including continuous oscillation the step-response metrics (settle/overshoot) miss.
grid_rms_w RMS grid power (W) over the whole run, transients included — the L2 tracking error: how cleanly the loop held zero, penalising big excursions (overshoot, swings) far harder than a small steady offset. Pairs with battery_travel_w_per_h as the control-effort term.
steady_rms_w RMS grid power (W) during steady state (excluding the 120 s after each event) — residual jitter when nothing is changing.
mean_abs_grid_w Mean absolute grid power (W) over the whole run — overall tracking accuracy.
share_imbalance_w Time-weighted watts misallocated between batteries sharing a phase (sum of each battery's deviation from the even fair share) — 0 when the pool splits load evenly, higher when one battery is left lopsided (issue #523). 0 for scenarios with at most one battery per phase.
avoidable_import_wh Energy imported from the grid (Wh) the battery could have supplied (it had charge and discharge headroom) — missed self-consumption.
avoidable_export_wh Energy exported to the grid (Wh) an AC-chargeable battery could have absorbed (it had room and charge headroom) — missed charging.
cost_regret_ct Money north-star: electricity bill (eurocents, import @ 30 ct/kWh, export @ 8 ct/kWh) over what a perfect-foresight optimal battery would have paid on the same load. Ungameable (both grid directions cost); 0 = matched the optimum. The single number that says how much the controller left on the table.
battery_travel_w_per_h Total absolute change in battery setpoints per hour (W/h) — control effort / actuator wear; lower is smoother.
Per-scenario tables (31 scenarios)
mixed_cadence/eff — settle 26.1→50.8s, overshoot 232.6→164.5W, RMS 25.6→21.9W
Metric Base Head Δ
settle_mean_s 26.1 50.8 +95%
settle_p95_s 31.6 72.4 +129%
unsettled_events 0.0 0.0 =
overshoot_mean_w 110.0 64.0 -42%
overshoot_max_w 232.6 164.5 -29%
band_crossings_per_h 33.0 24.4 -26%
grid_p2p_w 78.8 160.6 +104%
grid_rms_w 253.5 255.3 +1%
steady_rms_w 25.6 21.9 -14%
mean_abs_grid_w 58.2 64.1 +10%
share_imbalance_w 581.1 223.0 -62%
avoidable_import_wh 34.2 38.5 +13%
avoidable_export_wh 23.9 25.6 +7%
cost_regret_ct 0.83 0.95 +14%
battery_travel_w_per_h 25396.8 31306.0 +23%
mixed_cadence/fair — settle 24.8→47.7s, overshoot 176.3→70.4W, RMS 13.1→12.9W
Metric Base Head Δ
settle_mean_s 24.8 47.7 +92%
settle_p95_s 29.4 67.4 +129%
unsettled_events 0.0 0.0 =
overshoot_mean_w 111.3 30.7 -72%
overshoot_max_w 176.3 70.4 -60%
band_crossings_per_h 27.2 21.6 -21%
grid_p2p_w 27.6 131.9 +378%
grid_rms_w 246.0 246.0 =
steady_rms_w 13.1 12.9 -2%
mean_abs_grid_w 53.5 58.6 +10%
share_imbalance_w 528.9 101.3 -81%
avoidable_import_wh 30.4 33.7 +11%
avoidable_export_wh 23.1 24.9 +8%
cost_regret_ct 0.73 0.81 +11%
battery_travel_w_per_h 23198.8 28457.4 +23%
mixed_cadence_solar/eff — settle 34.6→52.5s, overshoot 351.2→384.7W, RMS 30.3→28.6W
Metric Base Head Δ
settle_mean_s 34.6 52.5 +52%
settle_p95_s 68.8 95.6 +39%
unsettled_events 1.8 2.0 +11%
overshoot_mean_w 104.6 62.4 -40%
overshoot_max_w 351.2 384.7 +10%
band_crossings_per_h 40.0 37.3 -7%
grid_p2p_w 109.1 192.3 +76%
grid_rms_w 267.6 268.4 +0%
steady_rms_w 30.3 28.6 -6%
mean_abs_grid_w 71.0 76.4 +8%
share_imbalance_w 430.0 148.1 -66%
avoidable_import_wh 55.4 60.5 +9%
avoidable_export_wh 51.2 54.1 +6%
cost_regret_ct 1.25 1.38 +10%
battery_travel_w_per_h 30571.0 37320.0 +22%
mixed_cadence_solar/fair — settle 30.8→56.0s, overshoot 169.0→75.4W, RMS 20.3→23.7W
Metric Base Head Δ
settle_mean_s 30.8 56.0 +82%
settle_p95_s 59.0 119.5 +103%
unsettled_events 1.4 1.8 +29%
overshoot_mean_w 86.2 28.7 -67%
overshoot_max_w 169.0 75.4 -55%
band_crossings_per_h 27.2 28.3 +4%
grid_p2p_w 74.5 147.8 +98%
grid_rms_w 257.9 261.6 +1%
steady_rms_w 20.3 23.7 +17%
mean_abs_grid_w 64.9 72.2 +11%
share_imbalance_w 557.9 119.0 -79%
avoidable_import_wh 49.4 56.0 +13%
avoidable_export_wh 47.9 52.4 +9%
cost_regret_ct 1.1 1.26 +15%
battery_travel_w_per_h 24392.2 32435.8 +33%
mixed_venus_b2500/eff — settle 56.2→81.2s, overshoot 220.6→221.9W, RMS 14.9→18.6W
Metric Base Head Δ
settle_mean_s 56.2 81.2 +44%
settle_p95_s 123.7 217.1 +76%
unsettled_events 0.2 2.4 +1100%
overshoot_mean_w 106.1 100.2 -6%
overshoot_max_w 220.6 221.9 +1%
band_crossings_per_h 55.7 49.2 -12%
grid_p2p_w 56.7 67.3 +19%
grid_rms_w 200.6 197.5 -2%
steady_rms_w 14.9 18.6 +25%
mean_abs_grid_w 42.4 43.2 +2%
share_imbalance_w 486.4 263.0 -46%
avoidable_import_wh 41.3 44.2 +7%
avoidable_export_wh 22.3 20.5 -8%
cost_regret_ct 1.06 1.16 +9%
battery_travel_w_per_h 29523.6 39704.6 +34%
mixed_venus_b2500/fair — settle 30.1→75.4s, overshoot 106.9→231.1W, RMS 13.2→22.3W
Metric Base Head Δ
settle_mean_s 30.1 75.4 +150%
settle_p95_s 62.9 191.0 +204%
unsettled_events 0.0 1.4 +1.4
overshoot_mean_w 70.6 110.5 +57%
overshoot_max_w 106.9 231.1 +116%
band_crossings_per_h 29.3 53.3 +82%
grid_p2p_w 42.7 67.1 +57%
grid_rms_w 194.1 189.9 -2%
steady_rms_w 13.2 22.3 +69%
mean_abs_grid_w 37.7 44.5 +18%
share_imbalance_w 621.9 100.7 -84%
avoidable_import_wh 34.4 47.4 +38%
avoidable_export_wh 22.1 19.4 -12%
cost_regret_ct 0.85 1.27 +49%
battery_travel_w_per_h 21395.6 43475.4 +103%
phase_imbalance — settle 44.9→53.4s, overshoot 166.7→145.2W, RMS 30.2→30.3W
Metric Base Head Δ
settle_mean_s 44.9 53.4 +19%
settle_p95_s 106.4 123.6 +16%
unsettled_events 0.6 0.0 -100%
overshoot_mean_w 74.8 64.9 -13%
overshoot_max_w 166.7 145.2 -13%
band_crossings_per_h 79.2 87.6 +11%
grid_p2p_w 42.5 42.4 -0%
grid_rms_w 199.9 199.6 -0%
steady_rms_w 30.2 30.3 +0%
mean_abs_grid_w 40.5 40.4 -0%
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 25.7 25.6 -0%
avoidable_export_wh 14.8 14.8 =
cost_regret_ct 0.65 0.65 =
battery_travel_w_per_h 18583.6 18274.2 -2%
single_venus_d_solar — settle 24.2→24.2s, overshoot 94.4→94.4W, RMS 15.9→15.9W
Metric Base Head Δ
settle_mean_s 24.2 24.2 =
settle_p95_s 27.1 27.1 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 76.8 76.8 =
overshoot_max_w 94.4 94.4 =
band_crossings_per_h 10.7 10.7 =
grid_p2p_w 29.9 29.9 =
grid_rms_w 104.3 104.3 =
steady_rms_w 15.9 15.9 =
mean_abs_grid_w 18.5 18.5 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 17.9 17.9 =
avoidable_export_wh 9.9 9.9 =
cost_regret_ct 0.46 0.46 =
battery_travel_w_per_h 9425.4 9425.4 =
single_venus_d_steps — settle 26.3→26.3s, overshoot 90.3→90.3W, RMS 15.5→15.5W
Metric Base Head Δ
settle_mean_s 26.3 26.3 =
settle_p95_s 33.8 33.8 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 73.3 73.3 =
overshoot_max_w 90.3 90.3 =
band_crossings_per_h 25.8 25.8 =
grid_p2p_w 30.9 30.9 =
grid_rms_w 256.4 256.4 =
steady_rms_w 15.5 15.5 =
mean_abs_grid_w 58.6 58.6 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 32.3 32.3 =
avoidable_export_wh 26.2 26.2 =
cost_regret_ct 0.76 0.76 =
battery_travel_w_per_h 21425.2 21425.2 =
single_venus_d_washer — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 61.0→61.0W
Metric Base Head Δ
settle_mean_s 0.0 0.0 =
settle_p95_s 0.0 0.0 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 0.0 0.0 =
overshoot_max_w 0.0 0.0 =
band_crossings_per_h 322.4 322.4 =
grid_p2p_w 192.7 192.7 =
grid_rms_w 61.0 61.0 =
steady_rms_w 61.0 61.0 =
mean_abs_grid_w 42.8 42.8 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 13.3 13.3 =
avoidable_export_wh 8.1 8.1 =
cost_regret_ct 0.33 0.33 =
battery_travel_w_per_h 24204.4 24204.4 =
single_venus_drain — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 907.3→907.3W
Metric Base Head Δ
settle_mean_s 0.0 0.0 =
settle_p95_s 0.0 0.0 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 0.0 0.0 =
overshoot_max_w 0.0 0.0 =
band_crossings_per_h 73.2 73.2 =
grid_p2p_w 1598.9 1598.9 =
grid_rms_w 907.3 907.3 =
steady_rms_w 907.3 907.3 =
mean_abs_grid_w 645.3 645.3 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 14.4 14.4 =
avoidable_export_wh 10.2 10.2 =
cost_regret_ct 0.21 0.21 =
battery_travel_w_per_h 4062.0 4062.0 =
single_venus_fill — settle 360.0→360.0s, overshoot 0.0→0.0W, RMS 953.6→953.6W
Metric Base Head Δ
settle_mean_s 360.0 360.0 =
settle_p95_s 600.0 600.0 =
unsettled_events 4.0 4.0 =
overshoot_mean_w 0.0 0.0 =
overshoot_max_w 0.0 0.0 =
band_crossings_per_h 10.1 10.1 =
grid_p2p_w 1713.1 1713.1 =
grid_rms_w 978.6 978.6 =
steady_rms_w 953.6 953.6 =
mean_abs_grid_w 662.8 662.8 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 4.8 4.8 =
avoidable_export_wh 6.7 6.7 =
cost_regret_ct 0.14 0.14 =
battery_travel_w_per_h 3173.0 3173.0 =
single_venus_noisy — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 94.3→94.3W
Metric Base Head Δ
settle_mean_s 0.0 0.0 =
settle_p95_s 0.0 0.0 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 0.0 0.0 =
overshoot_max_w 0.0 0.0 =
band_crossings_per_h 1452.6 1452.6 =
grid_p2p_w 296.7 296.7 =
grid_rms_w 94.3 94.3 =
steady_rms_w 94.3 94.3 =
mean_abs_grid_w 79.1 79.1 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 42.0 42.0 =
avoidable_export_wh 37.0 37.0 =
cost_regret_ct 0.96 0.96 =
battery_travel_w_per_h 22758.8 22758.8 =
single_venus_pv — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 60.8→60.8W
Metric Base Head Δ
settle_mean_s 0.0 0.0 =
settle_p95_s 0.0 0.0 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 0.0 0.0 =
overshoot_max_w 0.0 0.0 =
band_crossings_per_h 38.5 38.5 =
grid_p2p_w 42.9 42.9 =
grid_rms_w 60.8 60.8 =
steady_rms_w 60.8 60.8 =
mean_abs_grid_w 17.3 17.3 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 9.9 9.9 =
avoidable_export_wh 16.1 16.1 =
cost_regret_ct 0.17 0.17 =
battery_travel_w_per_h 6782.0 6782.0 =
single_venus_solar — settle 26.8→26.8s, overshoot 80.3→80.3W, RMS 17.8→17.8W
Metric Base Head Δ
settle_mean_s 26.8 26.8 =
settle_p95_s 32.6 32.6 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 67.1 67.1 =
overshoot_max_w 80.3 80.3 =
band_crossings_per_h 26.3 26.3 =
grid_p2p_w 41.9 41.9 =
grid_rms_w 108.7 108.7 =
steady_rms_w 17.8 17.8 =
mean_abs_grid_w 21.2 21.2 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 15.5 15.5 =
avoidable_export_wh 16.3 16.3 =
cost_regret_ct 0.34 0.34 =
battery_travel_w_per_h 7331.6 7331.6 =
single_venus_solar_slow — settle 33.9→33.9s, overshoot 68.3→68.3W, RMS 22.8→22.8W
Metric Base Head Δ
settle_mean_s 33.9 33.9 =
settle_p95_s 39.9 39.9 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 32.1 32.1 =
overshoot_max_w 68.3 68.3 =
band_crossings_per_h 5.6 5.6 =
grid_p2p_w 60.6 60.6 =
grid_rms_w 131.7 131.7 =
steady_rms_w 22.8 22.8 =
mean_abs_grid_w 31.6 31.6 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 21.3 21.3 =
avoidable_export_wh 26.1 26.1 =
cost_regret_ct 0.43 0.43 =
battery_travel_w_per_h 6486.0 6486.0 =
single_venus_steps — settle 26.0→26.0s, overshoot 88.0→88.0W, RMS 14.7→14.7W
Metric Base Head Δ
settle_mean_s 26.0 26.0 =
settle_p95_s 32.7 32.7 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 69.7 69.7 =
overshoot_max_w 88.0 88.0 =
band_crossings_per_h 23.8 23.8 =
grid_p2p_w 27.1 27.1 =
grid_rms_w 266.9 266.9 =
steady_rms_w 14.7 14.7 =
mean_abs_grid_w 61.1 61.1 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 34.1 34.1 =
avoidable_export_wh 27.0 27.0 =
cost_regret_ct 0.8 0.8 =
battery_travel_w_per_h 19543.6 19543.6 =
single_venus_steps_slow — settle 40.5→40.5s, overshoot 98.5→98.5W, RMS 14.8→14.8W
Metric Base Head Δ
settle_mean_s 40.5 40.5 =
settle_p95_s 56.2 56.2 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 37.1 37.1 =
overshoot_max_w 98.5 98.5 =
band_crossings_per_h 9.4 9.4 =
grid_p2p_w 78.2 78.2 =
grid_rms_w 331.7 331.7 =
steady_rms_w 14.8 14.8 =
mean_abs_grid_w 88.2 88.2 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 47.5 47.5 =
avoidable_export_wh 40.7 40.7 =
cost_regret_ct 1.1 1.1 =
battery_travel_w_per_h 17699.8 17699.8 =
single_venus_trace — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 278.9→278.9W
Metric Base Head Δ
settle_mean_s 0.0 0.0 =
settle_p95_s 0.0 0.0 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 0.0 0.0 =
overshoot_max_w 0.0 0.0 =
band_crossings_per_h 284.8 284.8 =
grid_p2p_w 689.3 689.3 =
grid_rms_w 278.5 278.5 =
steady_rms_w 278.9 278.9 =
mean_abs_grid_w 117.0 117.0 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 81.4 81.4 =
avoidable_export_wh 35.5 35.5 =
cost_regret_ct 1.36 1.36 =
battery_travel_w_per_h 33179.4 33179.4 =
single_venus_washer — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 61.0→61.0W
Metric Base Head Δ
settle_mean_s 0.0 0.0 =
settle_p95_s 0.0 0.0 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 0.0 0.0 =
overshoot_max_w 0.0 0.0 =
band_crossings_per_h 320.4 320.4 =
grid_p2p_w 196.1 196.1 =
grid_rms_w 61.1 61.1 =
steady_rms_w 61.0 61.0 =
mean_abs_grid_w 40.6 40.6 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 11.3 11.3 =
avoidable_export_wh 9.0 9.0 =
cost_regret_ct 0.27 0.27 =
battery_travel_w_per_h 24290.4 24290.4 =
two_venus/eff — settle 18.9→18.1s, overshoot 150.3→126.1W, RMS 13.9→14.0W
Metric Base Head Δ
settle_mean_s 18.9 18.1 -4%
settle_p95_s 27.1 23.0 -15%
unsettled_events 0.0 0.0 =
overshoot_mean_w 70.3 70.7 +1%
overshoot_max_w 150.3 126.1 -16%
band_crossings_per_h 40.0 33.6 -16%
grid_p2p_w 27.8 26.2 -6%
grid_rms_w 221.6 221.6 =
steady_rms_w 13.9 14.0 +1%
mean_abs_grid_w 43.2 43.1 -0%
share_imbalance_w 272.5 166.4 -39%
avoidable_import_wh 26.0 25.7 -1%
avoidable_export_wh 17.3 17.3 =
cost_regret_ct 0.64 0.64 =
battery_travel_w_per_h 21923.0 22029.2 +0%
two_venus/fair — settle 18.2→18.4s, overshoot 128.7→116.7W, RMS 13.8→13.8W
Metric Base Head Δ
settle_mean_s 18.2 18.4 +1%
settle_p95_s 22.2 24.4 +10%
unsettled_events 0.0 0.0 =
overshoot_mean_w 93.4 94.4 +1%
overshoot_max_w 128.7 116.7 -9%
band_crossings_per_h 20.0 21.6 +8%
grid_p2p_w 23.4 23.1 -1%
grid_rms_w 217.8 217.6 -0%
steady_rms_w 13.8 13.8 =
mean_abs_grid_w 41.5 41.4 -0%
share_imbalance_w 33.7 18.8 -44%
avoidable_import_wh 24.3 24.2 -0%
avoidable_export_wh 17.2 17.1 -1%
cost_regret_ct 0.59 0.59 =
battery_travel_w_per_h 19252.6 19385.4 +1%
two_venus_noisy/eff — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 94.5→94.3W
Metric Base Head Δ
settle_mean_s 0.0 0.0 =
settle_p95_s 0.0 0.0 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 0.0 0.0 =
overshoot_max_w 0.0 0.0 =
band_crossings_per_h 2901.2 2898.0 -0%
grid_p2p_w 295.9 294.5 -0%
grid_rms_w 94.5 94.3 -0%
steady_rms_w 94.5 94.3 -0%
mean_abs_grid_w 79.6 79.4 -0%
share_imbalance_w 34.8 26.9 -23%
avoidable_import_wh 43.0 43.6 +1%
avoidable_export_wh 36.6 35.8 -2%
cost_regret_ct 1.0 1.02 +2%
battery_travel_w_per_h 24526.2 23402.6 -5%
two_venus_noisy/fair — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 94.5→94.2W
Metric Base Head Δ
settle_mean_s 0.0 0.0 =
settle_p95_s 0.0 0.0 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 0.0 0.0 =
overshoot_max_w 0.0 0.0 =
band_crossings_per_h 2890.4 2893.6 +0%
grid_p2p_w 296.3 294.1 -1%
grid_rms_w 94.5 94.2 -0%
steady_rms_w 94.5 94.2 -0%
mean_abs_grid_w 79.6 79.4 -0%
share_imbalance_w 33.3 26.4 -21%
avoidable_import_wh 43.1 43.6 +1%
avoidable_export_wh 36.5 35.8 -2%
cost_regret_ct 1.0 1.02 +2%
battery_travel_w_per_h 24134.8 22778.8 -6%
two_venus_slow/fair — settle 41.3→41.8s, overshoot 181.1→174.5W, RMS 14.0→14.0W
Metric Base Head Δ
settle_mean_s 41.3 41.8 +1%
settle_p95_s 52.0 52.8 +2%
unsettled_events 0.0 0.0 =
overshoot_mean_w 57.7 56.6 -2%
overshoot_max_w 181.1 174.5 -4%
band_crossings_per_h 11.0 10.4 -5%
grid_p2p_w 74.1 81.8 +10%
grid_rms_w 303.8 303.7 -0%
steady_rms_w 14.0 14.0 =
mean_abs_grid_w 76.8 76.9 +0%
share_imbalance_w 69.9 24.4 -65%
avoidable_import_wh 41.3 41.5 +0%
avoidable_export_wh 35.5 35.3 -1%
cost_regret_ct 0.95 0.96 +1%
battery_travel_w_per_h 18570.4 18563.6 -0%
two_venus_solar/eff — settle 25.5→26.0s, overshoot 417.5→396.6W, RMS 19.6→20.4W
Metric Base Head Δ
settle_mean_s 25.5 26.0 +2%
settle_p95_s 50.2 44.3 -12%
unsettled_events 1.6 1.6 =
overshoot_mean_w 103.6 100.5 -3%
overshoot_max_w 417.5 396.6 -5%
band_crossings_per_h 43.3 40.4 -7%
grid_p2p_w 55.8 60.2 +8%
grid_rms_w 228.7 228.7 =
steady_rms_w 19.6 20.4 +4%
mean_abs_grid_w 51.3 51.9 +1%
share_imbalance_w 160.1 69.3 -57%
avoidable_import_wh 41.9 42.4 +1%
avoidable_export_wh 35.1 35.4 +1%
cost_regret_ct 0.98 0.99 +1%
battery_travel_w_per_h 26691.8 26336.0 -1%
two_venus_solar/fair — settle 22.7→25.9s, overshoot 139.8→151.4W, RMS 19.4→20.4W
Metric Base Head Δ
settle_mean_s 22.7 25.9 +14%
settle_p95_s 37.1 52.1 +40%
unsettled_events 1.2 1.4 +17%
overshoot_mean_w 88.5 98.1 +11%
overshoot_max_w 139.8 151.4 +8%
band_crossings_per_h 30.4 30.7 +1%
grid_p2p_w 54.7 59.8 +9%
grid_rms_w 226.9 225.6 -1%
steady_rms_w 19.4 20.4 +5%
mean_abs_grid_w 50.3 51.1 +2%
share_imbalance_w 219.8 31.3 -86%
avoidable_import_wh 40.4 40.1 -1%
avoidable_export_wh 35.1 36.5 +4%
cost_regret_ct 0.93 0.91 -2%
battery_travel_w_per_h 23851.6 24206.8 +1%
two_venus_trace/eff — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 283.2→283.1W
Metric Base Head Δ
settle_mean_s 0.0 0.0 =
settle_p95_s 0.0 0.0 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 0.0 0.0 =
overshoot_max_w 0.0 0.0 =
band_crossings_per_h 306.0 307.6 +1%
grid_p2p_w 745.3 744.9 -0%
grid_rms_w 282.3 282.1 -0%
steady_rms_w 283.2 283.1 -0%
mean_abs_grid_w 122.0 122.0 =
share_imbalance_w 416.1 398.3 -4%
avoidable_import_wh 80.2 80.0 -0%
avoidable_export_wh 41.9 42.0 +0%
cost_regret_ct 2.07 2.06 -0%
battery_travel_w_per_h 48431.2 47999.0 -1%
two_venus_trace/fair — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 282.6→282.1W
Metric Base Head Δ
settle_mean_s 0.0 0.0 =
settle_p95_s 0.0 0.0 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 0.0 0.0 =
overshoot_max_w 0.0 0.0 =
band_crossings_per_h 300.4 310.0 +3%
grid_p2p_w 725.4 732.2 +1%
grid_rms_w 281.6 281.1 -0%
steady_rms_w 282.6 282.1 -0%
mean_abs_grid_w 121.2 121.1 -0%
share_imbalance_w 63.3 30.3 -52%
avoidable_import_wh 79.7 79.0 -1%
avoidable_export_wh 41.6 42.0 +1%
cost_regret_ct 2.06 2.04 -1%
battery_travel_w_per_h 45944.0 46054.4 +0%
venus_d_plus_c/eff — settle 17.4→20.1s, overshoot 175.4→128.9W, RMS 14.7→14.7W
Metric Base Head Δ
settle_mean_s 17.4 20.1 +16%
settle_p95_s 21.3 31.9 +50%
unsettled_events 0.0 0.0 =
overshoot_mean_w 81.7 78.9 -3%
overshoot_max_w 175.4 128.9 -27%
band_crossings_per_h 33.4 35.2 +5%
grid_p2p_w 28.1 28.0 -0%
grid_rms_w 215.2 214.7 -0%
steady_rms_w 14.7 14.7 =
mean_abs_grid_w 41.9 41.6 -1%
share_imbalance_w 234.0 167.3 -29%
avoidable_import_wh 25.1 24.9 -1%
avoidable_export_wh 16.8 16.7 -1%
cost_regret_ct 0.62 0.61 -2%
battery_travel_w_per_h 22973.2 23208.0 +1%
venus_d_plus_c/fair — settle 18.6→21.6s, overshoot 118.4→121.0W, RMS 14.6→14.6W
Metric Base Head Δ
settle_mean_s 18.6 21.6 +16%
settle_p95_s 24.7 32.5 +32%
unsettled_events 0.0 0.0 =
overshoot_mean_w 84.3 90.6 +7%
overshoot_max_w 118.4 121.0 +2%
band_crossings_per_h 27.0 24.8 -8%
grid_p2p_w 27.0 27.4 +1%
grid_rms_w 211.9 211.5 -0%
steady_rms_w 14.6 14.6 =
mean_abs_grid_w 40.7 40.8 +0%
share_imbalance_w 130.8 30.2 -77%
avoidable_import_wh 24.0 24.1 +0%
avoidable_export_wh 16.7 16.7 =
cost_regret_ct 0.59 0.59 =
battery_travel_w_per_h 20999.4 21212.8 +1%

📊 Open the interactive reportsteering-eval-report.html, a single self-contained file (opens in-browser; download it if your browser blocks inline scripts).

The #523 balance gate re-enabled balance correction for an out-of-balance
pool, but the blanket grid-direction sign clamp zeroed any residual whose sign
opposed the grid. fair_share always carries the grid's sign, so that clamp only
ever fired on the balance-correction term — and near steady state that term is
exactly the over-served battery's "back off" half of the equalizing swap. The
clamp therefore left equalization one-sided (only the under-served battery
moved), pushing the pool's net output around and disturbing the grid: the
overshoot / slow-settle cost the balance fix carried.

Clamp only the grid-tracking half (fair_share) against the grid direction and
let the balance-correction redistribution through. That term is ~zero-sum
across the same-phase pool, so applying it is grid-neutral — the over-served
battery backs off by exactly what the under-served one takes on. The change is
inert during load steps (fair_share dominates, so the residual never flips
sign) and only engages near steady state, where #523 lives.

Steering-eval (vs develop): share_imbalance_w -60%, overshoot_max_w -5%
(was +24% with the gate alone), overshoot_mean_w -13%, priority-weighted
verdict now net better. Mirrored on the C++/ESPHome side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AiZvgThQbNq5hUfGHARuWB
@tomquist tomquist merged commit ba42b9c into develop Jun 24, 2026
59 checks passed
@tomquist tomquist deleted the claude/stoic-planck-2r133k branch June 24, 2026 20:53
tomquist added a commit that referenced this pull request Jun 27, 2026
Reproduces the reported failure where a full battery's saturation stays at
0% with the default-style pace settings, so excess solar is never transferred
to the healthy battery.

Root cause: saturation is scored from the post-pacing reading actually sent
(last_target). A battery that cannot follow its command never grows its pace
cap, so its reading stays pinned at PACE_BASE_STEP. When PACE_BASE_STEP is
below MIN_TARGET_FOR_SATURATION the tracker treats every poll as "idle" and
decays the score (and the stall-timeout rescue can't fire either, since it
also requires |target| >= MIN_TARGET) — the full battery is never recognised
as saturated. The reporter ran PACE_BASE_STEP=15 against
MIN_TARGET_FOR_SATURATION=20.

- PART A replays the reporter's own debug log (issue522_logs.txt): Venus E3
  reports 0 W while the balancer sends it a -15 reading every poll; the real
  SaturationTracker holds the score at 0.000.
- PART B drives the LoadBalancer end-to-end and shows the sharp break exactly
  at PACE_BASE_STEP == MIN_TARGET_FOR_SATURATION.

Verified on develop @ ba42b9c (after #526). Not collected by pytest
(repro_ prefix); kept to confirm the eventual fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvyVYm5tJWG6P2qP3DxJyB
tomquist added a commit that referenced this pull request Jun 27, 2026
Reproduces the reported failure where a full battery's saturation stays at
0% with the default-style pace settings, so excess solar is never transferred
to the healthy battery.

Root cause: saturation is scored from the post-pacing reading actually sent
(last_target). A battery that cannot follow its command never grows its pace
cap, so its reading stays pinned at PACE_BASE_STEP. When PACE_BASE_STEP is
below MIN_TARGET_FOR_SATURATION the tracker treats every poll as "idle" and
decays the score (and the stall-timeout rescue can't fire either, since it
also requires |target| >= MIN_TARGET) — the full battery is never recognised
as saturated. The reporter ran PACE_BASE_STEP=15 against
MIN_TARGET_FOR_SATURATION=20.

- PART A replays the reporter's own debug log (issue522_logs.txt): Venus E3
  reports 0 W while the balancer sends it a -15 reading every poll; the real
  SaturationTracker holds the score at 0.000.
- PART B drives the LoadBalancer end-to-end and shows the sharp break exactly
  at PACE_BASE_STEP == MIN_TARGET_FOR_SATURATION.

Verified on develop @ ba42b9c (after #526). Not collected by pytest
(repro_ prefix); kept to confirm the eventual fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvyVYm5tJWG6P2qP3DxJyB
tomquist added a commit that referenced this pull request Jun 27, 2026
Reproduces the reported failure where a full battery's saturation stays at
0% with the default-style pace settings, so excess solar is never transferred
to the healthy battery.

Root cause: saturation is scored from the post-pacing reading actually sent
(last_target). A battery that cannot follow its command never grows its pace
cap, so its reading stays pinned at PACE_BASE_STEP. When PACE_BASE_STEP is
below MIN_TARGET_FOR_SATURATION the tracker treats every poll as "idle" and
decays the score (and the stall-timeout rescue can't fire either, since it
also requires |target| >= MIN_TARGET) — the full battery is never recognised
as saturated. The reporter ran PACE_BASE_STEP=15 against
MIN_TARGET_FOR_SATURATION=20.

- PART A replays the reporter's own debug log (issue522_logs.txt): Venus E3
  reports 0 W while the balancer sends it a -15 reading every poll; the real
  SaturationTracker holds the score at 0.000.
- PART B drives the LoadBalancer end-to-end and shows the sharp break exactly
  at PACE_BASE_STEP == MIN_TARGET_FOR_SATURATION.

Verified on develop @ ba42b9c (after #526). Not collected by pytest
(repro_ prefix); kept to confirm the eventual fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvyVYm5tJWG6P2qP3DxJyB
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