Skip to content

Coalesce concurrent polls to prevent burst responses#542

Merged
tomquist merged 4 commits into
developfrom
claude/wait-for-next-message-deltas-4valul
Jul 2, 2026
Merged

Coalesce concurrent polls to prevent burst responses#542
tomquist merged 4 commits into
developfrom
claude/wait-for-next-message-deltas-4valul

Conversation

@tomquist

@tomquist tomquist commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Summary

Fix a critical issue where batteries and the CT002 meter emulator would receive multiple response deltas in quick succession when a meter read was delayed, causing the battery's control loop to overshoot its target. The fix coalesces concurrent polls from the same battery/consumer into a single response per meter reading.

Problem

When a meter read is delayed — either via WAIT_FOR_NEXT_MESSAGE, a throttled meter, or a slow HTTP source — the battery keeps polling (~1/s) and each datagram spawns its own handler task. Without coalescing, all parked handlers wake on the same fresh reading and each sends a response, creating a burst of deltas that the battery's firmware adds to its output. For Shelly batteries with a zero-export controller, this feeds the same stale error signal multiple times before the plant can respond, causing overshoot. For CT002, it steps the stateful balancer multiple times per real sample, winding up the battery.

Solution

Track in-flight handlers per battery/consumer using a set (_inflight_consumers for CT002, _inflight_batteries for Shelly). When a poll arrives:

  • If a handler is already parked for that consumer/battery, drop the duplicate poll (the state was already refreshed by the report update)
  • Otherwise, add it to the in-flight set, process the request, and remove it from the set in a finally block

This ensures exactly one response per meter reading, regardless of how many polls arrive while the read is in flight.

Changes

CT002 (src/astrameter/ct002/ct002.py)

  • Added _inflight_consumers: set[str] to track consumers with parked handlers
  • Wrapped the meter-read and response logic in a try/finally block
  • Early-return duplicate polls with a debug log
  • Clear the in-flight flag in the finally block to unblock the next reading

Shelly (src/astrameter/shelly/shelly.py)

  • Added _inflight_batteries: set[str] to track batteries with parked handlers
  • Wrapped the meter-read and response logic in a try/finally block
  • Early-return duplicate polls with a debug log
  • Clear the in-flight flag in the finally block

Tests

  • Added GatedPowermeter test fixture that parks reads on an asyncio.Event
  • Added comprehensive test suite for both CT002 and Shelly covering:
    • Concurrent polls from one consumer/battery coalesce to a single response
    • Coalescing is per-consumer/battery (two batteries each get their own response)
    • Polls are answered normally after a burst is coalesced (no permanent lock-out)
    • In-flight flag is cleared even when meter reads fail

Notes

  • The fix is transparent to the battery/meter — it sees the same response it would have gotten without the burst, just once instead of N times
  • The state update (report parsing) happens before the coalesce check, so dropped polls still refresh per-consumer metadata
  • Both implementations use identical logic and naming for consistency

https://claude.ai/code/session_01RaCn4moMmua81wuKo8nKEK

Summary by CodeRabbit

  • Bug Fixes
    • Fixed duplicate or repeated responses when meter readings are delayed or slow.
    • Requests from the same battery/consumer are now combined so only one reply is sent per reading.
    • Improved behavior under bursts of concurrent polls, including after temporary errors, so service resumes normally afterward.

tomquist and others added 2 commits July 2, 2026 19:42
When a meter read is delayed — WAIT_FOR_NEXT_MESSAGE awaiting the next
push, THROTTLE_INTERVAL sleeping out its window, or any slow source — the
battery keeps polling (~1/s) and each UDP datagram spawns its own handler
task. Every handler parked in before_send woke on the single fresh reading
and sent its own response, so the battery received a burst of instructions
milliseconds apart. Because each CT002 instruction is a delta the firmware
adds to its current output, the burst wound the battery up by N times the
intended correction and stepped the stateful balancer N times per real
sample.

Track an in-flight consumer set: the first handler owns the read-and-
respond section for its consumer; concurrent polls from the same battery
are dropped after refreshing per-consumer state, so exactly one response
goes out per reading. Keyed per consumer, so distinct batteries stay
independent. This sits at the request-handler level, so it fixes both the
WAIT_FOR_NEXT_MESSAGE and THROTTLE_INTERVAL causes at once.

Python-only: the ESPHome mirror drains UDP synchronously (pump_udp_ ->
handle_request_) with no async before_send, so it has no pile-up to
coalesce and needs no counterpart change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaCn4moMmua81wuKo8nKEK
The Shelly Pro 3EM emulator has the same concurrent-handler pile-up as
CT002: each UDP datagram spawns its own task, and when the meter read is
delayed (WAIT_FOR_NEXT_MESSAGE awaiting a push, THROTTLE_INTERVAL sleeping,
or a slow source) the battery keeps polling so handlers pile up parked in
the read; one fresh reading wakes them all and each answers.

Although a Shelly response carries an absolute grid reading rather than a
CT002-style delta, the harm is the same: the battery runs a closed-loop
zero-export controller, so the reading is the error signal it integrates
against its own output. Feeding it the same pre-adjustment reading several
times before the plant can reflect its response makes it over-correct past
target — effectively a burst of deltas.

Track an in-flight battery set keyed on source IP (matching the existing
dedup identity): the first handler owns the read-and-respond section; a
concurrent poll from the same battery is dropped after _track_battery_seen
has refreshed liveness/poll-interval state, so exactly one response goes
out per reading. Keyed per battery, so distinct batteries stay independent;
the flag is cleared in finally so a failed/hung read never locks a battery
out.

Replaces the old test_multiple_requests_with_throttling (which asserted the
pre-fix all-answered behavior) with an end-to-end UDP coalescing test plus
deterministic in-process tests for single-response coalescing, per-battery
independence, no lock-out, and flag-clear on meter failure. Python-only:
the Shelly emulator has no ESPHome mirror.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaCn4moMmua81wuKo8nKEK
@coderabbitai

coderabbitai Bot commented Jul 2, 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: db4c72be-85a6-45d4-9252-cd16891c9cd2

📥 Commits

Reviewing files that changed from the base of the PR and between 58ee49e and 63ae437.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • src/astrameter/ct002/ct002.py
  • src/astrameter/ct002/ct002_test.py
  • src/astrameter/shelly/shelly.py
  • src/astrameter/shelly/shelly_udp_test.py

Walkthrough

CT002 and Shelly emulator request handlers now track in-flight requests per consumer/battery using new sets, dropping duplicate concurrent polls while a meter reading is pending. Both handlers use try/finally to clear tracking state. New tests validate coalescing behavior, per-key isolation, and recovery after bursts or failures.

Changes

Poll coalescing fix

Layer / File(s) Summary
CT002 request coalescing
src/astrameter/ct002/ct002.py
Adds _inflight_consumers set and wraps _handle_request to drop duplicate polls per consumer_id, clearing state via try/finally.
CT002 coalescing tests
src/astrameter/ct002/ct002_test.py
Adds _RecordingTransport, _poll(), _gated_before_send() helpers and three async tests verifying coalescing, per-consumer isolation, and post-burst recovery.
Shelly request coalescing
src/astrameter/shelly/shelly.py
Adds _inflight_batteries set and reorganizes _handle_request to drop duplicate polls per battery IP with try/finally cleanup.
Shelly UDP coalescing tests
src/astrameter/shelly/shelly_udp_test.py
Adds GatedPowermeter and tests for UDP burst coalescing, per-battery isolation, post-burst recovery, and cleanup after meter-read failure; removes prior throttling test.
Changelog entry
CHANGELOG.md
Documents the fix ensuring one battery reply per meter reading.

Estimated code review effort: 4 (Complex) | ~55 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ClientA
  participant ClientB
  participant Handler
  participant Meter
  ClientA->>Handler: poll request
  Handler->>Handler: mark key as in-flight
  Handler->>Meter: read meter value (slow)
  ClientB->>Handler: duplicate poll (same key)
  Handler-->>ClientB: dropped (already in-flight)
  Meter-->>Handler: reading returned
  Handler-->>ClientA: single response
  Handler->>Handler: clear in-flight key (finally)
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: coalescing concurrent polls to prevent burst responses.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ 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/wait-for-next-message-deltas-4valul

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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaCn4moMmua81wuKo8nKEK
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Steering evaluation (base vs head)

Overall: 0 improved, 0 regressed, 15 unchanged across 15 metrics — mean 0% (unchanged).

Priority: priority-weighted 0% (unchanged) — ✅ 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 32 scenarios

Metric Base Head Δ
settle_mean_s 35.2 35.2 =
settle_p95_s 61.6 61.6 =
unsettled_events 0.5 0.5 =
overshoot_mean_w 44.0 44.0 =
overshoot_max_w 94.6 94.6 =
band_crossings_per_h 297.2 297.2 =
grid_p2p_w 257.8 257.8 =
grid_rms_w 239.5 239.5 =
steady_rms_w 111.6 111.6 =
mean_abs_grid_w 95.2 95.2 =
share_imbalance_w 60.8 60.8 =
avoidable_import_wh 35.0 35.0 =
avoidable_export_wh 25.5 25.5 =
cost_regret_ct 0.82 0.82 =
battery_travel_w_per_h 23700.4 23700.4 =

📊 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 (32 scenarios)
full_battery_low_pace — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 29.1→29.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 0.0 0.0 =
grid_p2p_w 70.0 70.0 =
grid_rms_w 29.1 29.1 =
steady_rms_w 29.1 29.1 =
mean_abs_grid_w 14.5 14.5 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 0.0 0.0 =
avoidable_export_wh 3.6 3.6 =
cost_regret_ct 0.0 0.0 =
battery_travel_w_per_h 31900.0 31900.0 =
mixed_cadence/eff — settle 50.8→50.8s, overshoot 164.5→164.5W, RMS 21.9→21.9W
Metric Base Head Δ
settle_mean_s 50.8 50.8 =
settle_p95_s 72.4 72.4 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 64.0 64.0 =
overshoot_max_w 164.5 164.5 =
band_crossings_per_h 24.4 24.4 =
grid_p2p_w 160.6 160.6 =
grid_rms_w 255.3 255.3 =
steady_rms_w 21.9 21.9 =
mean_abs_grid_w 64.1 64.1 =
share_imbalance_w 223.0 223.0 =
avoidable_import_wh 38.5 38.5 =
avoidable_export_wh 25.6 25.6 =
cost_regret_ct 0.95 0.95 =
battery_travel_w_per_h 31306.0 31306.0 =
mixed_cadence/fair — settle 47.7→47.7s, overshoot 70.4→70.4W, RMS 12.9→12.9W
Metric Base Head Δ
settle_mean_s 47.7 47.7 =
settle_p95_s 67.4 67.4 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 30.7 30.7 =
overshoot_max_w 70.4 70.4 =
band_crossings_per_h 21.6 21.6 =
grid_p2p_w 131.9 131.9 =
grid_rms_w 246.0 246.0 =
steady_rms_w 12.9 12.9 =
mean_abs_grid_w 58.6 58.6 =
share_imbalance_w 101.3 101.3 =
avoidable_import_wh 33.7 33.7 =
avoidable_export_wh 24.9 24.9 =
cost_regret_ct 0.81 0.81 =
battery_travel_w_per_h 28457.4 28457.4 =
mixed_cadence_solar/eff — settle 52.5→52.5s, overshoot 384.7→384.7W, RMS 28.6→28.6W
Metric Base Head Δ
settle_mean_s 52.5 52.5 =
settle_p95_s 95.6 95.6 =
unsettled_events 2.0 2.0 =
overshoot_mean_w 62.4 62.4 =
overshoot_max_w 384.7 384.7 =
band_crossings_per_h 37.3 37.3 =
grid_p2p_w 192.3 192.3 =
grid_rms_w 268.4 268.4 =
steady_rms_w 28.6 28.6 =
mean_abs_grid_w 76.4 76.4 =
share_imbalance_w 148.1 148.1 =
avoidable_import_wh 60.5 60.5 =
avoidable_export_wh 54.1 54.1 =
cost_regret_ct 1.38 1.38 =
battery_travel_w_per_h 37319.8 37319.8 =
mixed_cadence_solar/fair — settle 56.0→56.0s, overshoot 75.4→75.4W, RMS 23.7→23.7W
Metric Base Head Δ
settle_mean_s 56.0 56.0 =
settle_p95_s 119.5 119.5 =
unsettled_events 1.8 1.8 =
overshoot_mean_w 28.7 28.7 =
overshoot_max_w 75.4 75.4 =
band_crossings_per_h 28.3 28.3 =
grid_p2p_w 147.8 147.8 =
grid_rms_w 261.6 261.6 =
steady_rms_w 23.7 23.7 =
mean_abs_grid_w 72.2 72.2 =
share_imbalance_w 119.0 119.0 =
avoidable_import_wh 56.0 56.0 =
avoidable_export_wh 52.4 52.4 =
cost_regret_ct 1.26 1.26 =
battery_travel_w_per_h 32435.8 32435.8 =
mixed_venus_b2500/eff — settle 81.2→81.2s, overshoot 221.9→221.9W, RMS 18.6→18.6W
Metric Base Head Δ
settle_mean_s 81.2 81.2 =
settle_p95_s 217.1 217.1 =
unsettled_events 2.4 2.4 =
overshoot_mean_w 100.2 100.2 =
overshoot_max_w 221.9 221.9 =
band_crossings_per_h 49.2 49.2 =
grid_p2p_w 67.3 67.3 =
grid_rms_w 197.5 197.5 =
steady_rms_w 18.6 18.6 =
mean_abs_grid_w 43.2 43.2 =
share_imbalance_w 263.0 263.0 =
avoidable_import_wh 44.2 44.2 =
avoidable_export_wh 20.5 20.5 =
cost_regret_ct 1.16 1.16 =
battery_travel_w_per_h 39704.6 39704.6 =
mixed_venus_b2500/fair — settle 75.4→75.4s, overshoot 231.1→231.1W, RMS 22.3→22.3W
Metric Base Head Δ
settle_mean_s 75.4 75.4 =
settle_p95_s 191.0 191.0 =
unsettled_events 1.4 1.4 =
overshoot_mean_w 110.5 110.5 =
overshoot_max_w 231.1 231.1 =
band_crossings_per_h 53.3 53.3 =
grid_p2p_w 67.1 67.1 =
grid_rms_w 189.9 189.9 =
steady_rms_w 22.3 22.3 =
mean_abs_grid_w 44.5 44.5 =
share_imbalance_w 100.7 100.7 =
avoidable_import_wh 47.4 47.4 =
avoidable_export_wh 19.4 19.4 =
cost_regret_ct 1.27 1.27 =
battery_travel_w_per_h 43475.4 43475.4 =
phase_imbalance — settle 53.4→53.4s, overshoot 145.2→145.2W, RMS 30.3→30.3W
Metric Base Head Δ
settle_mean_s 53.4 53.4 =
settle_p95_s 123.6 123.6 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 64.9 64.9 =
overshoot_max_w 145.2 145.2 =
band_crossings_per_h 87.6 87.6 =
grid_p2p_w 42.4 42.4 =
grid_rms_w 199.6 199.6 =
steady_rms_w 30.3 30.3 =
mean_abs_grid_w 40.4 40.4 =
share_imbalance_w 0.0 0.0 =
avoidable_import_wh 25.6 25.6 =
avoidable_export_wh 14.8 14.8 =
cost_regret_ct 0.65 0.65 =
battery_travel_w_per_h 18274.2 18274.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.1→18.1s, overshoot 126.1→126.1W, RMS 14.0→14.0W
Metric Base Head Δ
settle_mean_s 18.1 18.1 =
settle_p95_s 23.0 23.0 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 70.7 70.7 =
overshoot_max_w 126.1 126.1 =
band_crossings_per_h 33.6 33.6 =
grid_p2p_w 26.2 26.2 =
grid_rms_w 221.6 221.6 =
steady_rms_w 14.0 14.0 =
mean_abs_grid_w 43.1 43.1 =
share_imbalance_w 166.4 166.4 =
avoidable_import_wh 25.7 25.7 =
avoidable_export_wh 17.3 17.3 =
cost_regret_ct 0.64 0.64 =
battery_travel_w_per_h 22029.2 22029.2 =
two_venus/fair — settle 18.4→18.4s, overshoot 116.7→116.7W, RMS 13.8→13.8W
Metric Base Head Δ
settle_mean_s 18.4 18.4 =
settle_p95_s 24.4 24.4 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 94.4 94.4 =
overshoot_max_w 116.7 116.7 =
band_crossings_per_h 21.6 21.6 =
grid_p2p_w 23.1 23.1 =
grid_rms_w 217.6 217.6 =
steady_rms_w 13.8 13.8 =
mean_abs_grid_w 41.4 41.4 =
share_imbalance_w 18.8 18.8 =
avoidable_import_wh 24.2 24.2 =
avoidable_export_wh 17.1 17.1 =
cost_regret_ct 0.59 0.59 =
battery_travel_w_per_h 19385.4 19385.4 =
two_venus_noisy/eff — 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 2898.0 2898.0 =
grid_p2p_w 294.5 294.5 =
grid_rms_w 94.3 94.3 =
steady_rms_w 94.3 94.3 =
mean_abs_grid_w 79.4 79.4 =
share_imbalance_w 26.9 26.9 =
avoidable_import_wh 43.6 43.6 =
avoidable_export_wh 35.8 35.8 =
cost_regret_ct 1.02 1.02 =
battery_travel_w_per_h 23402.6 23402.6 =
two_venus_noisy/fair — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 94.2→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 2893.6 2893.6 =
grid_p2p_w 294.1 294.1 =
grid_rms_w 94.2 94.2 =
steady_rms_w 94.2 94.2 =
mean_abs_grid_w 79.4 79.4 =
share_imbalance_w 26.4 26.4 =
avoidable_import_wh 43.6 43.6 =
avoidable_export_wh 35.8 35.8 =
cost_regret_ct 1.02 1.02 =
battery_travel_w_per_h 22778.8 22778.8 =
two_venus_slow/fair — settle 41.8→41.8s, overshoot 174.5→174.5W, RMS 14.0→14.0W
Metric Base Head Δ
settle_mean_s 41.8 41.8 =
settle_p95_s 52.8 52.8 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 56.6 56.6 =
overshoot_max_w 174.5 174.5 =
band_crossings_per_h 10.4 10.4 =
grid_p2p_w 81.8 81.8 =
grid_rms_w 303.7 303.7 =
steady_rms_w 14.0 14.0 =
mean_abs_grid_w 76.9 76.9 =
share_imbalance_w 24.4 24.4 =
avoidable_import_wh 41.5 41.5 =
avoidable_export_wh 35.3 35.3 =
cost_regret_ct 0.96 0.96 =
battery_travel_w_per_h 18563.6 18563.6 =
two_venus_solar/eff — settle 26.0→26.0s, overshoot 396.6→396.6W, RMS 20.4→20.4W
Metric Base Head Δ
settle_mean_s 26.0 26.0 =
settle_p95_s 44.3 44.3 =
unsettled_events 1.6 1.6 =
overshoot_mean_w 100.5 100.5 =
overshoot_max_w 396.6 396.6 =
band_crossings_per_h 40.4 40.4 =
grid_p2p_w 60.2 60.2 =
grid_rms_w 228.7 228.7 =
steady_rms_w 20.4 20.4 =
mean_abs_grid_w 51.9 51.9 =
share_imbalance_w 69.3 69.3 =
avoidable_import_wh 42.4 42.4 =
avoidable_export_wh 35.4 35.4 =
cost_regret_ct 0.99 0.99 =
battery_travel_w_per_h 26336.0 26336.0 =
two_venus_solar/fair — settle 25.9→25.9s, overshoot 151.4→151.4W, RMS 20.4→20.4W
Metric Base Head Δ
settle_mean_s 25.9 25.9 =
settle_p95_s 52.1 52.1 =
unsettled_events 1.4 1.4 =
overshoot_mean_w 98.1 98.1 =
overshoot_max_w 151.4 151.4 =
band_crossings_per_h 30.7 30.7 =
grid_p2p_w 59.8 59.8 =
grid_rms_w 225.6 225.6 =
steady_rms_w 20.4 20.4 =
mean_abs_grid_w 51.1 51.1 =
share_imbalance_w 31.3 31.3 =
avoidable_import_wh 40.1 40.1 =
avoidable_export_wh 36.5 36.5 =
cost_regret_ct 0.91 0.91 =
battery_travel_w_per_h 24206.8 24206.8 =
two_venus_trace/eff — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 283.1→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 307.6 307.6 =
grid_p2p_w 744.9 744.9 =
grid_rms_w 282.1 282.1 =
steady_rms_w 283.1 283.1 =
mean_abs_grid_w 122.0 122.0 =
share_imbalance_w 398.3 398.3 =
avoidable_import_wh 80.0 80.0 =
avoidable_export_wh 42.0 42.0 =
cost_regret_ct 2.06 2.06 =
battery_travel_w_per_h 47999.0 47999.0 =
two_venus_trace/fair — settle 0.0→0.0s, overshoot 0.0→0.0W, RMS 282.1→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 310.0 310.0 =
grid_p2p_w 732.2 732.2 =
grid_rms_w 281.1 281.1 =
steady_rms_w 282.1 282.1 =
mean_abs_grid_w 121.1 121.1 =
share_imbalance_w 30.3 30.3 =
avoidable_import_wh 79.0 79.0 =
avoidable_export_wh 42.0 42.0 =
cost_regret_ct 2.04 2.04 =
battery_travel_w_per_h 46054.4 46054.4 =
venus_d_plus_c/eff — settle 20.1→20.1s, overshoot 128.9→128.9W, RMS 14.7→14.7W
Metric Base Head Δ
settle_mean_s 20.1 20.1 =
settle_p95_s 31.9 31.9 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 78.9 78.9 =
overshoot_max_w 128.9 128.9 =
band_crossings_per_h 35.2 35.2 =
grid_p2p_w 28.0 28.0 =
grid_rms_w 214.7 214.7 =
steady_rms_w 14.7 14.7 =
mean_abs_grid_w 41.6 41.6 =
share_imbalance_w 167.3 167.3 =
avoidable_import_wh 24.9 24.9 =
avoidable_export_wh 16.7 16.7 =
cost_regret_ct 0.61 0.61 =
battery_travel_w_per_h 23208.0 23208.0 =
venus_d_plus_c/fair — settle 21.6→21.6s, overshoot 121.0→121.0W, RMS 14.6→14.6W
Metric Base Head Δ
settle_mean_s 21.6 21.6 =
settle_p95_s 32.5 32.5 =
unsettled_events 0.0 0.0 =
overshoot_mean_w 90.6 90.6 =
overshoot_max_w 121.0 121.0 =
band_crossings_per_h 24.8 24.8 =
grid_p2p_w 27.4 27.4 =
grid_rms_w 211.5 211.5 =
steady_rms_w 14.6 14.6 =
mean_abs_grid_w 40.8 40.8 =
share_imbalance_w 30.2 30.2 =
avoidable_import_wh 24.1 24.1 =
avoidable_export_wh 16.7 16.7 =
cost_regret_ct 0.59 0.59 =
battery_travel_w_per_h 21212.8 21212.8 =

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

The end-to-end test's two coalesced-away polls are expected to time out,
and send_req swallowed that with `except TimeoutError`. But asyncio.wait_for
raises asyncio.TimeoutError, which on Python 3.10 is a distinct class from
the builtin TimeoutError (they were unified in 3.11). So on 3.10 the
expected timeout escaped and failed the test, while 3.11-3.13 passed.

Catch both classes so the expected coalescing timeouts are swallowed on
every supported Python version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaCn4moMmua81wuKo8nKEK
@tomquist tomquist marked this pull request as ready for review July 2, 2026 20:29
@tomquist tomquist merged commit 66604bd into develop Jul 2, 2026
60 checks passed
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