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.
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
datetimeobjects to get shift duration.datetime(2026, 11, 1, 6, 0) - datetime(2026, 10, 31, 22, 0)returns exactlytimedelta(hours=8)regardless of DST — it is wrong on the two transition nights and silently wrong, because it never raises. - Defaulting
foldand assuming it doesn’t matter.fold=0is the implicit default; on a fall-back night, the second (correct, post-transition) occurrence needsfold=1explicitly, 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 |
Related Permalink to this section
- Shift Boundary Logic — the materialized shift calendar and half-open interval slicing this page extends.
- Correcting Timezone Shifts Across Global Plants — ensures telemetry arrives UTC-stamped before any of this local-time logic runs.
- OEE vs TEEP vs OOE: Which Effectiveness Metric to Report — correct shift-elapsed time is what Planned Production Time and Utilization are built from.
- Reconciling OEE with MES Production Counts — a mismatched midnight-attribution convention is a common source of the count deltas this reconciliation resolves.