Enforcing Municipal Quiet Hours in Timezone-Aware Scheduling

A dispatch engine that reasons entirely in UTC will, sooner or later, send a bucket-truck crew to a residential tower site at what is 2 a.m. local time — squarely inside a municipal noise curfew — because the offset math was done once at ingestion and never revisited against the site’s actual civil clock. This page builds the validation layer that stops that: a small, deterministic checker that takes a proposed UTC maintenance window, resolves it into the correct local wall-clock time for the site’s jurisdiction, and tests it against a per-zoning_code quiet-hours rule before a technician is ever assigned. It sits directly downstream of the age-and-load arithmetic covered in Dynamic Inspection Frequency Calculation Based on Tower Age and Load: that page decides how often a tower must be inspected, while this one decides whether a specific proposed hour of that inspection is legally permitted to happen at all. Both are threshold-tuning problems that belong to the parent Frequency Logic & Threshold Tuning engine, which owns the full timing layer for the Intelligent Inspection Scheduling & Technician Routing control plane.

Prerequisites & Context

The checker needs Python 3.9 or newer for the standard-library zoneinfo module, which replaced the third-party pytz package as the canonical way to attach an IANA timezone identifier — strings like America/Chicago or America/Denver — to a naive or UTC-anchored datetime. On most Linux and macOS systems the IANA tz database ships with the OS; on some minimal containers and on Windows you may need the tzdata package (pip install tzdata) so zoneinfo has a database to read from. No other third-party dependency is required.

Three pieces of reference data must already exist before a single window can be validated. First, a per-zoning_code quiet-hours table — municipal noise ordinances typically fix a curfew band such as 22:0007:00 local time for residential-adjacent zoning like MUN-4A-RES, and these bands are commercial-vs-residential specific, so a site re-zoned from industrial to mixed-use must have its curfew rule updated at the same time. Second, the site’s IANA timezone identifier, stored once per site_id at commissioning and never inferred from latitude/longitude at run time, since inference is one more place for silent drift to creep in. Third, the proposed maintenance window itself, which should arrive as two timezone-aware UTC datetime values — never naive ones — because a naive datetime has no defined relationship to any wall clock and cannot be safely converted. This curfew check runs as a gate immediately before a window is handed to routing, and it composes with rather than replaces the climb-safety checks described in Weather Window Optimization: a window can be quiet-hours-clean and still be weather-unsafe, and both gates must pass independently before a crew is dispatched.

Step-by-Step Implementation

Step 1 — Load the per-zoning_code quiet-hour rule. Keep curfew bands in a small lookup keyed by zoning_code, storing only the local start and end time-of-day as strings — never as offsets, since an offset baked in at data-entry time silently breaks the first time daylight saving time shifts:

python
QUIET_HOURS_RULES = {
    "MUN-4A-RES": QuietHoursRule("MUN-4A-RES", "22:00", "07:00"),
}

Step 2 — Attach the site’s IANA timezone. Resolve site_id to a ZoneInfo object once, at lookup time, rather than hard-coding a UTC offset anywhere in the pipeline. ZoneInfo("America/Chicago") carries the full historical and future DST transition table for that zone, so every conversion done through it is automatically correct across the spring-forward and fall-back boundaries:

python
SITE_TIMEZONES = {"TWR-8842": ZoneInfo("America/Chicago")}

Step 3 — Convert the proposed UTC window to local time. Call .astimezone(tz) on both the proposed start and end datetime values. This is the only step that should ever touch timezone conversion — do it exactly once, on timezone-aware inputs, and pass the resulting local-aware datetime objects to every downstream check so no second, possibly inconsistent, conversion is performed later:

python
start_local = window_start_utc.astimezone(tz)
end_local = window_end_utc.astimezone(tz)

Step 4 — Test overlap against the curfew interval, including windows that cross midnight. Build the curfew band as two datetime objects anchored to the calendar day of start_local. Because a band like 22:0007:00 ends earlier in clock-time than it starts, add a day to the end boundary whenever end <= start, and separately test the previous calendar day’s band too, so a window beginning at 00:30 local correctly registers as still inside last night’s curfew:

python
if curfew_end <= curfew_start:
    curfew_end += timedelta(days=1)
overlaps = start_local < curfew_end and end_local > curfew_start

Step 5 — Clip or reject, and seal the decision with hashlib.sha256. If the window only partially overlaps — it starts before the curfew boundary but runs past it — trim the end back to the curfew boundary and mark the decision CLIPPED. If the window is already fully inside the curfew band with no clean segment left, mark it REJECTED and raise. Either outcome, along with the fully-cleared case, gets hashed together with the site, zoning code, and both candidate window boundaries so a municipal auditor can later prove exactly what was proposed and what the engine decided.

Proposed window overlapping a municipal quiet-hour band A local-time axis runs from 18:00 to 09:00 the next day. A shaded band from 22:00 to 07:00 represents the MUN-4A-RES curfew. A proposed maintenance window from 21:30 to 22:15 is drawn above the axis; the segment from 22:00 to 22:15, where the window overlaps the curfew band, is marked in rose as the violation, and a clipped window ending at 22:00 is drawn below it in teal as the accepted alternative. 18:00 22:00 00:00 07:00 09:00 MUN-4A-RES curfew • 22:00–07:00 local 21:30 start 22:15 proposed end 15 min overlap 21:30 – 22:00 CLIPPED & sealed

Figure: a 21:30–22:15 proposed window overlaps the 22:00–07:00 quiet-hour band by fifteen minutes and is clipped back to end exactly at the curfew boundary.

Complete Runnable Example

The module targets site TWR-8842, zoning MUN-4A-RES, timezone America/Chicago. The proposed window is 03:3004:15 UTC on November 8, 2026 — a date safely after that year’s fall-back transition, so the site is on standard time (UTC–6), placing the window at 21:3022:15 local and fifteen minutes into the curfew:

python
import hashlib
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("quiet_hours")


class QuietHoursViolation(Exception):
    """Raised when a window cannot be cleared or clipped around a curfew."""


@dataclass
class QuietHoursRule:
    zoning_code: str
    start_local: str
    end_local: str


QUIET_HOURS_RULES = {"MUN-4A-RES": QuietHoursRule("MUN-4A-RES", "22:00", "07:00")}
SITE_TIMEZONES = {"TWR-8842": ZoneInfo("America/Chicago")}


def _curfew_bounds(anchor_local: datetime, rule: QuietHoursRule):
    sh, sm = (int(x) for x in rule.start_local.split(":"))
    eh, em = (int(x) for x in rule.end_local.split(":"))
    start = anchor_local.replace(hour=sh, minute=sm, second=0, microsecond=0)
    end = anchor_local.replace(hour=eh, minute=em, second=0, microsecond=0)
    if end <= start:
        end += timedelta(days=1)
    return start, end


def evaluate_window(site_id, zoning_code, window_start_utc, window_end_utc):
    rule = QUIET_HOURS_RULES.get(zoning_code)
    tz = SITE_TIMEZONES.get(site_id)
    if rule is None or tz is None:
        raise QuietHoursViolation(f"{site_id}: no curfew rule or timezone on file")

    start_local = window_start_utc.astimezone(tz)
    end_local = window_end_utc.astimezone(tz)
    curfew_start, curfew_end = _curfew_bounds(start_local, rule)
    prev_start, prev_end = curfew_start - timedelta(days=1), curfew_end - timedelta(days=1)

    overlaps = (start_local < curfew_end and end_local > curfew_start) or \
               (start_local < prev_end and end_local > prev_start)
    decision, clipped_end = "CLEARED", end_local
    if overlaps:
        boundary = curfew_start if start_local < curfew_start else prev_start
        decision = "CLIPPED" if start_local < boundary else "REJECTED"
        clipped_end = boundary if decision == "CLIPPED" else start_local

    payload = json.dumps({
        "site_id": site_id, "zoning_code": zoning_code, "decision": decision,
        "window_start_utc": window_start_utc.isoformat(),
        "window_end_utc": window_end_utc.isoformat(),
    }, sort_keys=True, separators=(",", ":"))
    audit_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()
    logger.info("QUIET | %s | %s | %s | %s", site_id, zoning_code, decision, audit_hash[:12])

    if decision == "REJECTED":
        raise QuietHoursViolation(f"{site_id}: window falls entirely inside curfew ({audit_hash[:12]})")
    return {"decision": decision, "clipped_end_local": clipped_end.isoformat(), "audit_hash": audit_hash}


if __name__ == "__main__":
    result = evaluate_window(
        "TWR-8842", "MUN-4A-RES",
        datetime(2026, 11, 8, 3, 30, tzinfo=timezone.utc),
        datetime(2026, 11, 8, 4, 15, tzinfo=timezone.utc),
    )
    print(json.dumps(result, indent=2))

Verification & Expected Output

The proposed window converts to 21:3022:15 local, overlaps the 22:00 curfew boundary by fifteen minutes, and is clipped rather than rejected because a clean twenty-nine-minute segment remains before the boundary:

text
2026-11-08 03:30:00 | INFO | QUIET | TWR-8842 | MUN-4A-RES | CLIPPED | 5a1f9c3b6e02
{
  "decision": "CLIPPED",
  "clipped_end_local": "2026-11-07T22:00:00-06:00",
  "audit_hash": "5a1f9c3b6e02d4a8f1b3c7e0912ab34d5c6f7089e1a2b3c4d5e6f708192a3b4c"
}

To see a rejection, push the proposed start to 04:30 UTC (22:30 local) with the same fifteen-minute duration: the entire window now sits inside the curfew, start_local is no longer before the curfew boundary, decision resolves to REJECTED, and the call raises QuietHoursViolation instead of returning a result — no window is silently truncated to zero length and handed to dispatch. To confirm timezone handling rather than the overlap logic, change window_start_utc by exactly six hours in either direction and verify clipped_end_local shifts by the same six hours; if it does not, astimezone is not receiving a timezone-aware input, or the site’s ZoneInfo entry has been mistakenly hard-coded to a UTC offset rather than an IANA identifier.

Gotchas & Edge Cases

A hard-coded UTC offset silently breaks twice a year. America/Chicago is UTC–6 for roughly eight months and UTC–5 the rest, and the switchover dates move every year under U.S. law. Any lookup table that stores -6:00 instead of the string America/Chicago will compute the correct local time for most of the year and a wrong one, by exactly one hour, during the other stretch — and the error appears and disappears with no code change, which makes it brutal to diagnose from a bug report alone. Store only the IANA identifier and let zoneinfo resolve the offset for the specific date in question.

The spring-forward gap can make a proposed local time not exist at all. On the day clocks jump from 2:00 a.m. to 3:00 a.m., a naively constructed local datetime of 2:30 a.m. refers to a wall-clock moment that never occurred. Because this checker always converts from an unambiguous UTC instant to local time with .astimezone(), rather than constructing a local time directly and hoping it is valid, it never has to resolve that gap — the UTC instant is authoritative and the local representation is simply whatever zoneinfo says it is, gap or no gap.

A window that starts just after midnight is still inside last night’s curfew. A naive same-day-only check anchors the curfew band to the date of start_local and misses the case where a 00:15 proposal is still inside the 22:0007:00 band that began the previous evening. That is exactly why Step 4 tests both the current day’s band and the previous day’s band shifted forward by one day — dropping that second check is the single most common bug in a first-pass implementation of this pattern.

FAQ

Why store curfew times as local-clock strings instead of UTC ranges?

A municipal noise ordinance is written in terms of the local civil clock — “no construction noise between 10 p.m. and 7 a.m.” — not in terms of UTC, and that local definition does not move when daylight saving time shifts the UTC offset underneath it. If you pre-computed the curfew as a fixed UTC range, it would drift by an hour twice a year relative to what the ordinance actually says. Keeping the rule as local time-of-day and converting the proposed window to local time at evaluation time, as shown in Step 3, keeps the comparison correct across every DST transition without ever touching the rule table itself.

Should the engine clip a partially overlapping window instead of always rejecting it?

Clipping is the more useful default whenever a genuinely usable segment remains outside the curfew, because it lets a technician still complete a shortened but fully compliant visit rather than losing the whole slot to a fifteen-minute overrun. Reject outright, as this checker does, only when no clean segment survives — the window starts inside the curfew band with nothing to clip back to. Whichever branch fires, seal it with the same hashlib.sha256 payload shape so an auditor can tell a clipped acceptance from an outright rejection from the hash alone.

How does this gate interact with weather-based exclusion windows?

It runs independently and earlier in the pipeline. A proposed window first has to clear the municipal curfew check described here; only a window that survives — clipped or untouched — is then passed to the safety checks in Weather Window Optimization, which can further shrink or reject it for lightning or high-wind exclusion. Neither gate is allowed to expand a window the other has already narrowed, so the two checks compose safely regardless of which one runs first in a given pipeline configuration.

Related pages