Add adaptive grid-state predictor to compensate for meter latency#481
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughIntroduces an adaptive grid-state predictor into the CT002 ChangesAdaptive grid-state predictor for CT002 active control
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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 Metrics are the per-scenario mean of 5 seeds. Aggregate — mean across 31 scenarios
📊 Interactive grid-power charts (zoom / hover / toggle series) are in the self-contained What do these metrics mean?
Per-scenario tables (31 scenarios)mixed_cadence/eff — settle 93.0→73.9s, overshoot 666.0→752.7W, RMS 43.1→24.4W
mixed_cadence/fair — settle 56.3→74.6s, overshoot 342.4→255.5W, RMS 15.6→13.2W
mixed_cadence_solar/eff — settle 79.3→69.9s, overshoot 1478.4→493.6W, RMS 150.0→49.8W
mixed_cadence_solar/fair — settle 63.0→109.8s, overshoot 381.3→249.2W, RMS 22.2→27.1W
mixed_venus_b2500/eff — settle 124.0→91.4s, overshoot 277.4→197.8W, RMS 20.3→16.8W
mixed_venus_b2500/fair — settle 103.4→104.1s, overshoot 449.7→336.4W, RMS 20.5→19.3W
phase_imbalance — settle 89.2→53.2s, overshoot 527.1→322.0W, RMS 33.3→31.2W
single_venus_d_solar — settle 23.8→19.4s, overshoot 196.6→197.2W, RMS 16.7→15.0W
single_venus_d_steps — settle 24.7→19.8s, overshoot 201.0→204.3W, RMS 17.4→15.3W
single_venus_d_washer — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 71.3→55.3W
single_venus_drain — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 906.4→907.4W
single_venus_fill — settle 360.0→360.0s, overshoot 0.0→0.0W, RMS 955.5→953.6W
single_venus_noisy — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 103.3→96.2W
single_venus_pv — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 57.7→57.2W
single_venus_solar — settle 26.5→23.7s, overshoot 188.5→196.1W, RMS 17.0→17.9W
single_venus_solar_slow — settle 360.0→68.8s, overshoot 1723.6→41.0W, RMS 811.2→24.1W
single_venus_steps — settle 26.8→23.0s, overshoot 202.2→203.3W, RMS 14.9→14.2W
single_venus_steps_slow — settle 282.3→71.1s, overshoot 1984.1→17.1W, RMS 792.3→14.0W
single_venus_trace — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 304.0→284.6W
single_venus_washer — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 66.1→56.0W
two_venus/eff — settle 26.4→18.9s, overshoot 466.1→465.8W, RMS 15.2→14.2W
two_venus/fair — settle 26.1→17.2s, overshoot 376.6→269.3W, RMS 15.6→14.6W
two_venus_noisy/eff — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 117.7→116.4W
two_venus_noisy/fair — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 104.3→95.5W
two_venus_slow/fair — settle 282.3→76.8s, overshoot 4084.5→17.3W, RMS 1595.2→14.5W
two_venus_solar/eff — settle 40.9→42.5s, overshoot 868.8→582.9W, RMS 21.3→22.1W
two_venus_solar/fair — settle 69.6→45.2s, overshoot 412.1→337.0W, RMS 23.1→23.9W
two_venus_trace/eff — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 314.8→306.3W
two_venus_trace/fair — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 313.8→295.1W
venus_d_plus_c/eff — settle 29.0→19.9s, overshoot 494.9→512.9W, RMS 16.5→15.2W
venus_d_plus_c/fair — settle 27.1→17.2s, overshoot 370.6→273.7W, RMS 16.2→15.9W
📊 Open the interactive report — |
ee6cd30 to
373de00
Compare
50ae9a8 to
a4a0ccf
Compare
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
a4a0ccf to
70c15e7
Compare
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
|
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).
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).
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).
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).
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).
…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>
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_):Configuration:
BalancerConfig.grid_predict_trustparameter (default0.5, range[0, 1])0disables the predictor (acts on raw meter); any positive value seeds the self-adapting trustPRED_TRUST_MIN/MAX,PRED_TRUST_RAISE_STEP,PRED_TRUST_SHRINK,PRED_INNOVATION_GATE_W)Control path integration:
_compute_auto_targetnow calls the predictor and usescontrol_gridinstead ofgrid_totalfor fair-share and residual calculationsState tracking:
_pred_grid,_pred_pool_output,_pred_sample_id,_pred_trust,_pred_innov_signsample_idparameter added tocompute_targetto detect genuinely fresh meter samplesParity across stacks:
src/astrameter/ct002/balancer.pyesphome/components/ct002/balancer.cppandbalancer.hsample_idthrough the control pathTest coverage:
TestGridPredictorclass validates output crediting, meter correction, and trust adaptationtest_stale_meter_during_probe_handoff_stays_balancednow passes (previously documented as a failure mode)GridPredictorCreditsDeliveredOutput)Configuration integration:
CT002Component.__init__andct002.pyexposegrid_predict_trustparametermain.pyreadsGRID_PREDICT_TRUSTfrom config file (fallback0.5){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 readsEach 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
Configuration
GRID_PREDICT_TRUST/grid_predict_trust, default 0.5, range 0.0–1.0). On by default; set to 0 to disable.Improvements
Tests