Repository navigation
refactor(clock): drop the host-timezone clock, measure everything from now_utc - #5084
Conversation
…m now_utc update_time() kept two clocks: now_utc/midnight_utc from the configured timezone: setting, and now/midnight from datetime.now() - whatever timezone the container happens to be in. minutes_now and the time_abs_str/time_now_str renderings came off the second one. They agree only while the two timezones match. When they do not, minutes_now is offset from the midnight_utc-keyed rate and forecast arrays it indexes into, so Predbat reads "now" at the wrong point in its own rates, and every timestamp it renders - the plan table included - is offset from the data it labels. Derive minutes_now and difference_minutes from now_utc, and anchor the two time strings to midnight_utc. now_utc and the midnight derived from it share a tzinfo (replace() keeps it), so these stay wall-clock differences, exactly as the naive arithmetic was - verified on both 2026 DST changeover days. self.now and self.midnight are gone. The one caller that genuinely needs the host clock is the scheduler in initialize(): its run times go to run_every(), and hass.py's timer_tick compares them against datetime.now(). It now computes that locally instead of two instance fields existing for its sake. Also in scope because they shared the fields: - minutes_since_yesterday() built its comparison point with datetime.combine(), which drops tzinfo - replace() instead, same answer for the same wall clock. - fetch.py's date: overrides are parsed naive from apps.yaml, so they take midnight_utc's offset before the subtraction; day_of_week now reads the configured timezone's weekday. - futurerate's cache-age arithmetic stays on the host clock, since its stamps are host-naive and persisted to storage by earlier versions - it just computes its own midnight rather than borrowing Predbat's. - predheat carried its own copy of the same split and gets the same treatment. New tests/test_clock.py pins a configured timezone that deliberately disagrees with the host, which is the only way the two clocks are separable at all - on a machine where they agree the old and new code are indistinguishable. Mutation-tested: restoring the host clock fails it (930 vs 150). ./run_all --quick green (314 tests) under both a mismatched host timezone and TZ=Europe/London; pre-commit clean. Follow-up, not in this PR: now_utc/midnight_utc are misnamed - they are timezone aware local time for the inverter system, never UTC. Worth renaming now that they are the only clock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Hmm. That's not a pr I thought I'd pushed. Need to check what Claude has been up to. |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved moderate clock-consistency findings remain in predbat.py and test_clock.py, plus a PredHeat regression-test gap.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR refactors Predbat and PredHeat to use the configured timezone clock consistently while preserving host-time handling for scheduling and legacy cache timestamps.
Changes:
- Consolidates configured-time clock calculations and updates date/rate handling.
- Preserves host-clock compatibility for scheduling and FutureRate caches.
- Adds mismatched-timezone regression coverage and updates affected tests and documentation.
File summaries
| File | Reviewed changes and final review notes |
|---|---|
apps/predbat/utils.py |
Updates timezone-aware elapsed-time calculations. |
apps/predbat/unit_test.py |
Registers clock tests. |
apps/predbat/tests/test_rate_replicate_missing_slots.py |
Updates timezone-aware fixtures. |
apps/predbat/tests/test_manual_times.py |
Removes obsolete clock restoration. |
apps/predbat/tests/test_manual_soc.py |
Removes obsolete clock restoration. |
apps/predbat/tests/test_manual_soc_max.py |
Removes obsolete clock restoration. |
apps/predbat/tests/test_clock.py |
Adds mismatched-timezone regression tests. Moderate, 1 vote: the midnight comparison can falsely fail due to rounding and separate clock reads; use cyclic distance or capture the wall clock before updating. |
apps/predbat/tests/test_basic_rates.py |
Updates clock fixtures. |
apps/predbat/tests/test_basic_rates_utc.py |
Removes obsolete host-clock fixtures. |
apps/predbat/predheat.py |
Aligns prediction calculations with configured time. Nit, 1 vote: add regression coverage for differing configured and host timezones. |
apps/predbat/predbat.py |
Consolidates clock calculations and scheduler timing. Moderate, 2 votes: update TeslemetryAPI._local_today_weekday() to use the base configured clock or preserve a compatibility alias. Moderate, 1 vote: avoid rendering current-time strings from rewound midnight_utc during calculate_yesterday(). |
apps/predbat/futurerate.py |
Preserves host-naive cache timestamp compatibility. |
apps/predbat/fetch.py |
Uses configured timezone for date overrides and weekdays. |
apps/predbat/annual_tariff.py |
Updates clock-origin documentation. |
.cspell/custom-dictionary-workspace.txt |
Adds Kiritimati to the dictionary. |
Review details
Suppressed comments (3)
apps/predbat/predbat.py:291
calculate_yesterday()temporarily rewinds the sharedself.midnight_utc(output.py:3296), while inverter/component threads can callbase.time_now_str()(for example inverter.py:1917). This change makes a live notification render with yesterday's date during that window; the oldself.midnightsnapshot was not rewound. Render this current-time string from stableself.now_utc(or synchronize the rewind).
return (self.midnight_utc + timedelta(minutes=self.minutes_now)).strftime("%H:%M:%S")
apps/predbat/predheat.py:537
- Please add a regression test for this new
PredHeat.update_pred()clock calculation with a configured timezone different from the host. The existing Predheat test replacesupdate_predwith a lambda (apps/predbat/tests/test_predheat.py:31-34), so this changed code is never executed and a regression back to the host clock would still pass the suite.
# Measured against now_utc like the rest of Predbat, rather than a second naive clock
# taken from the host's timezone - see update_time() in predbat.py.
self.minutes_now = int((now_utc - self.midnight_utc).total_seconds() / 60 / PREDICT_STEP) * PREDICT_STEP
apps/predbat/tests/test_clock.py:93
- This comparison treats midnight as a discontinuity. If
update_time()runs at 23:59:xx, it roundsminutes_nowto 23:55, then the separatedatetime.now()can already be 00:00; the absolute difference is 1435 and the test fails even though the configured clock is correct. Compare the cyclic distance between the two minute values (or capture the wall clock before the update).
if abs(clock["minutes_now"] - wall_minutes) > PREDICT_STEP:
- Files reviewed: 15/15 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
No this is a new one from me to follow up the solcast time fix |
|
Makes sense, seen a few with related time stuff that implied a refactor might be good.
Sent from Yahoo Mail for iPhone
On Monday, September 14, 2026, 1:51 pm, Trefor Southwell ***@***.***> wrote:
springfall2008 left a comment (springfall2008/batpred#5084)
Hmm. That's not a pr I thought I'd pushed. Need to check what Claude has been up to.
No this is a new one from me to follow up the solcast time fix
—
Reply to this email directly, view it on GitHub, or unsubscribe.
You are receiving this because you were mentioned.Message ID: ***@***.***>
|
… flake Three of the four review findings held up: - teslemetry's _local_today_weekday() read base.now through getattr with a fallback, so deleting that field did not break it - it silently dropped to the live wall clock, losing clock_skew and any pinned clock. It reads base.now_utc now. The test doubles pinned base.now too, which is why the suite stayed green through the change; they are moved over, and a test pins the weekday against a fixed Thursday so a fallback fails on six days in seven (mutation-tested). - predheat duplicated update_time()'s calculation, and its tests stub update_pred out entirely, so the copy could drift back to the host clock with the suite green. Both callers now share utils.minutes_since_midnight(), which is tested directly - including both 2026 DST changeover days. - test_clock's wall-clock cross-check treated midnight as a discontinuity: at 23:59 update_time floors to 23:55 while the later read is already 00:00, five minutes apart but 1435 by subtraction. It compares the cyclic distance now. Not changed: the finding that time_now_str() would render yesterday's date during calculate_yesterday()'s rewind. time_now_str renders "%H:%M:%S" - there is no date in it - and calculate_yesterday fakes minutes_now to 0, so it rendered 00:00:00 both before and after this PR. The concurrency premise does not hold either: ComponentBase does not expose the time strings, and the cited caller (inverter.py:1917) runs on the main loop, as does load_axle_slot. Detail in the PR thread. ./run_all --quick green (314 tests), pre-commit clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Posted by Claude on behalf of @springfall2008. Worked through the Copilot review. Three findings held up and are fixed in 5f05735; one does not. 1. It reads through 2. PredHeat coverage gap — real. Confirmed 3. 4. Three separate reasons:
Worth noting the related effect that is real and intended:
|
…ked minutes_now The other half of GH#4804. springfall2008#5054 stopped components seeing the midnight_utc that calculate_yesterday() (output.py) rewinds; it fakes minutes_now to 0 in the same breath, and that passthrough was left alone because it could not be fixed safely at the time - base.minutes_now was measured from a different clock than now_utc, so deriving it would have made components disagree with the planner wherever the container's timezone differed from the timezone: setting. springfall2008#5084 removed that second clock, so the derivation is now exactly the calculation update_time() makes - through the same helper. 0 is the nastier of the two fakes: a rewound date is obviously wrong, while 0 reads as a legitimate "just after midnight". It reaches 39 component-side call sites - octopus.py's dispatch-slot arithmetic (which stops dropping expired slots and shifts every published offset), solcast.py's actual-PV alignment, load_ml_component.py's slot alignment, web.py's SOC history, and the AlphaESS/Deye/Sunsynk adapters that pick the live TOU slot by time of day while writing to a real inverter. The AlphaESS test doubles set base.minutes_now directly on a MagicMock base, so they answered arithmetic with more mocks rather than failing; they get a real clock and a set_mock_clock() helper that moves it. Mutation-tested: reverting the property fails the new ComponentBase test with "Faked base.minutes_now leaked into the component: 0". ./run_all --quick green (314 tests) under both a mismatched host timezone and TZ=Europe/London; pre-commit clean. Stacked on springfall2008#5084 - merge that first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_Posted by Claude on behalf of @springfall2008 _
Follow-up to #5054, which fixed
ComponentBase.midnight_utcreading acalculate_yesterday()-rewound value. Trying to giveminutes_nowthe same treatment surfaced why it could not simply be derived: it is measured from a different clock.Problem
update_time()kept two clocks:minutes_nowand thetime_abs_str/time_now_strrenderings came off the second one. They agree only while the container's timezone matches thetimezone:setting.When they do not:
minutes_nowindexes rate and forecast arrays that are keyed in minutes frommidnight_utc, so Predbat reads "now" at the wrong point in its own rates.time_abs_str(minute)rendersmidnight_utc-keyed offsets against a differently-offset midnight, so every timestamp it publishes - the plan table included - is shifted from the data it labels.A test run on a UTC+3 host with
timezone: Europe/Londonshows the two 120 minutes apart.Fix
Measure everything from
now_utc, and deleteself.now/self.midnight.now_utcand the midnight derived from it share a tzinfo (replace()keeps it), so the subtractions stay wall-clock differences - identical to the naive arithmetic they replace. Checked on both 2026 DST changeover days plus a control date.The one caller that genuinely needs the host clock is the scheduler in
initialize(): its run times are handed torun_every(), andhass.py'stimer_tickcompares them againstdatetime.now(). It now computes that locally rather than two instance fields existing for its sake.Also in scope, because they shared the fields:
minutes_since_yesterday()built its comparison point withdatetime.combine(), which drops tzinfo -replace()instead, same answer for the same wall clock.fetch.py'sdate:overrides are parsed naive from apps.yaml, so they takemidnight_utc's offset before the subtraction;day_of_weeknow reads the configured timezone's weekday.futurerate's cache-age arithmetic stays on the host clock - its stamps are host-naive and persisted to storage by earlier versions, so an upgrade must keep reading them. It just computes its own midnight rather than borrowing Predbat's.predheatcarried its own copy of the same split and gets the same treatment.Behaviour change
Confined to setups where the container timezone differs from the
timezone:setting: theirminutes_nowand rendered timestamps move to the configured timezone, which is the fix. Matched setups - the normal HA addon case - see nothing move.Testing
New
tests/test_clock.pypins a configured timezone that deliberately disagrees with the host (Pacific/Kiritimati, UTC+14, no DST). That disagreement is the only thing that makes the two clocks separable - on a machine where they agree, the old and new code are indistinguishable, which is why nothing caught this before.Mutation-tested: restoring the host clock fails the new tests (
minutes_now 930against a configured-timezone150) and nothing else../run_all --quickgreen (314 tests) under both a mismatched host timezone andTZ=Europe/London./run_pre_commitgreenFollow-up, not in this PR
now_utc/midnight_utcare misnamed - they are timezone-aware local time for the inverter system, never UTC. Worth renaming now that they are the only clock.🤖 Generated with Claude Code