Skip to content

Add adaptive grid-state predictor to compensate for meter latency#481

Merged
tomquist merged 6 commits into
developfrom
claude/elegant-feynman-9r9dv1
Jun 15, 2026
Merged

Add adaptive grid-state predictor to compensate for meter latency#481
tomquist merged 6 commits into
developfrom
claude/elegant-feynman-9r9dv1

Conversation

@tomquist

@tomquist tomquist commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Summary

Introduces an adaptive grid-state predictor that compensates for powermeter latency without per-meter tuning. The controller now acts on a predicted grid estimate instead of the raw meter reading, which eliminates overshoot and limit-cycling caused by stale feedback while remaining rock-steady against hunting loads.

Key Changes

  • New adaptive predictor (_predict_control_grid / predict_control_grid_):

    • Credits each battery's reported output change between meter refreshes, so in-flight corrections are not re-issued
    • Pulls the estimate toward fresh meter samples using an online-learned adaptive trust
    • Trust rises additively under sustained same-sign innovations (genuine disturbances) and shrinks multiplicatively on sign flips (hunting signature)
    • Only innovations above a noise gate adapt the trust, so steady-state meter noise has no effect
  • Configuration:

    • New BalancerConfig.grid_predict_trust parameter (default 0.5, range [0, 1])
    • 0 disables the predictor (acts on raw meter); any positive value seeds the self-adapting trust
    • Five new constants define trust bounds and adaptation rates (PRED_TRUST_MIN/MAX, PRED_TRUST_RAISE_STEP, PRED_TRUST_SHRINK, PRED_INNOVATION_GATE_W)
  • Control path integration:

    • _compute_auto_target now calls the predictor and uses control_grid instead of grid_total for fair-share and residual calculations
    • Sign-disagreement clamp updated to use predicted grid
    • Manual/inactive paths continue using raw meter for categorical decisions (charge territory, demand sizing)
  • State tracking:

    • New instance variables track predictor state: _pred_grid, _pred_pool_output, _pred_sample_id, _pred_trust, _pred_innov_sign
    • sample_id parameter added to compute_target to detect genuinely fresh meter samples
  • Parity across stacks:

    • Python implementation in src/astrameter/ct002/balancer.py
    • C++ mirror in esphome/components/ct002/balancer.cpp and balancer.h
    • Both stacks updated to thread sample_id through the control path
  • Test coverage:

    • New TestGridPredictor class validates output crediting, meter correction, and trust adaptation
    • Existing pacing and oscillation tests disable the predictor to isolate their behavior
    • E2E test test_stale_meter_during_probe_handoff_stays_balanced now passes (previously documented as a failure mode)
    • C++ parity test added (GridPredictorCreditsDeliveredOutput)
    • Convergence test updated to drive a closed loop with realistic battery feedback
  • Configuration integration:

    • CT002Component.__init__ and ct002.py expose grid_predict_trust parameter
    • main.py reads GRID_PREDICT_TRUST from config file (fallback 0.5)
    • ESPHome component updated to skip active control on stale meter readings (sentinel {0,0,0})

Implementation Details

The predictor maintains two estimates:

  • _pred_grid: the instantaneous grid the control path acts on
  • _pred_meter (implicit): what the latent meter currently reads

Each poll, the estimate is advanced by the pool's reported output change (grid moves opposite to net output). On a fresh meter sample, the estimate is pulled toward the reading by the adaptive trust. The trust is learned online from the innovation's sign pattern: sustained same-sign runs raise it additively (tracking real steps fast), while sign flips shrink it multiplicatively (starving hunting of gain). This asymmetry lets one law both track real steps fast and stay rock-steady against a hunting load, with no per-meter tuning.

The predictor is disabled by default in tests that exercise other control paths (pacing, oscillation damping) to isolate their behavior. Production defaults to `0.5

https://claude.ai/code/session_01NMP7xYPqugfbRDVmv83WAc

Summary by CodeRabbit

Release Notes

  • New Features

    • Added latency-compensated grid prediction for CT active control with online “trust” learning.
  • Configuration

    • Introduced Grid Prediction (GRID_PREDICT_TRUST / grid_predict_trust, default 0.5, range 0.0–1.0). On by default; set to 0 to disable.
  • Improvements

    • Active control now ignores failed/unavailable meter readings instead of using zero placeholders, improving stability and balance after probe handoffs.
  • Tests

    • Expanded and updated CT002 and parity tests to cover grid prediction behavior and edge cases.

@coderabbitai

coderabbitai Bot commented Jun 14, 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: 16c07cb7-91c0-4b7b-8862-01dc21f7aca7

📥 Commits

Reviewing files that changed from the base of the PR and between 39bba24 and a8a9376.

📒 Files selected for processing (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • CHANGELOG.md

Walkthrough

Introduces an adaptive grid-state predictor into the CT002 LoadBalancer that replaces raw meter readings with a trust-weighted predicted control_grid in the residual/fair-share/sign-clamp path. Adds online per-battery trust learning bounded by new constants, a meter-unavailable guard preventing sentinel values from entering predictor state, numeric precision upgrades (floatdouble) for saturation and fade parameters, a cache-key correctness fix in efficiency deprioritization, and comprehensive test coverage across Python and C++.

Changes

Adaptive grid-state predictor for CT002 active control

Layer / File(s) Summary
Predictor constants, config fields, and state members
esphome/components/ct002/balancer.h, src/astrameter/ct002/balancer.py
Defines five inline predictor trust constants (PRED_TRUST_MIN/MAX/RAISE_STEP/SHRINK/INNOVATION_GATE_W), adds grid_predict_trust to BalancerConfig, updates efficiency_saturation_threshold and fade_weight to double, declares predict_control_grid_ method, and adds five pred_* internal state fields to LoadBalancer.
Predictor algorithm implementation
esphome/components/ct002/balancer.cpp, src/astrameter/ct002/balancer.py
Implements the online observer predict_control_grid_ / _predict_control_grid: advances predicted grid by credited pool-output delta, gates innovation by PRED_INNOVATION_GATE_W, raises or shrinks trust on same-sign or sign-flip innovations, blends predicted grid toward meter reading via trust, and returns raw grid_total when disabled.
Control loop integration: residual, fair-share, sign-clamp
esphome/components/ct002/balancer.cpp, src/astrameter/ct002/balancer.py
Calls the predictor at the start of compute_auto_target_, substitutes control_grid for grid_total in fair-share scaling and the residual sign-disagreement clamp, leaving categorical decision logic on the raw meter value.
float→double precision upgrades and cache key fix
esphome/components/ct002/balancer.cpp, esphome/components/ct002/balancer.h, esphome/components/ct002/ct002.h
Changes saturation alpha, decay factor, and related intermediates from float to double across BalancerConfig::clamp(), SaturationTracker, LoadBalancer, and CT002Component; makes the efficiency-deprioritization cache key mutable and recomputes it after priority swaps.
Meter-unavailable guard and config wiring
esphome/components/ct002/ct002.cpp, src/astrameter/ct002/ct002.py, src/astrameter/main.py, CHANGELOG.md
Adds meter_ok / meter_failed guards in C++ and Python request handlers to skip active control when the pipeline returns no values; wires grid_predict_trust from GRID_PREDICT_TRUST config key through main.pyCT002 constructor → BalancerConfig; documents the new feature in the changelog.
Configuration schema and deployment integration
esphome/components/ct002/__init__.py, ha_addon/config.yaml, ha_addon/run.sh, ha_addon/translations/en.yaml, web/ts/schema.ts
Adds CONF_GRID_PREDICT_TRUST constant and extends BALANCER_SCHEMA in ESPHome codegen with grid_predict_trust field; updates Home Assistant add-on configuration, schema reading in run.sh, UI translations, and web UI schema to expose the predictor across all deployment layers.
Tests: unit, parity, e2e, and regression
tests/test_balancer.py, tests/components/ct002/host_balancer_test.cpp, tests/components/ct002/fixtures/balancer_parity_harness.cpp, tests/components/ct002/test_balancer_parity.py, tests/components/ct002/test_shared_e2e.py, tests/test_e2e_probe_lockup.py, tests/test_steering_eval.py
Disables predictor in existing pacing/overshoot tests; adds TestGridPredictor and GridPredictorCreditsDeliveredOutput covering disabled passthrough, first-sample seeding, within-sample crediting, and trust growth/collapse; rewrites test_convergence as a closed-loop iterative check; inverts the stale-meter probe regression assertion from "expects drift" to "stays balanced".

Sequence Diagram(s)

sequenceDiagram
  participant CT002Handler as CT002 Request Handler
  participant predict_control_grid as predict_control_grid_
  participant TrustState as Predictor Trust State
  participant compute_auto_target as compute_auto_target_

  CT002Handler->>CT002Handler: run filter pipeline → meter_ok?
  alt meter_ok = true
    CT002Handler->>compute_auto_target: grid_total, sample_id, reports
    compute_auto_target->>predict_control_grid: grid_total, sample_id, reports
    predict_control_grid->>predict_control_grid: advance pred_grid_ by pool-output delta
    alt fresh sample_id
      predict_control_grid->>TrustState: compute innovation = grid_total - pred_grid_
      TrustState-->>predict_control_grid: raise or shrink pred_trust_
      predict_control_grid->>predict_control_grid: blend pred_grid_ by pred_trust_
    end
    predict_control_grid-->>compute_auto_target: control_grid
    compute_auto_target->>compute_auto_target: fair-share and sign-clamp using control_grid
    compute_auto_target-->>CT002Handler: per-consumer targets
  else meter_ok = false (sentinel {0,0,0})
    CT002Handler->>CT002Handler: skip compute_auto_target_ (guard)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • tomquist/AstraMeter#385: Introduced the CT002/CT003 C++/ESPHome ct002 component and LoadBalancer/BalancerConfig, which this PR extends with latency-compensated grid prediction.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.78% 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 accurately describes the main change: introducing an adaptive grid-state predictor to compensate for meter latency, which is the central feature of this pull request across all modified files.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/elegant-feynman-9r9dv1

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.

@github-actions

github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Steering evaluation (base vs head)

Overall: 12 improved, 0 regressed, 0 unchanged across 12 metrics — mean -50.7% (better).

Priority: priority-weighted -53.8% (better) — ✅ no do-no-harm guardrail regressions.

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 71.4 45.2 -37%
settle_p95_s 139.8 88.3 -37%
unsettled_events 1.6 0.7 -56%
overshoot_mean_w 326.6 107.6 -67%
overshoot_max_w 506.2 191.1 -62%
band_crossings_per_h 373.5 303.8 -19%
grid_p2p_w 624.1 302.0 -52%
steady_rms_w 225.6 117.0 -48%
mean_abs_grid_w 176.3 98.3 -44%
avoidable_import_wh 93.0 37.1 -60%
avoidable_export_wh 57.7 25.2 -56%
battery_travel_w_per_h 106675.3 31794.3 -70%

📊 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.
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.
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.
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 93.0→73.9s, overshoot 666.0→752.7W, RMS 43.1→24.4W
Metric Base Head Δ
settle_mean_s 93.0 73.9 -21%
settle_p95_s 158.3 119.3 -25%
unsettled_events 2.4 0.4 -83%
overshoot_mean_w 225.7 189.9 -16%
overshoot_max_w 666.0 752.7 +13%
band_crossings_per_h 274.0 127.2 -54%
grid_p2p_w 227.9 131.8 -42%
steady_rms_w 43.1 24.4 -43%
mean_abs_grid_w 71.7 59.5 -17%
avoidable_import_wh 46.2 36.6 -21%
avoidable_export_wh 25.6 22.9 -11%
battery_travel_w_per_h 83767.8 47004.8 -44%
mixed_cadence/fair — settle 56.3→74.6s, overshoot 342.4→255.5W, RMS 15.6→13.2W
Metric Base Head Δ
settle_mean_s 56.3 74.6 +33%
settle_p95_s 72.5 103.2 +42%
unsettled_events 0.0 0.0 =
overshoot_mean_w 142.1 126.2 -11%
overshoot_max_w 342.4 255.5 -25%
band_crossings_per_h 122.4 108.6 -11%
grid_p2p_w 92.6 70.9 -23%
steady_rms_w 15.6 13.2 -15%
mean_abs_grid_w 52.0 46.5 -11%
avoidable_import_wh 33.2 28.1 -15%
avoidable_export_wh 18.8 18.4 -2%
battery_travel_w_per_h 38621.8 29424.4 -24%
mixed_cadence_solar/eff — settle 79.3→69.9s, overshoot 1478.4→493.6W, RMS 150.0→49.8W
Metric Base Head Δ
settle_mean_s 79.3 69.9 -12%
settle_p95_s 183.6 133.3 -27%
unsettled_events 3.4 2.4 -29%
overshoot_mean_w 297.2 144.8 -51%
overshoot_max_w 1478.4 493.6 -67%
band_crossings_per_h 160.0 114.0 -29%
grid_p2p_w 345.0 109.9 -68%
steady_rms_w 150.0 49.8 -67%
mean_abs_grid_w 89.2 66.0 -26%
avoidable_import_wh 82.9 55.3 -33%
avoidable_export_wh 50.6 43.4 -14%
battery_travel_w_per_h 74543.2 38936.4 -48%
mixed_cadence_solar/fair — settle 63.0→109.8s, overshoot 381.3→249.2W, RMS 22.2→27.1W
Metric Base Head Δ
settle_mean_s 63.0 109.8 +74%
settle_p95_s 137.9 310.8 +125%
unsettled_events 3.4 3.2 -6%
overshoot_mean_w 138.0 105.7 -23%
overshoot_max_w 381.3 249.2 -35%
band_crossings_per_h 120.4 124.3 +3%
grid_p2p_w 121.5 96.5 -21%
steady_rms_w 22.2 27.1 +22%
mean_abs_grid_w 60.6 61.3 +1%
avoidable_import_wh 51.0 50.9 -0%
avoidable_export_wh 39.7 40.9 +3%
battery_travel_w_per_h 42795.4 34741.2 -19%
mixed_venus_b2500/eff — settle 124.0→91.4s, overshoot 277.4→197.8W, RMS 20.3→16.8W
Metric Base Head Δ
settle_mean_s 124.0 91.4 -26%
settle_p95_s 343.1 247.6 -28%
unsettled_events 3.2 2.8 -13%
overshoot_mean_w 137.7 122.5 -11%
overshoot_max_w 277.4 197.8 -29%
band_crossings_per_h 263.2 84.1 -68%
grid_p2p_w 81.6 53.6 -34%
steady_rms_w 20.3 16.8 -17%
mean_abs_grid_w 43.3 37.8 -13%
avoidable_import_wh 43.2 38.8 -10%
avoidable_export_wh 21.3 17.5 -18%
battery_travel_w_per_h 51130.6 35910.6 -30%
mixed_venus_b2500/fair — settle 103.4→104.1s, overshoot 449.7→336.4W, RMS 20.5→19.3W
Metric Base Head Δ
settle_mean_s 103.4 104.1 +1%
settle_p95_s 226.0 275.9 +22%
unsettled_events 1.4 2.4 +71%
overshoot_mean_w 286.3 212.9 -26%
overshoot_max_w 449.7 336.4 -25%
band_crossings_per_h 141.6 88.0 -38%
grid_p2p_w 78.8 46.1 -41%
steady_rms_w 20.5 19.3 -6%
mean_abs_grid_w 44.9 40.2 -10%
avoidable_import_wh 48.0 44.3 -8%
avoidable_export_wh 19.1 15.6 -18%
battery_travel_w_per_h 44859.0 35586.8 -21%
phase_imbalance — settle 89.2→53.2s, overshoot 527.1→322.0W, RMS 33.3→31.2W
Metric Base Head Δ
settle_mean_s 89.2 53.2 -40%
settle_p95_s 175.7 158.3 -10%
unsettled_events 1.2 1.2 =
overshoot_mean_w 452.8 260.2 -43%
overshoot_max_w 527.1 322.0 -39%
band_crossings_per_h 174.4 45.6 -74%
grid_p2p_w 55.2 40.3 -27%
steady_rms_w 33.3 31.2 -6%
mean_abs_grid_w 47.7 42.0 -12%
avoidable_import_wh 32.5 28.1 -14%
avoidable_export_wh 15.1 13.4 -11%
battery_travel_w_per_h 38498.0 22792.4 -41%
single_venus_d_solar — settle 23.8→19.4s, overshoot 196.6→197.2W, RMS 16.7→15.0W
Metric Base Head Δ
settle_mean_s 23.8 19.4 -18%
settle_p95_s 27.3 21.0 -23%
unsettled_events 0.0 0.0 =
overshoot_mean_w 185.2 186.9 +1%
overshoot_max_w 196.6 197.2 +0%
band_crossings_per_h 18.7 9.3 -50%
grid_p2p_w 29.2 25.3 -13%
steady_rms_w 16.7 15.0 -10%
mean_abs_grid_w 16.6 15.1 -9%
avoidable_import_wh 18.1 15.5 -14%
avoidable_export_wh 6.8 7.2 +6%
battery_travel_w_per_h 15932.4 8726.2 -45%
single_venus_d_steps — settle 24.7→19.8s, overshoot 201.0→204.3W, RMS 17.4→15.3W
Metric Base Head Δ
settle_mean_s 24.7 19.8 -20%
settle_p95_s 30.4 22.8 -25%
unsettled_events 0.0 0.0 =
overshoot_mean_w 179.9 189.6 +5%
overshoot_max_w 201.0 204.3 +2%
band_crossings_per_h 53.6 30.0 -44%
grid_p2p_w 33.9 24.8 -27%
steady_rms_w 17.4 15.3 -12%
mean_abs_grid_w 42.7 41.0 -4%
avoidable_import_wh 25.2 23.7 -6%
avoidable_export_wh 17.5 17.2 -2%
battery_travel_w_per_h 33438.0 23251.2 -30%
single_venus_d_washer — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 71.3→55.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 608.0 319.2 -48%
grid_p2p_w 266.8 185.8 -30%
steady_rms_w 71.3 55.3 -22%
mean_abs_grid_w 47.3 35.2 -26%
avoidable_import_wh 13.9 9.8 -29%
avoidable_export_wh 9.7 7.8 -20%
battery_travel_w_per_h 84774.0 24577.6 -71%
single_venus_drain — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 906.4→907.4W
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 119.1 88.1 -26%
grid_p2p_w 1597.0 1598.2 +0%
steady_rms_w 906.4 907.4 +0%
mean_abs_grid_w 644.0 645.1 +0%
avoidable_import_wh 17.0 14.7 -14%
avoidable_export_wh 8.1 9.8 +21%
battery_travel_w_per_h 9058.4 5605.2 -38%
single_venus_fill — settle 360.0→360.0s, overshoot 0.0→0.0W, RMS 955.5→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 42.9 2.7 -94%
grid_p2p_w 1718.6 1710.9 -0%
steady_rms_w 955.5 953.6 -0%
mean_abs_grid_w 662.9 661.5 -0%
avoidable_import_wh 4.3 3.4 -21%
avoidable_export_wh 4.4 6.3 +43%
battery_travel_w_per_h 4019.2 2963.2 -26%
single_venus_noisy — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 103.3→96.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 1371.4 1441.4 +5%
grid_p2p_w 327.4 304.0 -7%
steady_rms_w 103.3 96.2 -7%
mean_abs_grid_w 85.2 80.5 -6%
avoidable_import_wh 50.6 42.3 -16%
avoidable_export_wh 34.6 38.2 +10%
battery_travel_w_per_h 78715.4 36349.0 -54%
single_venus_pv — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 57.7→57.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 80.1 35.9 -55%
grid_p2p_w 40.7 41.6 +2%
steady_rms_w 57.7 57.2 -1%
mean_abs_grid_w 16.3 16.3 =
avoidable_import_wh 13.4 13.2 -1%
avoidable_export_wh 10.9 11.3 +4%
battery_travel_w_per_h 10732.6 7213.0 -33%
single_venus_solar — settle 26.5→23.7s, overshoot 188.5→196.1W, RMS 17.0→17.9W
Metric Base Head Δ
settle_mean_s 26.5 23.7 -11%
settle_p95_s 31.4 27.7 -12%
unsettled_events 0.0 0.0 =
overshoot_mean_w 178.1 180.3 +1%
overshoot_max_w 188.5 196.1 +4%
band_crossings_per_h 64.5 10.9 -83%
grid_p2p_w 40.0 44.2 +11%
steady_rms_w 17.0 17.9 +5%
mean_abs_grid_w 18.7 20.2 +8%
avoidable_import_wh 16.2 17.1 +6%
avoidable_export_wh 11.8 13.2 +12%
battery_travel_w_per_h 9969.4 7720.2 -23%
single_venus_solar_slow — settle 360.0→68.8s, overshoot 1723.6→41.0W, RMS 811.2→24.1W
Metric Base Head Δ
settle_mean_s 360.0 68.8 -81%
settle_p95_s 600.0 86.7 -86%
unsettled_events 4.0 0.0 -100%
overshoot_mean_w 1404.2 17.9 -99%
overshoot_max_w 1723.6 41.0 -98%
band_crossings_per_h 235.9 4.3 -98%
grid_p2p_w 2695.5 69.0 -97%
steady_rms_w 811.2 24.1 -97%
mean_abs_grid_w 665.1 41.5 -94%
avoidable_import_wh 558.7 29.2 -95%
avoidable_export_wh 438.9 33.0 -92%
battery_travel_w_per_h 476953.8 6289.6 -99%
single_venus_steps — settle 26.8→23.0s, overshoot 202.2→203.3W, RMS 14.9→14.2W
Metric Base Head Δ
settle_mean_s 26.8 23.0 -14%
settle_p95_s 31.4 26.9 -14%
unsettled_events 0.0 0.0 =
overshoot_mean_w 164.3 177.9 +8%
overshoot_max_w 202.2 203.3 +1%
band_crossings_per_h 58.4 31.2 -47%
grid_p2p_w 30.8 23.7 -23%
steady_rms_w 14.9 14.2 -5%
mean_abs_grid_w 48.8 48.1 -1%
avoidable_import_wh 29.6 28.5 -4%
avoidable_export_wh 19.1 19.6 +3%
battery_travel_w_per_h 27290.6 21980.8 -19%
single_venus_steps_slow — settle 282.3→71.1s, overshoot 1984.1→17.1W, RMS 792.3→14.0W
Metric Base Head Δ
settle_mean_s 282.3 71.1 -75%
settle_p95_s 596.9 79.4 -87%
unsettled_events 10.0 0.0 -100%
overshoot_mean_w 1179.2 12.6 -99%
overshoot_max_w 1984.1 17.1 -99%
band_crossings_per_h 252.0 9.0 -96%
grid_p2p_w 2595.1 702.8 -73%
steady_rms_w 792.3 14.0 -98%
mean_abs_grid_w 661.9 122.3 -82%
avoidable_import_wh 411.6 60.9 -85%
avoidable_export_wh 250.2 61.4 -75%
battery_travel_w_per_h 477046.4 16748.4 -96%
single_venus_trace — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 304.0→284.6W
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 404.0 332.4 -18%
grid_p2p_w 670.1 709.8 +6%
steady_rms_w 304.0 284.6 -6%
mean_abs_grid_w 119.9 122.2 +2%
avoidable_import_wh 98.1 82.4 -16%
avoidable_export_wh 21.5 39.6 +84%
battery_travel_w_per_h 59613.2 52114.6 -13%
single_venus_washer — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 66.1→56.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 472.0 318.4 -33%
grid_p2p_w 242.6 187.3 -23%
steady_rms_w 66.1 56.0 -15%
mean_abs_grid_w 42.6 34.8 -18%
avoidable_import_wh 11.7 9.2 -21%
avoidable_export_wh 9.6 8.3 -14%
battery_travel_w_per_h 60464.4 24873.6 -59%
two_venus/eff — settle 26.4→18.9s, overshoot 466.1→465.8W, RMS 15.2→14.2W
Metric Base Head Δ
settle_mean_s 26.4 18.9 -28%
settle_p95_s 38.0 27.0 -29%
unsettled_events 0.0 0.0 =
overshoot_mean_w 261.9 265.8 +1%
overshoot_max_w 466.1 465.8 -0%
band_crossings_per_h 84.0 33.2 -60%
grid_p2p_w 30.9 22.8 -26%
steady_rms_w 15.2 14.2 -7%
mean_abs_grid_w 42.9 41.2 -4%
avoidable_import_wh 27.5 25.6 -7%
avoidable_export_wh 14.9 14.9 =
battery_travel_w_per_h 31303.8 25744.8 -18%
two_venus/fair — settle 26.1→17.2s, overshoot 376.6→269.3W, RMS 15.6→14.6W
Metric Base Head Δ
settle_mean_s 26.1 17.2 -34%
settle_p95_s 32.8 23.2 -29%
unsettled_events 0.0 0.0 =
overshoot_mean_w 329.0 229.9 -30%
overshoot_max_w 376.6 269.3 -28%
band_crossings_per_h 61.6 29.4 -52%
grid_p2p_w 22.4 21.6 -4%
steady_rms_w 15.6 14.6 -6%
mean_abs_grid_w 43.0 39.8 -7%
avoidable_import_wh 28.0 24.8 -11%
avoidable_export_wh 14.5 14.3 -1%
battery_travel_w_per_h 29362.8 21744.6 -26%
two_venus_noisy/eff — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 117.7→116.4W
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 2297.0 2336.0 +2%
grid_p2p_w 379.0 374.5 -1%
steady_rms_w 117.7 116.4 -1%
mean_abs_grid_w 95.5 94.7 -1%
avoidable_import_wh 58.5 56.9 -3%
avoidable_export_wh 37.9 38.7 +2%
battery_travel_w_per_h 165965.8 154714.0 -7%
two_venus_noisy/fair — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 104.3→95.5W
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 2586.4 2866.8 +11%
grid_p2p_w 330.1 300.5 -9%
steady_rms_w 104.3 95.5 -8%
mean_abs_grid_w 86.0 80.2 -7%
avoidable_import_wh 50.5 43.6 -14%
avoidable_export_wh 36.0 36.6 +2%
battery_travel_w_per_h 92273.2 33466.2 -64%
two_venus_slow/fair — settle 282.3→76.8s, overshoot 4084.5→17.3W, RMS 1595.2→14.5W
Metric Base Head Δ
settle_mean_s 282.3 76.8 -73%
settle_p95_s 596.9 93.1 -84%
unsettled_events 10.0 0.0 -100%
overshoot_mean_w 3422.9 11.7 -100%
overshoot_max_w 4084.5 17.3 -100%
band_crossings_per_h 257.6 9.4 -96%
grid_p2p_w 5622.1 722.0 -87%
steady_rms_w 1595.2 14.5 -99%
mean_abs_grid_w 1282.2 123.2 -90%
avoidable_import_wh 774.9 62.1 -92%
avoidable_export_wh 509.2 60.5 -88%
battery_travel_w_per_h 953825.8 17710.4 -98%
two_venus_solar/eff — settle 40.9→42.5s, overshoot 868.8→582.9W, RMS 21.3→22.1W
Metric Base Head Δ
settle_mean_s 40.9 42.5 +4%
settle_p95_s 133.3 146.1 +10%
unsettled_events 2.0 2.2 +10%
overshoot_mean_w 285.6 235.0 -18%
overshoot_max_w 868.8 582.9 -33%
band_crossings_per_h 102.5 39.7 -61%
grid_p2p_w 66.6 66.2 -1%
steady_rms_w 21.3 22.1 +4%
mean_abs_grid_w 49.8 50.0 +0%
avoidable_import_wh 44.0 41.2 -6%
avoidable_export_wh 29.8 32.8 +10%
battery_travel_w_per_h 35441.0 29134.4 -18%
two_venus_solar/fair — settle 69.6→45.2s, overshoot 412.1→337.0W, RMS 23.1→23.9W
Metric Base Head Δ
settle_mean_s 69.6 45.2 -35%
settle_p95_s 228.6 166.4 -27%
unsettled_events 4.6 2.6 -43%
overshoot_mean_w 305.3 215.1 -30%
overshoot_max_w 412.1 337.0 -18%
band_crossings_per_h 87.5 25.3 -71%
grid_p2p_w 73.9 75.5 +2%
steady_rms_w 23.1 23.9 +3%
mean_abs_grid_w 52.5 51.1 -3%
avoidable_import_wh 46.9 43.1 -8%
avoidable_export_wh 30.9 32.4 +5%
battery_travel_w_per_h 34863.8 26689.0 -23%
two_venus_trace/eff — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 314.8→306.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 472.0 349.8 -26%
grid_p2p_w 749.9 788.3 +5%
steady_rms_w 314.8 306.3 -3%
mean_abs_grid_w 128.0 127.8 -0%
avoidable_import_wh 98.7 90.4 -8%
avoidable_export_wh 28.4 36.7 +29%
battery_travel_w_per_h 90367.4 72933.2 -19%
two_venus_trace/fair — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 313.8→295.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 442.2 313.8 -29%
grid_p2p_w 722.1 767.8 +6%
steady_rms_w 313.8 295.1 -6%
mean_abs_grid_w 126.8 126.8 =
avoidable_import_wh 99.1 82.2 -17%
avoidable_export_wh 27.0 44.1 +63%
battery_travel_w_per_h 84285.4 68361.2 -19%
venus_d_plus_c/eff — settle 29.0→19.9s, overshoot 494.9→512.9W, RMS 16.5→15.2W
Metric Base Head Δ
settle_mean_s 29.0 19.9 -31%
settle_p95_s 49.7 40.2 -19%
unsettled_events 0.0 0.0 =
overshoot_mean_w 228.9 217.3 -5%
overshoot_max_w 494.9 512.9 +4%
band_crossings_per_h 96.2 60.0 -38%
grid_p2p_w 36.2 24.3 -33%
steady_rms_w 16.5 15.2 -8%
mean_abs_grid_w 39.5 37.5 -5%
avoidable_import_wh 25.5 23.9 -6%
avoidable_export_wh 13.6 13.2 -3%
battery_travel_w_per_h 36950.2 29795.2 -19%
venus_d_plus_c/fair — settle 27.1→17.2s, overshoot 370.6→273.7W, RMS 16.2→15.9W
Metric Base Head Δ
settle_mean_s 27.1 17.2 -37%
settle_p95_s 39.4 26.9 -32%
unsettled_events 0.0 0.0 =
overshoot_mean_w 321.1 232.3 -28%
overshoot_max_w 370.6 273.7 -26%
band_crossings_per_h 54.0 28.4 -47%
grid_p2p_w 24.0 22.5 -6%
steady_rms_w 16.2 15.9 -2%
mean_abs_grid_w 38.4 36.6 -5%
avoidable_import_wh 24.7 23.2 -6%
avoidable_export_wh 13.5 13.2 -2%
battery_travel_w_per_h 30070.6 22521.2 -25%

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

@tomquist tomquist force-pushed the claude/elegant-feynman-9r9dv1 branch 2 times, most recently from ee6cd30 to 373de00 Compare June 14, 2026 13:47
@tomquist tomquist changed the base branch from develop to claude/eval-economic-aggregate June 14, 2026 13:47
Base automatically changed from claude/eval-economic-aggregate to develop June 14, 2026 14:13
@tomquist tomquist force-pushed the claude/elegant-feynman-9r9dv1 branch 2 times, most recently from 50ae9a8 to a4a0ccf Compare June 14, 2026 16:41
claude added 3 commits June 14, 2026 21:25
Active control acted directly on the raw grid-meter reading, which lags
reality by the meter's refresh interval plus transport/measurement delay.
Acting on stale feedback makes the loop re-issue a correction that is
already in flight — the dominant source of overshoot and latency-driven
hunting — and forced users to hand-tune throttle/pacing per powermeter.

Introduce a self-learning grid-state observer in the balancer. The
batteries report their own output faster than most grid meters refresh,
so every poll the estimate is advanced by the pool's actual reported
output change (crediting an in-flight correction before the meter shows
it), and on each fresh meter sample it is pulled toward the reading by an
online-adapted trust: a sustained same-sign innovation run (a genuine
load/solar step) raises it additively so steps are tracked fast, while a
sign flip (latency-driven hunting) shrinks it multiplicatively so the
fast prediction dominates and the hunt is starved. The same setting works
across meters of different latency, so no per-meter tuning is needed.

On by default (grid_predict_trust, seeds the self-adapting trust; 0
disables). Across the steering-evaluation suite this cuts avoidable grid
import+export ~5%, overshoot ~40%, band-crossings ~43%, and battery
travel ~28%, with 18/19 scenarios improving on the primary metric.

Mirrored in the ESPHome C++ port (balancer.{h,cpp}) per the parity rule;
the differential parity suite now threads a grid-derived sample_id so the
meter-correction/adaptation branch is checked on both stacks. On a meter
failure the [0,0,0] sentinel now bypasses the balancer (Python + C++) so
the stateful predictor never treats a fabricated zero as a fresh reading.

https://claude.ai/code/session_01NMP7xYPqugfbRDVmv83WAc
The C++ port computed the efficiency cache key once (const) before the
saturation swap and stored that pre-swap key, while the canonical Python
recomputes it from the post-swap priority order inside the swap branch.
After a swap the next same-sample tick would therefore miss the cache in
C++ (key reflects the old order) but hit it in Python, so C++ re-ran the
deprioritize/swap/probe machinery the Python side skipped, diverging.

Recompute the key from the post-swap priority_, mirroring balancer.py.

Pre-existing port bug, independent of the grid-state predictor. It is
latent in the current suite (a swap always begins a probe, which gates
the next-tick recompute and masks the divergence), but the discrepancy
is real; fixed defensively to keep the two stacks bit-aligned.

https://claude.ai/code/session_01NMP7xYPqugfbRDVmv83WAc
Port the precision hardening from PR #483: the C++ saturation EMA
(alpha/decay/score), the efficiency saturation swap threshold, and the
per-consumer fade weight ran in float while the canonical Python uses
double. A float alpha/decay/threshold sits ~1e-8 off the double value, so
the saturation score can drift across the swap threshold on a knife-edge
and the fade EMA can snap-to-goal on a different poll, flipping a discrete
deprioritize/swap decision and diverging the two stacks.

Promote to double on the C++ side (saturation_alpha/decay_factor and their
SaturationTracker/LoadBalancer/CT002 carriers, efficiency_saturation_threshold,
fade_weight) and the float locals that captured them. Python is unchanged
— this only brings the port up to its precision.

https://claude.ai/code/session_01NMP7xYPqugfbRDVmv83WAc
@tomquist tomquist force-pushed the claude/elegant-feynman-9r9dv1 branch from a4a0ccf to 70c15e7 Compare June 14, 2026 21:27
@tomquist tomquist marked this pull request as ready for review June 14, 2026 21:46
Make the adaptive grid predictor (grid_predict_trust) configurable from
the Home Assistant add-on Configuration tab (config.yaml option + schema,
run.sh emits GRID_PREDICT_TRUST into the generated [CT002]/[CT003]
section, translations entry), and as a first-class ESPHome balancer key
(CONF_GRID_PREDICT_TRUST), matching how osc_damp_*/min_dc_output are
exposed. The web config generator's balancer fields gain the same knob.

Rewrite the CHANGELOG entry to be user-focused (outcomes, not mechanism)
and refresh the improvement figures to the current seed-averaged
steering-eval aggregate over the realistic-latency scenario set: avoidable
grid import/export ~-55-60%, worst-case overshoot ~-65%, grid swing ~-50%,
battery wear ~-70%, settling ~-35%.

https://claude.ai/code/session_01NMP7xYPqugfbRDVmv83WAc
@github-actions

github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-06-15 05:15 UTC

@tomquist tomquist merged commit f3812c1 into develop Jun 15, 2026
60 checks passed
@tomquist tomquist deleted the claude/elegant-feynman-9r9dv1 branch June 15, 2026 05:14
tomquist pushed a commit that referenced this pull request Jun 15, 2026
Near steady state a small grid error split N ways can leave each
battery's share below the firmware's ~20 W input deadband, so none of
them correct and the pool tolerates ~N times the residual offset a single
battery would — a few watts of avoidable grid import/export per battery.

When the (predicted) grid error is below the configured threshold and
more than one battery is active, hand the whole correction to the single
most-active battery so it clears its deadband, bypassing balance
correction for that tick. The designated battery is chosen
deterministically (largest |output|, id tiebreak) so the Python and C++
ports agree. Composes with the #481 grid predictor, which already damps
the latency-driven hunting that concentrating small errors would
otherwise cause: across the steering-evaluation suite this cuts avoidable
import/export ~8% in the multi-battery scenarios for only ~2-4% more
setpoint churn.

Off by default (CONCENTRATE_DEADBAND / concentrate_deadband, 0 disables);
~60 W is a reasonable opt-in value. Wired through the config loader, web
config generator, and ESPHome, mirrored in the C++ balancer, and covered
by unit tests plus the differential parity fuzz (extended to exercise the
new knob).
tomquist pushed a commit that referenced this pull request Jun 15, 2026
Near steady state a small grid error split N ways can leave each
battery's share below the firmware's ~20 W input deadband, so none of
them correct and the pool tolerates ~N times the residual offset a single
battery would — a few watts of avoidable grid import/export per battery.

When the predicted grid error is below the threshold, the whole
correction is handed to the single most-active *participating* battery
(charge-blind / faded-out units excluded) so it clears its deadband,
bypassing balance correction for that tick. Only fires when those
batteries are on the same phase (control_grid sums phases) and when fair
distribution is enabled; the designated battery is chosen
deterministically (largest |output|, id tiebreak) so the Python and C++
ports agree.

Composes with the #481 grid predictor, which already damps the
latency-driven hunting that concentrating small errors would otherwise
cause: across the steering-evaluation suite this cuts avoidable
import/export ~4% overall (up to ~18% in mixed-cadence / solar / B2500
scenarios) while also lowering oscillation and battery travel in
aggregate.

On by default (CONCENTRATE_DEADBAND=60; 0 disables). Wired through the
config loader, web config generator, and ESPHome, mirrored in the C++
balancer, and covered by unit tests plus the differential parity fuzz
(extended to exercise the new knob).
tomquist pushed a commit that referenced this pull request Jun 15, 2026
Near steady state a small grid error split N ways can leave each
battery's share below the firmware's ~20 W input deadband, so none of
them correct and the pool tolerates ~N times the residual offset a single
battery would — a few watts of avoidable grid import/export per battery.

When the predicted grid error is below the threshold, the whole
correction is handed to the single most-active *participating* battery
(charge-blind / faded-out units excluded) so it clears its deadband,
bypassing balance correction for that tick. Only fires when those
batteries are on the same phase (control_grid sums phases) and when fair
distribution is enabled; the designated battery is chosen
deterministically (largest |output|, id tiebreak) so the Python and C++
ports agree.

Composes with the #481 grid predictor, which already damps the
latency-driven hunting that concentrating small errors would otherwise
cause: across the steering-evaluation suite this cuts avoidable
import/export ~4% overall (up to ~18% in mixed-cadence / solar / B2500
scenarios) while also lowering oscillation and battery travel in
aggregate.

On by default (CONCENTRATE_DEADBAND=60; 0 disables). Wired through the
config loader, web config generator, and ESPHome, mirrored in the C++
balancer, and covered by unit tests plus the differential parity fuzz
(extended to exercise the new knob).
tomquist pushed a commit that referenced this pull request Jun 15, 2026
Near steady state a small grid error split N ways can leave each
battery's share below the firmware's ~20 W input deadband, so none of
them correct and the pool tolerates ~N times the residual offset a single
battery would — a few watts of avoidable grid import/export per battery.

When the predicted grid error is below the threshold, the whole
correction is handed to the single most-active *participating* battery
(charge-blind / faded-out units excluded) so it clears its deadband,
bypassing balance correction for that tick. Only fires when those
batteries are on the same phase (control_grid sums phases) and when fair
distribution is enabled; the designated battery is chosen
deterministically (largest |output|, id tiebreak) so the Python and C++
ports agree.

Composes with the #481 grid predictor, which already damps the
latency-driven hunting that concentrating small errors would otherwise
cause: across the steering-evaluation suite this cuts avoidable
import/export ~4% overall (up to ~18% in mixed-cadence / solar / B2500
scenarios) while also lowering oscillation and battery travel in
aggregate.

On by default (CONCENTRATE_DEADBAND=60; 0 disables). Wired through the
config loader, web config generator, and ESPHome, mirrored in the C++
balancer, and covered by unit tests plus the differential parity fuzz
(extended to exercise the new knob).
tomquist pushed a commit that referenced this pull request Jun 15, 2026
Near steady state a small grid error split N ways can leave each
battery's share below the firmware's ~20 W input deadband, so none of
them correct and the pool tolerates ~N times the residual offset a single
battery would — a few watts of avoidable grid import/export per battery.

When the predicted grid error is below the threshold, the whole
correction is handed to the single most-active *participating* battery
(charge-blind / faded-out units excluded) so it clears its deadband,
bypassing balance correction for that tick. Only fires when those
batteries are on the same phase (control_grid sums phases) and when fair
distribution is enabled; the designated battery is chosen
deterministically (largest |output|, id tiebreak) so the Python and C++
ports agree.

Composes with the #481 grid predictor, which already damps the
latency-driven hunting that concentrating small errors would otherwise
cause: across the steering-evaluation suite this cuts avoidable
import/export ~4% overall (up to ~18% in mixed-cadence / solar / B2500
scenarios) while also lowering oscillation and battery travel in
aggregate.

On by default (CONCENTRATE_DEADBAND=60; 0 disables). Wired through the
config loader, web config generator, and ESPHome, mirrored in the C++
balancer, and covered by unit tests plus the differential parity fuzz
(extended to exercise the new knob).
tomquist added a commit that referenced this pull request Jun 16, 2026
…480)

* Add deadband concentration for multi-battery self-consumption

Near steady state a small grid error split N ways can leave each
battery's share below the firmware's ~20 W input deadband, so none of
them correct and the pool tolerates ~N times the residual offset a single
battery would — a few watts of avoidable grid import/export per battery.

When the predicted grid error is below the threshold, the whole
correction is handed to the single most-active *participating* battery
(charge-blind / faded-out units excluded) so it clears its deadband,
bypassing balance correction for that tick. Only fires when those
batteries are on the same phase (control_grid sums phases) and when fair
distribution is enabled; the designated battery is chosen
deterministically (largest |output|, id tiebreak) so the Python and C++
ports agree.

Composes with the #481 grid predictor, which already damps the
latency-driven hunting that concentrating small errors would otherwise
cause: across the steering-evaluation suite this cuts avoidable
import/export ~4% overall (up to ~18% in mixed-cadence / solar / B2500
scenarios) while also lowering oscillation and battery travel in
aggregate.

On by default (CONCENTRATE_DEADBAND=60; 0 disables). Wired through the
config loader, web config generator, and ESPHome, mirrored in the C++
balancer, and covered by unit tests plus the differential parity fuzz
(extended to exercise the new knob).

* Address review: exclude zero-weight batteries from concentration; fix stale defaults docs

- Concentration candidate set now also excludes batteries with an explicit
  distribution weight of 0 (operator asked them to take no share): without this
  a zero-weight but most-active battery could be designated and swallow the whole
  near-zero correction. Mirrored in the C++ port; covered by a new 3-battery unit
  test (two batteries collapse the candidate set to one and never concentrate, so
  they can't exercise the path).
- Differential parity fuzz now emits an explicit `0` for the disabled path
  (distinct from an omitted token, which exercises the on-by-default) so the
  plain-split behavior is actually covered.
- Refresh stale "0 = off (default)" copy in the web schema and the parity-harness
  comment to match the on-by-default contract (default 60).

---------

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