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 zoneinfo in the standard library (never pytz, whose localize/normalize model works differently) and the fold attribute on datetime.
  • 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.Decimal for the rate each period carries. The DST math is all about instants, but the periods being selected hold Decimal prices 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.

A season boundary and reads plotted along a timeline across DST transitions Two civil-time timelines show how daylight saving bends the mapping to instants. On the fall-back timeline the local hour from one to two a.m. occurs twice, an earlier fold zero occurrence in daylight time and a later fold one occurrence in standard time; a read in that repeated hour must be pinned to one occurrence by its fold so it is classified under exactly one season and never double-counted. On the spring-forward timeline the local hour from two to three a.m. never happens, so a boundary or read expressed in the missing hour is snapped forward to the first real instant after the gap rather than dropped. A separate marker shows a season boundary set at local midnight, well clear of the two a.m. transitions, resolving cleanly to a single UTC instant. Fall-back night: the 1–2 a.m. hour occurs twice 00:30 01:30 (fold 0) 01:30 (fold 1) 02:30 repeated local hour pin read to one fold → one season fold=0 earlier instant · fold=1 later instant Spring-forward night: the 2–3 a.m. hour never happens 01:30 02:30 (imaginary) 03:30 missing local hour snap forward to first real instant boundary at local midnight clear of 2 a.m. → one UTC instant the common, safe case

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:30 local 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, because fold=0 and fold=1 can 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:30 on the spring-forward day refers to a civil time that never occurred. _civil_to_instant detects the round-trip mismatch and snaps forward to the first real instant, so the edge is placed deterministically at 03:00 local rather than at whatever an unchecked astimezone returns. 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_instant path 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_instant per 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

Why not just store the season boundary directly as a UTC instant?
Because the tariff order defines the boundary in civil time — local midnight on a calendar date — and DST transition dates move every year, so a UTC instant correct for one year is wrong for the next. Storing the civil spec plus the IANA zone and resolving to an instant per billing year keeps the boundary faithful to what the commission actually approved and immune to offset drift.
What is the fold attribute and when does it matter?
fold disambiguates a wall-clock time that occurs twice during the fall-back transition. fold=0 names the first occurrence (still in daylight time), fold=1 the second (in standard time), and they resolve to instants an hour apart. It matters only for reads or boundaries inside the repeated hour; a boundary at local midnight never touches it. When it does apply, the ingestion layer must set the fold policy before classification so a read is counted under exactly one season.
What happens to a read timestamped in the spring-forward missing hour?
That civil time never existed, so it is a data-quality signal. The conversion detects the round-trip mismatch and snaps forward to the first real instant after the gap, giving a deterministic classification rather than an imaginary datetime. Flag such reads for review, because a feed asserting a nonexistent local time usually indicates a clock or timezone-tagging fault upstream.
Do I compare reads in local time or UTC?
Always in UTC. Resolve each civil boundary to a single UTC instant once, then classify every read by comparing instants. Comparing in local time reintroduces the offset ambiguity across transitions and makes a year-spanning winter season, which crosses both transitions, behave inconsistently. Local time is for expressing the boundary; instants are for the arithmetic.
How does a winter season that crosses both transitions stay correct?
Resolve its start and end civil edges to UTC instants once, then classify all reads in UTC. UTC instants advance monotonically even though the local offset changes twice within the season, so the season spans both the fall-back and spring-forward nights transparently. The mistake to avoid is re-deriving the boundary per read in local time, which lets the shifting offset corrupt the edge.

Up: Seasonal Rate Mapping & Calendar Logic · Automated Rate Calculation & Rule Engines