Skip to content

Auto-convert kW/MW/mW power sensors to watts, reject non-power units - #573

Merged
tomquist merged 3 commits into
developfrom
claude/unit-conversion-validation-scf0jf
Aug 3, 2026
Merged

Auto-convert kW/MW/mW power sensors to watts, reject non-power units#573
tomquist merged 3 commits into
developfrom
claude/unit-conversion-validation-scf0jf

Conversation

@tomquist

@tomquist tomquist commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes silent misreading of kilowatt-scale power sensors as watts, which caused typical household readings to round to ~0 W and left batteries idle with no error. Both the ESPHome component and Home Assistant powermeter now:

  1. Auto-convert declared power units (kW → ×1000, MW → ×1e6, mW → ×0.001) to watts
  2. Reject at config time (ESPHome) or runtime (Home Assistant) any non-power unit (°C, %, kWh, etc.) with an explicit error
  3. Assume watts for sensors with no declared unit (historical behavior, preserved for backward compatibility)

Key Changes

ESPHome Component (esphome/components/ct002/)

  • Added POWER_UNIT_SCALES dict mapping power units to conversion factors
  • New _power_unit_scale() and _validate_power_unit() helpers to validate and extract unit scales from sensor config
  • FINAL_VALIDATE_SCHEMA hook rejects non-power units at config validation time with clear error messages
  • to_code() calls set_power_unit_scale() for each phase when a unit is declared
  • C++ side: set_power_unit_scale() setter, per-phase unit_scale_ and unit_declared_ arrays, and a kW-suspicion heuristic (logs warning when undeclared sensor shows sub-1 W readings) as a runtime safety net

Home Assistant Powermeter (src/astrameter/powermeter/homeassistant.py)

  • Tracks unit_of_measurement per entity in _entity_units dict
  • _update_entity_unit() extracts and validates units from both initial snapshots and state diffs
  • _get_entity_value() applies the unit scale at read time; rejects non-power units with a clear error
  • Handles partial attribute diffs correctly (a diff without unit_of_measurement leaves the recorded unit untouched)

Tests

  • 10 new Home Assistant tests covering kW→W conversion, unit preservation across state-only diffs, attribute diffs, mixed units in calculate mode, and REST bootstrap
  • 4 new ESPHome unit validation tests

Documentation & Examples

  • Updated docs/powermeters.md, docs/installation/esphome.md, docs/esphome-powermeters.md to document unit handling
  • Updated esphome.example.yaml and test YAML with unit examples and comments
  • Updated CHANGELOG.md with user-facing summary

Implementation Details

  • Backward compatible: sensors without a declared unit are assumed to report watts (no breaking change)
  • Order-independent: unit and state updates may arrive in any order; the scale is applied at read time
  • Config-time validation (ESPHome): non-power units are caught during YAML validation, not at runtime
  • Runtime validation (Home Assistant): non-power units are caught when first encountered, with a clear error message
  • Logging: ESPHome logs a one-shot warning when an undeclared sensor shows signs of being kW (10+ consecutive sub-1 W readings); Home Assistant logs info when a kW unit is detected and converted

Addresses issues #39 and #572.

https://claude.ai/code/session_01XFuVf6AqbQSPdie9gwvtyZ

Summary by CodeRabbit

  • New Features

    • Power readings in kW, MW, and mW are automatically converted to watts.
    • Sensors without a declared unit default to watts.
    • ESPHome warns when unitless readings appear to be incorrectly scaled kilowatts.
  • Bug Fixes

    • Unsupported temperature, energy, and other non-power units are rejected with clear errors.
    • Documentation warns against manual ×1000 adjustments that could cause double scaling.
  • Documentation

    • Updated installation, configuration, and powermeter guidance for unit handling and validation.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@tomquist, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 16 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 24313b20-3da7-4450-910e-d5b196b4279a

📥 Commits

Reviewing files that changed from the base of the PR and between 5839280 and 6fe4e55.

📒 Files selected for processing (2)
  • src/astrameter/powermeter/homeassistant.py
  • src/astrameter/powermeter/homeassistant_test.py

Walkthrough

Changes

Power unit handling

Layer / File(s) Summary
ESPHome unit validation and code generation
esphome/components/ct002/..., tests/components/ct002/*
ESPHome accepts W, kW, MW, and mW, converts declared units to watts, defaults undeclared units to watts, and rejects unsupported units.
ESPHome runtime conversion and warnings
esphome/components/ct002/ct002.*
CT002 applies per-phase scales and warns once when undeclared sensors produce sustained nonzero readings below 1 W.
Home Assistant unit tracking and conversion
src/astrameter/powermeter/homeassistant.py, src/astrameter/powermeter/homeassistant_test.py
Home Assistant preserves units across snapshots, REST responses, and websocket updates. Supported units convert to watts, while unsupported units raise ValueError.
Documentation and synchronization guidance
docs/..., CONTRIBUTING.md, CHANGELOG.md, esphome.example.yaml
Documentation describes unit conversion, validation, defaults, warnings, and removal of manual scaling workarounds.

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

Possibly related issues

Possibly related PRs

  • tomquist/AstraMeter#550 — Both PRs modify CT002Component::setup() reading handling, but this PR adds unit scaling while that PR addresses non-finite readings.

Sequence Diagram(s)

sequenceDiagram
  participant ESPHomeSensor
  participant CT002Component
  participant HomeAssistant
  ESPHomeSensor->>CT002Component: Report reading and declared unit
  CT002Component->>CT002Component: Convert reading to watts
  CT002Component->>HomeAssistant: Publish watt reading
Loading
sequenceDiagram
  participant HomeAssistant
  participant PowerEntity
  participant PowerMeter
  HomeAssistant->>PowerEntity: Send state and unit attributes
  PowerEntity->>PowerEntity: Persist unit across partial updates
  PowerEntity->>PowerMeter: Provide entity value
  PowerMeter->>PowerMeter: Convert value to watts or raise ValueError
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.21% 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 clearly and concisely summarizes the pull request's main conversion and validation changes.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/unit-conversion-validation-scf0jf

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.

tomquist added a commit that referenced this pull request Jul 23, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFuVf6AqbQSPdie9gwvtyZ
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Steering evaluation (base vs head)

Overall: 5 improved, 1 regressed, 9 unchanged across 15 metrics — mean +0.3% (worse).

Priority: priority-weighted +0.6% (worse) — ✅ 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.3 35.2 -0%
settle_p95_s 61.8 61.6 -0%
unsettled_events 0.5 0.5 =
overshoot_mean_w 44.1 44.0 -0%
overshoot_max_w 90.1 94.6 +5%
band_crossings_per_h 297.3 297.2 -0%
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 23706.5 23700.4 -0%

📊 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 28.7→26.0s, overshoot 252.7→396.6W, RMS 20.5→20.4W
Metric Base Head Δ
settle_mean_s 28.7 26.0 -9%
settle_p95_s 52.6 44.3 -16%
unsettled_events 1.6 1.6 =
overshoot_mean_w 104.4 100.5 -4%
overshoot_max_w 252.7 396.6 +57%
band_crossings_per_h 42.0 40.4 -4%
grid_p2p_w 61.3 60.2 -2%
grid_rms_w 229.5 228.7 -0%
steady_rms_w 20.5 20.4 -0%
mean_abs_grid_w 52.3 51.9 -1%
share_imbalance_w 70.5 69.3 -2%
avoidable_import_wh 42.3 42.4 +0%
avoidable_export_wh 36.1 35.4 -2%
cost_regret_ct 0.98 0.99 +1%
battery_travel_w_per_h 26533.4 26336.0 -1%
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).

tomquist and others added 2 commits July 28, 2026 20:43
A grid-power source reporting kW was silently misread as W in both stacks
(#39, #572): typical household magnitudes round to ~0 W on the wire, the
batteries idle, and nothing hints at a unit mismatch.

ESPHome ct002:
- FINAL_VALIDATE_SCHEMA rejects power_sensor_lX references whose declared
  unit_of_measurement is not a power unit (°C, %, kWh, ...) with an
  explicit config-time error.
- to_code passes a per-phase unit scale (kW→1000, MW→1e6, mW→0.001) to the
  firmware, applied where sensor callbacks fill the raw-value cache.
- Sensors with no declared unit keep the historical assume-watts behavior,
  but the firmware now logs a one-shot warning after 10 consecutive
  nonzero sub-1 W readings — the signature of a kW feed misread as W.

Python HomeAssistant powermeter:
- Tracks each entity's unit_of_measurement from the subscribe_entities
  snapshot, attribute diffs, and the REST bootstrap.
- Converts kW/MW/mW states to watts at read time; rejects non-power units
  with a clear ValueError instead of feeding wrong values downstream.

Docs note that a manual ×1000 workaround must be removed on kW sensors,
since the value would now be scaled twice.

Closes #572

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFuVf6AqbQSPdie9gwvtyZ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFuVf6AqbQSPdie9gwvtyZ
@tomquist
tomquist force-pushed the claude/unit-conversion-validation-scf0jf branch from 467259f to 5839280 Compare July 28, 2026 20:44
@tomquist
tomquist marked this pull request as ready for review August 3, 2026 20:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/astrameter/powermeter/homeassistant.py`:
- Around line 333-342: Update _update_entity_unit to distinguish complete
snapshots/REST payloads from partial “+” attribute diffs: clear the cached
_entity_units entry when a complete payload omits _ATTR_UNIT_OF_MEASUREMENT,
while preserving it for diffs that omit the key. Add a reconnect regression test
covering an initial kW unit followed by an unqualified state that uses the watts
default.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a3253f6d-5f1a-4493-a1c7-77238fc474f0

📥 Commits

Reviewing files that changed from the base of the PR and between 26707b6 and 5839280.

📒 Files selected for processing (13)
  • CHANGELOG.md
  • CONTRIBUTING.md
  • docs/esphome-powermeters.md
  • docs/installation/esphome.md
  • docs/powermeters.md
  • esphome.example.yaml
  • esphome/components/ct002/__init__.py
  • esphome/components/ct002/ct002.cpp
  • esphome/components/ct002/ct002.h
  • src/astrameter/powermeter/homeassistant.py
  • src/astrameter/powermeter/homeassistant_test.py
  • tests/components/ct002/test.1phase.yaml
  • tests/components/ct002/test_codegen.py

Comment thread src/astrameter/powermeter/homeassistant.py Outdated
A unit learned from an earlier snapshot survived _reset_for_reconnect and
would keep scaling values after the entity stopped declaring one (e.g.
reconfigured from kW to unitless). Full attribute payloads (snapshot/REST)
now replace the recorded unit — clearing it back to the watts default when
absent — while partial '+' diffs still only touch it when the key itself
is present. Adds reconnect and full-snapshot regression tests.

Addresses CodeRabbit review on #573.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFuVf6AqbQSPdie9gwvtyZ
@tomquist
tomquist merged commit ebd3089 into develop Aug 3, 2026
123 of 125 checks passed
@tomquist
tomquist deleted the claude/unit-conversion-validation-scf0jf branch August 3, 2026 20:56
tomquist added a commit that referenced this pull request Aug 3, 2026
* Share one power-unit list between the meter and the sensor picker

The unit auto-conversion added in #573 taught the Home Assistant
powermeter to read W, kW, MW and mW. The dashboard's sensor picker kept
its own copy of that list, still saying "W or kW" — so an MW or mW
sensor was hidden from the guided form, and one already configured was
reported as "Not found in Home Assistant right now."

Both sides now read `astrameter.power_units`, a leaf module so the
picker cannot pull in the powermeter package (whose `__init__` cycles
back through the config loader).

A `device_class: power` entity whose unit the meter refuses is still
offered — a mislabelled template sensor is exactly the entity someone
goes looking for — but it is marked "not a power unit" in the list, and
choosing it says AstraMeter cannot read it instead of looking resolved.
The multiplier field now warns that a kW sensor is converted on its own,
since a leftover 1000 there scales the reading twice.

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

* Fall back to the entity id when a sensor has no friendly name

HaEntity.name is optional, so an entity without one rendered "undefined
reports kWh". The suggestion list already fell back; the row's help line
now does too.

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

---------

Co-authored-by: tomquist <528585+tomquist@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <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.

1 participant