Skip to content

fix(energy): floor the request window to whole UTC hours - #284

Closed
andrew-blake wants to merge 6 commits into
fix/report-timestamps-device-localfrom
chore/gating-comment-cleanup
Closed

fix(energy): floor the request window to whole UTC hours#284
andrew-blake wants to merge 6 commits into
fix/report-timestamps-device-localfrom
chore/gating-comment-cleanup

Conversation

@andrew-blake

@andrew-blake andrew-blake commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Summary

Sits on #283 and needs nothing from it; the documentation correction here amends ADR-023, which #275 introduces. Energy requests floor the from bound to the hour and normalise both bounds to UTC, so the oldest hourly bucket is always requested whole: the server sums only the samples at or after from, and a bound inside the hour returns a partial slice of that bucket which shrinks as the poll clock advances. The measurements and the UTC-label evidence are in the docstring of EnergyTrackerBase._energy_window. Two further commits make the outdoor-temperature poll observable from outside: diagnostics gain the time of the last poll, and a unit whose poll starts failing says so once. The rest correct comments and documentation that no longer describe the code.

Key changes

  • EnergyTrackerBase._energy_window computes the request window, floors from to the hour, and converts both bounds with astimezone(UTC). A naive now raises. The ATA and ATW trackers both take their window from it.
  • sensor_ata.py and docs/entities.md: the ATA outdoor-temperature sensor is created for every unit, and both now say so.
  • telemetry_tracker.py: a clause naming sensor_atw.py as where the zone-2 and boiler sensors are gated, so the request filter here reads as the cache-hygiene measure it is.
  • ADR-023 and the mock's dataset comment rest the zone-2 assumption on evidence observable in a captured API response: the dataset ids, and the unfiltered eight-dataset set a single-zone unit receives.
  • Diagnostics carry outdoor_temp_last_poll_at, stamped on every path. Read against outdoor_temp_recorded_at it separates "the endpoint has nothing newer" from "we have not asked recently". outdoor_temp_last_error answers neither, being cleared by any call that did not raise, including one that returned no reading.
  • A unit entering an outdoor-temperature failure streak warns once, naming the unit and that the sensor keeps its previous value; recovery logs once at info. A failed poll resets the 30-minute timer and logged only at debug, so such a unit read as an idle one.

What changes for users

  • No sensor value or statistic changes, since the decrease guard already kept the correct value. The recurring "possible API issue" warning in the log stops.
  • A new warning can appear: a unit whose outdoor-temperature poll begins failing logs once that the sensor will hold its previous value until a poll succeeds. It was previously silent at the default log level, so a first sighting of it means the condition was already there, not that this change caused it.

Risks accepted

  • The hour floor is applied in UTC on the evidence of live probes of two units at different offsets, with no vendor contract behind it, while /report/v1/ on the same account labels its points in the unit's own zone (ADR-022). If some units label these buckets locally at an offset that is not a whole number of hours, the floored bound lands mid-bucket and their warnings persist. That persistence is the detection; the undo is a revert of the two energy commits.
  • The outdoor-temperature failure warning guards a path derivable from the code, where a failed poll resets the 30-minute timer and logs only at debug, but which has not been observed happening on a real deployment. If such failures prove common the warning could become noise, and the streak gate is then the thing to tighten.

AI Disclosure

  • No AI/agent tooling was used
  • AI/agent tooling assisted; I reviewed and ran the change myself before submitting

Testing

  • make pre-commit: all hooks passed.
  • make test-api: 395 passed, 1 skipped, 1 xfailed, 16 deselected.
  • make test-integration: 259 passed, 1 warning.
  • make test-e2e: 16 passed, 397 deselected.
  • Three integration tests added. In tests/integration/test_coordinator_energy.py: the energy request's from bound is floored to the hour and the window only ever widens, and a local-aware now yields the same window as its UTC equivalent while a naive one raises. In tests/integration/test_outdoor_temperature_sensor.py: a failing outdoor-temperature poll warns exactly once per failure streak.
  • Live probes of the vendor energy endpoint, establishing that its hourly buckets are labelled in UTC; recorded in the _energy_window docstring.
  • Prod deployment of the energy commits: 2026-08-25 08:10:29 UTC, with all 62 shipped .py and .json files verified byte-identical by per-file SHA-256 to the branch as it stood at that commit.
  • Prod deployment of the branch tip: the two observability commits landed after that deployment and have not run on a real installation. They add a diagnostics field and one warning, and change no request or entity state.
  • Prod confirmation that the recurring energy warning stops: zero "decreased from A to B kWh - keeping previous value (possible API issue)" warnings across 29 consecutive 30-minute energy poll rounds (2026-08-26 21:40 to 2026-08-27 12:10 local), and across the 8 rounds immediately following the deployment. This exercises the energy commits, which are what was deployed. Before the fix the warning fired on every round without exception. The deployment has run 51 hours without a restart; rounds between the two observed windows are no longer evidenceable because the container log has rotated past them, and the round count is derived from delta log lines so it is a floor rather than a census.

andrew-blake and others added 5 commits August 25, 2026 08:26
sensor_ata.py carried "Only created for devices where outdoor sensor
detected during capability discovery" from the original gated
implementation (#53). The sensor has been always-created since, and the
two lines below the comment in the same description say so. docs/entities.md
carried the matching "(if available)", contradicted by its own line 102.

The hazard is not today's behaviour, which is correct. has_outdoor_temp_sensor
still exists as a runtime discovery flag, so the comment reads as an
invitation to reinstate should_create_fn on it - gating creation on a
transient value, which would drop the entity for any unit idle at setup.

Also names which concern the ATW keep-filter serves: the sensors are gated
in sensor_atw.py, and that filter only keeps fake values out of the cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The server sums only the samples at or after "from", and both energy call
sites computed it as now - 48h with no flooring. The oldest bucket was
therefore a shrinking partial hour: every poll asked for a smaller slice
of it than the last, so the value came back lower and the decrease guard
logged it as "possible API issue".

Measured on prod 2026-08-25: all 79 such warnings hit the bucket exactly
48h old, at :16 and :46 past the hour - the 30-minute energy interval.
Kukja's 2026-08-23 06:00 bucket holds 0.567 kWh, read as 0.433 at :16
(prorata predicts 44/60) and 0.133 at :46 (14/60, observed 0.235).
Re-requesting it floored returned 0.567 again.

No data was wrong: the guard already kept the larger value, and nothing
was double-counted or lost. This removes ~4 warnings/hour/unit that had
been framing our own window arithmetic as a vendor fault - 76% of prod's
melcloudhome log volume.

The window computation moves to EnergyTrackerBase so the reason lives in
one place rather than being duplicated at both call sites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The report endpoints stamp points in the unit's own zone (ADR-022), so
"is UTC right here?" is the obvious question about the hour floor. It is:
measured 2026-08-25 07:51 UTC, a Europe/London (+1) and a Europe/Skopje
(+2) unit both reported newest bucket 07:00 for an identical window, and
pushing "to" three hours past either unit's local wall-clock surfaced no
newer bucket, ruling out clipping as the explanation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The energy request format ("%Y-%m-%d %H:%M") carries no offset marker, so
strftime drops the tzinfo without complaint. A caller passing a
local-aware datetime would query a window shifted by its offset, and
nothing - not the request, not the response, not the decrease guard -
would report anything wrong.

Both current callers pass datetime.now(UTC), so this changes no behaviour
today. It closes the path where a future one does not. A naive datetime
cannot be rescued by astimezone and raises instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR-023's "what tilts it that way" paragraph and the mock's INTEMP_DATASETS
comment both justified themselves by citing a source this repo should not
reference. Removing the citation alone would have left the ADR asserting a
deliberate assumption with no reason given, next to a paragraph arguing the
other way, so the argument is rebuilt from facts checkable in a captured
response:

- the report's dataset ids are exactly the measure names the per-measure
  telemetry endpoint used, zone-2 names included
- a single-zone unit still receives *_zone1 and *_boiler series, so the
  server is not filtering datasets by device capability on any visible axis

The counter-argument gains the consequence that follows honestly from that:
if the unfiltered set is simply fixed at eight, zone-2 series never arrive
for anyone. The ROOM_TEMPERATURE_ZONE1 / SET_TEMPERATURE_ZONE1 label keys
cited on the other side are observable in comfort-graph responses.

This is a weaker case than the one it replaces, and deliberately reads that
way. No claim here rests on anything but a recorded response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.25%. Comparing base (77683f2) to head (07684c1).

Additional details and impacted files

Impacted file tree graph

@@                          Coverage Diff                           @@
##           fix/report-timestamps-device-local     #284      +/-   ##
======================================================================
+ Coverage                               93.22%   93.25%   +0.03%     
======================================================================
  Files                                      44       44              
  Lines                                    2922     2937      +15     
======================================================================
+ Hits                                     2724     2739      +15     
  Misses                                    198      198              
Flag Coverage Δ
unittests 93.25% <100.00%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
custom_components/melcloudhome/api/models_ata.py 99.00% <100.00%> (+0.01%) ⬆️
custom_components/melcloudhome/api/models_atw.py 100.00% <100.00%> (ø)
custom_components/melcloudhome/coordinator.py 96.58% <100.00%> (+0.12%) ⬆️
...stom_components/melcloudhome/diagnostics_shared.py 100.00% <ø> (ø)
...stom_components/melcloudhome/energy_tracker_ata.py 92.72% <100.00%> (-0.26%) ⬇️
...stom_components/melcloudhome/energy_tracker_atw.py 82.71% <100.00%> (-0.42%) ⬇️
...tom_components/melcloudhome/energy_tracker_base.py 96.26% <100.00%> (+0.20%) ⬆️
custom_components/melcloudhome/sensor_ata.py 94.11% <ø> (ø)
...ustom_components/melcloudhome/telemetry_tracker.py 94.59% <ø> (ø)

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 77683f2...07684c1. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…n on failure

Diagnostics carried the reading and any error, but never when a poll last
completed, so "the endpoint has nothing newer" and "we have not asked
recently" were indistinguishable from outside. outdoor_temp_last_poll_at
is stamped on every path and read against outdoor_temp_recorded_at.

A failed poll resets the 30-minute timer and keeps the previous value, and
logged only at debug. A unit failing every poll was therefore identical to
an idle one at the level anyone runs in production. Entering a failure
streak now warns once, naming the unit and that the sensor holds its old
value; recovery logs once at info. Neither repeats while the state
persists.

outdoor_temp_last_error is cleared by any call that did not raise,
including one that returned no reading, so it answers "did this raise",
not "did this find anything". The new field is what separates those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andrew-blake

Copy link
Copy Markdown
Owner Author

Split into #286 (the energy window fix) and #287 (outdoor-temperature poll diagnostics). No single title covered both concerns.

#286 carries the three energy commits and is the half already deployed and soaked on real hardware. #287 carries the diagnostics field and the failure-streak warning, plus the stale gating-comment cleanup, and has not run on a real installation.

The ADR-023 provenance commit that was sitting here has moved to #283, which already amends that ADR.

@andrew-blake
andrew-blake deleted the chore/gating-comment-cleanup branch August 30, 2026 16:54
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