Computing Seasonal Rate Boundaries Across DST Transitions
A seasonal schedule is written in civil time — “the summer rate begins June 1 at local midnight” — but meter reads are classified by comparing instants, and twice a year the mapping from civil time to instant stops being a bijection. On the spring-forward night one hour of local wall-clock time never happens; on the fall-back night one hour happens twice. If the boundary conversion ignores this, a season edge that lands near a transition can be placed at the wrong instant, and reads in the repeated or missing hour get double-counted under two seasons or dropped from both. This page sits under seasonal rate mapping and calendar logic, part of the broader automated rate calculation and rule engines subsystem, and shows how to convert a civil season boundary to a single unambiguous UTC instant and classify every read against it — including the ones that fall in the hours DST bends.
The core discipline is a clean separation: store the boundary as a civil time plus an IANA zone, resolve it to exactly one UTC instant using zoneinfo fold semantics, and do all read classification in UTC. Local time is for humans and tariff orders; instants are for arithmetic. Mixing the two — comparing a naive local boundary against a UTC read — is what floats a season edge by the offset and, across a transition, floats it by an offset that itself changed mid-cycle.
Minimal Prerequisites
- Python 3.11+ — for
zoneinfoin the standard library (neverpytz, whoselocalize/normalizemodel works differently) and thefoldattribute ondatetime. - IANA timezone identifiers (e.g.
America/Denver), not fixed UTC offsets. A fixed offset cannot know when the transition happens, so it cannot resolve an ambiguous or missing local time. decimal.Decimalfor the rate each period carries. The DST math is all about instants, but the periods being selected holdDecimalprices that must pass through untouched.- Data assumption: seasonal boundaries arrive as civil
(year, month, day, hour, minute)tuples plus a zone id, typically local midnight on the first of a month. Meter reads arrive as timezone-aware instants (already normalized to UTC at ingestion, as covered in AMI/AMR feed synchronization).
Annotated Implementation
The function below takes a civil season boundary and its zone, resolves it to a single UTC instant, and returns a classifier that assigns any read to “before” or “on-or-after” the boundary using half-open [boundary, ...) semantics. The subtlety is entirely in _civil_to_instant: for a normal civil time the conversion is direct, but for the fall-back repeated hour fold selects which of the two occurrences the boundary means, and for the spring-forward missing hour the civil time never existed, so we snap forward to the first real instant on the far side of the gap rather than silently produce a nonexistent one.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
UTC = ZoneInfo("UTC")
def _civil_to_instant(
year: int, month: int, day: int, hour: int, minute: int,
zone: ZoneInfo, *, fold: int = 0,
) -> datetime:
"""Resolve a civil (wall-clock) time in `zone` to one UTC instant.
`fold=0` picks the FIRST occurrence of a repeated fall-back time,
`fold=1` the second. A spring-forward gap time is imaginary, so we
detect it and snap to the first real instant after the gap.
"""
local = datetime(year, month, day, hour, minute, tzinfo=zone, fold=fold)
# Spring-forward detection: an imaginary time round-trips to a
# DIFFERENT wall clock. utcoffset differs across the fold for a gap
# time, so the naive components won't survive UTC->local->UTC.
as_utc = local.astimezone(UTC)
if as_utc.astimezone(zone).replace(tzinfo=None) != local.replace(tzinfo=None):
# Imaginary time: advance to the first instant that actually exists.
# Step forward minute by minute past the gap (gaps are <= 1 hour).
probe = local
for _ in range(60):
probe = probe + timedelta(minutes=1)
candidate = probe.astimezone(UTC)
if candidate.astimezone(zone).replace(tzinfo=None) == probe.replace(tzinfo=None):
return candidate
return as_utc
@dataclass(frozen=True)
class SeasonBoundary:
label: str
instant_utc: datetime # the single resolved edge, in UTC
rate_after: Decimal # price in force at/after the boundary
def classify(self, read_utc: datetime) -> str:
# Half-open: the boundary instant itself belongs to the AFTER side.
return "after" if read_utc >= self.instant_utc else "before"
def build_boundary(
label: str, civil: tuple[int, int, int, int, int],
zone_id: str, rate_after: str, *, fold: int = 0,
) -> SeasonBoundary:
zone = ZoneInfo(zone_id)
instant = _civil_to_instant(*civil, zone=zone, fold=fold)
return SeasonBoundary(label=label, instant_utc=instant, rate_after=Decimal(rate_after))
The round-trip check is the linchpin. A valid civil time, converted to UTC and back to local, reproduces the same wall-clock components; an imaginary spring-forward time does not, because the zone has no offset that maps it to a real instant. Detecting that mismatch lets the code make a deliberate choice — advance to the first real instant — instead of accepting whatever astimezone happened to return. For the fall-back repeated hour there is no mismatch (both occurrences are real), so fold is the only lever: fold=0 binds the boundary to the earlier instant (still in daylight time), fold=1 to the later one (in standard time). A season boundary set at local midnight almost never collides with a 2 a.m. transition, but a boundary set at 02:00 on the transition day does, and choosing the fold explicitly is what keeps that edge single-valued.
Edge Cases and Billing Gotchas
DST is where seasonal boundary logic quietly produces bills that cannot be reconciled. Each of these has a specific, deterministic handling.
- Fall-back repeated hour (fold). When the clock falls back, a read timestamped
01:30local is ambiguous — it names two distinct instants an hour apart, one in daylight time and one in standard time. If the feed carries the offset, honor it; if it carries only naive local time, the ingestion layer must decide the fold policy before classification, becausefold=0andfold=1can land on opposite sides of a season boundary set inside that hour. Never let two occurrences of the same wall-clock read both count. - Spring-forward missing hour. A boundary or a read expressed as
02:30on the spring-forward day refers to a civil time that never occurred._civil_to_instantdetects the round-trip mismatch and snaps forward to the first real instant, so the edge is placed deterministically at03:00local rather than at whatever an uncheckedastimezonereturns. A read claiming a missing-hour timestamp is itself a data-quality signal worth flagging. - Boundary at midnight on a DST-transition day. In a small number of zones the transition happens at midnight rather than 2 a.m., so a season boundary conventionally set at “local midnight” can itself land in a gap or a repeat. Because the boundary is resolved through the same
_civil_to_instantpath as any other civil time, this case is handled without special-casing — the round-trip check and fold apply uniformly. - Year-spanning winter season. A winter period defined “October 1 to June 1” crosses both the fall-back and spring-forward transitions within one season. Because classification is done entirely in UTC after each civil edge is resolved once, the season spans the transitions transparently — the instants are monotonic even though the local offsets are not. Do not re-derive the boundary per read; resolve it once and compare instants.
- Reusing the boundary across years. DST transition dates move year to year, so a boundary instant computed for 2026 is not valid for 2027. Resolve
_civil_to_instantper billing year from the civil spec plus that year’s zone rules; caching a raw UTC instant and reusing it across years reintroduces the very offset drift this logic exists to prevent.
Verification Snippet
Exercise both transitions and the ordinary midnight case. America/Denver falls back on 2026-11-01 and springs forward on 2026-03-08, both at 2 a.m. local.
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
DENVER = ZoneInfo("America/Denver")
UTC = ZoneInfo("UTC")
def test_midnight_boundary_resolves_to_single_instant() -> None:
# Summer season starts June 1 local midnight — well clear of any transition.
b = build_boundary("summer", (2026, 6, 1, 0, 0), "America/Denver", "0.0142")
assert b.instant_utc == datetime(2026, 6, 1, 6, 0, tzinfo=UTC) # MDT = UTC-6
just_before = datetime(2026, 6, 1, 5, 59, tzinfo=UTC)
on_edge = datetime(2026, 6, 1, 6, 0, tzinfo=UTC)
assert b.classify(just_before) == "before"
assert b.classify(on_edge) == "after" # half-open: edge is AFTER
def test_fall_back_fold_selects_distinct_instants() -> None:
early = _civil_to_instant(2026, 11, 1, 1, 30, zone=DENVER, fold=0)
late = _civil_to_instant(2026, 11, 1, 1, 30, zone=DENVER, fold=1)
# The same wall clock names two instants exactly one hour apart.
assert late - early == timedelta(hours=1)
def test_spring_forward_gap_snaps_forward() -> None:
# 02:30 on 2026-03-08 never exists in Denver; expect the first real instant.
resolved = _civil_to_instant(2026, 3, 8, 2, 30, zone=DENVER, fold=0)
local = resolved.astimezone(DENVER)
assert (local.hour, local.minute) == (3, 0) # snapped to 03:00 MDT
# And it is a real, round-trippable instant.
assert local.astimezone(UTC).astimezone(DENVER) == local
The three tests cover the three regimes the boundary math must survive: the ordinary case that must stay boring, the fall-back hour that must resolve to distinct instants, and the spring-forward gap that must snap deterministically instead of returning an imaginary time.
FAQ
Related Topics
- Seasonal Rate Mapping & Calendar Logic — the parent guide covering period modeling and resolution end to end.
- Interval Tree vs Sorted List for Seasonal Rate Lookup — choosing the structure that compares against the instants resolved here.
- Automated Rate Calculation & Rule Engines — the subsystem this boundary math feeds.
- Proration & Billing Period Calculation — splitting a cycle at the resolved boundary instant.
- AMI/AMR Feed Synchronization Protocols — where reads are normalized to UTC instants before classification.
Up: Seasonal Rate Mapping & Calendar Logic · Automated Rate Calculation & Rule Engines