Skip to content

refactor(clock): drop the host-timezone clock, measure everything from now_utc - #5084

Merged
springfall2008 merged 2 commits into
mainfrom
fix/unify-clock-on-tz-aware
Sep 14, 2026
Merged

springfall2008 merged 2 commits into
mainfrom
fix/unify-clock-on-tz-aware

Conversation

@springfall2008

@springfall2008 springfall2008 commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

_Posted by Claude on behalf of @springfall2008 _

Follow-up to #5054, which fixed ComponentBase.midnight_utc reading a calculate_yesterday()-rewound value. Trying to give minutes_now the same treatment surfaced why it could not simply be derived: it is measured from a different clock.

Problem

update_time() kept two clocks:

self.now_utc = datetime.now(self.local_tz)   # the configured `timezone:` setting → midnight_utc
self.now     = datetime.now()                # whatever timezone the container is in → midnight, minutes_now

minutes_now and the time_abs_str/time_now_str renderings came off the second one. They agree only while the container's timezone matches the timezone: setting.

When they do not:

  • minutes_now indexes rate and forecast arrays that are keyed in minutes from midnight_utc, so Predbat reads "now" at the wrong point in its own rates.
  • time_abs_str(minute) renders midnight_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/London shows the two 120 minutes apart.

Fix

Measure everything from now_utc, and delete self.now / self.midnight.

now_utc and 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 to run_every(), and hass.py's timer_tick compares them against datetime.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 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 - 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.
  • predheat carried 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: their minutes_now and 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.py pins 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 930 against a configured-timezone 150) and nothing else.

  • ./run_all --quick green (314 tests) under both a mismatched host timezone and TZ=Europe/London
  • ./run_pre_commit green

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.

🤖 Generated with Claude Code

…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>
Copilot AI lite review requested due to automatic review settings September 14, 2026 12:40
@chalfontchubby

Copy link
Copy Markdown
Collaborator

Hmm. That's not a pr I thought I'd pushed. Need to check what Claude has been up to.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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 shared self.midnight_utc (output.py:3296), while inverter/component threads can call base.time_now_str() (for example inverter.py:1917). This change makes a live notification render with yesterday's date during that window; the old self.midnight snapshot was not rewound. Render this current-time string from stable self.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 replaces update_pred with 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 rounds minutes_now to 23:55, then the separate datetime.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.

Comment thread apps/predbat/predbat.py
@springfall2008

Copy link
Copy Markdown
Owner Author

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

@chalfontchubby

chalfontchubby commented Sep 14, 2026 via email

Copy link
Copy Markdown
Collaborator

… 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>
@springfall2008

Copy link
Copy Markdown
Owner Author

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. TeslemetryAPI._local_today_weekday() read base.now — real, and the nastiest of the four.

It reads through getattr(base, "now", None) with a fallback, so deleting the field did not break it: it silently dropped to the live wall clock, losing clock_skew and any pinned clock. Worse, the test doubles pinned base.now as well, so the suite stayed green through exactly the change that broke it. Now reads base.now_utc, doubles moved over, and a new test pins the weekday against a fixed Thursday so a fallback fails on six days in seven. Mutation-tested: reverting gives Expected Thursday (3), got 0.

2. PredHeat coverage gap — real. Confirmed test_predheat.py replaces update_pred with a lambda, so the changed line never executed. Rather than add a heavyweight test around update_pred, both callers now share utils.minutes_since_midnight(), which is tested directly — including both 2026 DST changeover days. That also removes the duplicated formula that made drift possible.

3. test_clock.py midnight discontinuity — real flake. At 23:59 update_time() floors to 23:55 while the later read is already 00:00: five minutes apart, 1435 by subtraction. Compares cyclic distance now.

4. time_now_str() rendering yesterday's date during calculate_yesterday() — does not hold.

Three separate reasons:

  • time_now_str() renders "%H:%M:%S". There is no date in it, and midnight_utc - 1 day has the same time-of-day, so the rewind cannot change its output.
  • calculate_yesterday() fakes minutes_now to 0 for the same window, so that string rendered 00:00:00 both before and after this PR.
  • The concurrency premise is wrong: ComponentBase does not expose time_now_str/time_abs_str at all, so no component thread can reach them. The cited caller (inverter.py:1917) runs on the main update loop, and load_axle_slot — the only other base.time_abs_str caller outside the base mixins — is called from fetch.py on that same thread.

Worth noting the related effect that is real and intended: time_abs_str is now anchored to midnight_utc, so yesterday's plan HTML renders yesterday's dates. It previously rendered them against today's naive midnight, i.e. a day out.

./run_all --quick green (314 tests), pre-commit clean.

@springfall2008
springfall2008 merged commit 33d0ffd into main Sep 14, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/unify-clock-on-tz-aware branch September 14, 2026 13:37
Scholdan pushed a commit to Scholdan/batpred that referenced this pull request Sep 15, 2026
…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>
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.

3 participants