Skip to content

Handling Shift Handovers Across Midnight and DST

Line 9’s palletizer at a packaging plant in Youngstown, Ohio runs a 22:00–06:00 night shift, and twice a year that shift’s wall-clock arithmetic breaks in a different way: on the November fall-back Sunday it appears to run nine hours instead of eight, and on the March spring-forward Sunday it appears to run seven. Neither is a data error — both are the correct elapsed time for a shift that happens to straddle a DST transition — but a report built on naive local-time subtraction gets both wrong, and a report that also needs to attribute output to a single calendar business date has a second problem layered on top: the shift genuinely spans two dates. This page is the operational companion to shift boundary logic: it works through midnight-crossing calendar attribution, the two distinct DST failure modes, and how to split a downtime event that straddles either seam without losing or duplicating a second of it.

Fall-back night shift: one business date, a repeated local hour, and a fault resolved by fold A night shift from 22:00 October 31 to 06:00 November 1 America/New_York crosses the fall-back transition at 02:00 EDT, so 01:00 to 02:00 occurs twice on the wall clock but once each in EDT and EST. The whole shift is attributed to the October 31 business date. A downtime fault inside the repeated hour is disambiguated by the fold attribute into two distinct UTC instants, and the shift elapses 9 hours instead of the standard 8. Night shift, America/New_York — Oct 31 22:00 to Nov 1 06:00 wall-clock half-open segments · fall-back transition at 02:00 EDT / 01:00 EST 22:00–01:00 EDT fold=0, UTC-4 01:00–02:00 (twice) fold 0 then fold 1 01:00–06:00 EST fold=1, UTC-5 repeated local hour — two distinct UTC instants 01:20 f0 01:20 f1 05:20:00Z 06:20:00Z 3,600 s apart — fault duration resolves to 20 min, not 0 or negative attributed to business_date = Oct 31 shift elapses 9h (32,400 s), not the standard 8h
The whole shift is attributed to a single business date (its start), while the wall clock itself repeats an hour; the fold attribute is what lets two same-looking local timestamps resolve to two distinct, correctly ordered UTC instants.

1. Attributing a midnight-crossing shift to one business date Permalink to this section

A shift that starts before midnight and ends after it must still roll up as a single unit of production for reporting, payroll, and crew scorecards — splitting it at midnight would fragment one crew’s output across two “days” for no operational reason. The convention this pipeline uses, matching the materializer in shift boundary logic, is that a shift’s business date is the calendar date of its start instant in local time, regardless of how far past midnight it runs.

from __future__ import annotations

from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo

FACILITY_TZ = ZoneInfo("America/New_York")


@dataclass(frozen=True)
class NightShift:
    business_date: date       # the date this shift's output is attributed to
    start_utc: datetime       # tz-aware, resolved from 22:00 local on business_date
    end_utc: datetime         # tz-aware, resolved from 06:00 local the next day


def resolve_night_shift(business_date: date) -> NightShift:
    """Resolve a 22:00-06:00 night shift to UTC instants for one business date.

    The shift is always attributed to `business_date`, the calendar day on
    which it started, even though the end instant falls on the next date.
    """
    local_start = datetime.combine(business_date, time(22, 0), tzinfo=FACILITY_TZ)
    local_end = datetime.combine(
        business_date + timedelta(days=1), time(6, 0), tzinfo=FACILITY_TZ
    )
    return NightShift(
        business_date=business_date,
        start_utc=local_start.astimezone(ZoneInfo("UTC")),
        end_utc=local_end.astimezone(ZoneInfo("UTC")),
    )


def business_date_for_event(event_ts_utc: datetime, shifts: list[NightShift]) -> date | None:
    """Look up which shift's business date an event belongs to, by UTC instant."""
    for shift in shifts:
        if shift.start_utc <= event_ts_utc < shift.end_utc:  # half-open
            return shift.business_date
    return None

datetime.combine(..., tzinfo=FACILITY_TZ) is the load-bearing call: zoneinfo.ZoneInfo implements PEP 495, so attaching it directly at construction lets Python’s C implementation resolve the UTC offset for that specific date, including whether DST is in effect — there is no separate “look up the offset” step to get wrong.

2. Spring-forward and fall-back inside the shift window Permalink to this section

The two DST transitions break the same shift in opposite ways, and treating them identically is the most common bug in hand-rolled shift-duration code.

Spring-forward (nonexistent local time). On March 8, 2026, America/New_York’s clocks jump from 01:59:59 EST directly to 03:00:00 EDT. A night shift running 22:00–06:00 that night elapses only 7 hours of real time, because the 02:00–03:00 wall-clock hour never occurs. If a shift boundary or a downtime event happens to be defined at a local time inside that gap (rare, but possible for a mid-shift maintenance window scheduled at “02:15”), naively localizing it either raises or silently normalizes to an arbitrary side of the gap.

Fall-back (ambiguous local time). On November 1, 2026, clocks fall from 01:59:59 EDT back to 01:00:00 EST, so every wall-clock time between 01:00 and 02:00 occurs twice. A night shift elapses 9 hours of real time. Python’s fold attribute (PEP 495) disambiguates: fold=0 selects the first occurrence (still EDT, UTC-4), fold=1 the second (already EST, UTC-5). Without an explicit fold, zoneinfo defaults to fold=0, which silently picks the first occurrence even when the correct one is the second — a defect that only shows up twice a year and is easy to ship unnoticed.

from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

NY = ZoneInfo("America/New_York")
UTC = ZoneInfo("UTC")


def shift_elapsed_seconds(business_date, start_time, duration: timedelta) -> int:
    """Correct elapsed seconds for a shift, accounting for DST gaps/folds.

    Localizing the naive start and (start + duration) independently and
    diffing the resulting UTC instants is what makes this correct: the
    wall-clock arithmetic never has to know a transition happened.
    """
    local_start = datetime.combine(business_date, start_time, tzinfo=NY)
    naive_end = datetime.combine(business_date, start_time) + duration
    local_end = naive_end.replace(tzinfo=NY)
    return int((local_end.astimezone(UTC) - local_start.astimezone(UTC)).total_seconds())


def resolve_ambiguous_local_time(
    business_date, local_time_naive, *, prefer_second_occurrence: bool
) -> datetime:
    """Resolve a local time that might be ambiguous (fall-back) to one UTC instant.

    Use when a downtime event or maintenance window is logged with only a
    local timestamp and no fold hint from the source system — the caller
    must supply a policy (plant procedure: night crews log the SECOND
    occurrence as "01:xx (post-fallback)").
    """
    naive = datetime.combine(business_date, local_time_naive)
    fold = 1 if prefer_second_occurrence else 0
    aware = naive.replace(tzinfo=NY, fold=fold)
    # Confirm the fold actually changed the UTC offset; if not, the time
    # was never ambiguous and the fold argument was a no-op, which is fine.
    return aware.astimezone(UTC)

shift_elapsed_seconds deliberately never subtracts naive local times or hardcodes a shift length; it always converts both endpoints to UTC first and diffs there, so it is correct on all 363 ordinary nights and the two DST nights without a special case in the caller.

3. Splitting a downtime event across the boundary Permalink to this section

PLC and edge-gateway telemetry should already carry UTC timestamps — that discipline is covered in correcting timezone shifts across global plants — so most downtime events never need local-time math at all. The case that does need it is a fault whose reporting must distinguish the two sides of a boundary: either the shift handover itself (06:00, when Line 9 passes to the day crew) or, rarer but real, a maintenance log entry written by hand during the repeated fall-back hour that needs to be pinned to the correct occurrence before it can be matched against the sensor stream.

from dataclasses import dataclass, replace
from datetime import datetime


@dataclass
class DowntimeEvent:
    asset_id: str
    category: str
    start_utc: datetime
    end_utc: datetime


def split_at_instant(event: DowntimeEvent, boundary_utc: datetime) -> list[DowntimeEvent]:
    """Split a downtime event at a single UTC boundary instant (shift handover
    or a DST fold instant already resolved to UTC).

    Guarantees: the returned segments' durations sum exactly to the
    original event's duration; a boundary outside the event's span
    returns the event unchanged.
    """
    if boundary_utc <= event.start_utc or boundary_utc >= event.end_utc:
        return [event]  # boundary does not intersect this event

    before = replace(event, end_utc=boundary_utc)
    after = replace(event, start_utc=boundary_utc)
    assert (before.end_utc - before.start_utc) + (after.end_utc - after.start_utc) \
        == (event.end_utc - event.start_utc)  # no leakage across the split
    return [before, after]

Because the function operates purely on UTC instants, it is identical whether the boundary is an ordinary shift handover, a spring-forward gap edge, or a fall-back fold instant — once every timestamp has been converted to UTC, a DST transition is no longer a special case, just another point on the same monotonic timeline. A fault logged 23:40–00:20 that straddles midnight but stays entirely inside one night shift is not split by this function — it correctly stays whole, because business_date_for_event from section 1 already attributes the whole interval to one shift regardless of which calendar date each half falls on. Only a fault crossing an actual shift or crew boundary gets split.

Gotchas & anti-patterns Permalink to this section

  • Subtracting naive local datetime objects to get shift duration. datetime(2026, 11, 1, 6, 0) - datetime(2026, 10, 31, 22, 0) returns exactly timedelta(hours=8) regardless of DST — it is wrong on the two transition nights and silently wrong, because it never raises.
  • Defaulting fold and assuming it doesn’t matter. fold=0 is the implicit default; on a fall-back night, the second (correct, post-transition) occurrence needs fold=1 explicitly, or every ambiguous timestamp resolves to the wrong UTC-4 offset instead of UTC-5.
  • Attributing a night shift’s output by its end date instead of its start date. Either convention can work, but it must be applied consistently everywhere — mixing conventions between the sensor pipeline and the MES export produces the same phantom per-shift deltas covered in reconciling OEE with MES production counts.
  • Assuming every timezone observes DST. Facilities in Arizona or Saskatchewan never shift; a shift-duration function hardcoded to expect a 7-or-9-hour anomaly twice a year will be wrong for those plants every single night if it isn’t driven by the IANA zone’s actual transition table.
  • Splitting every midnight-crossing event. Only split at an actual shift, crew, or reporting boundary. Splitting every event that happens to cross a calendar date needlessly fragments short downtime intervals below classification thresholds covered in threshold tuning for microstops.

Quick reference Permalink to this section

Case Rule
Shift crosses midnight, no DST involved Attribute to the business date of the shift’s start instant; never split at midnight
Spring-forward inside the shift window Convert both endpoints to UTC before diffing; never subtract naive local times
Fall-back inside the shift window Resolve the ambiguous local hour with an explicit fold (0 = first/EDT, 1 = second/EST)
Event straddles the actual shift/crew handover Split at the UTC boundary instant; segment durations must sum to the original
Event entirely inside one shift but crosses midnight Do not split; attribute the whole interval to that shift’s business date
Facility timezone never observes DST Do not special-case elapsed time; let zoneinfo return the fixed offset for every date