Automating 10-Hour Rest Period Validation
The exact task this guide solves is narrow but unforgiving: given a completed duty and the next report time for the same crew member, decide — deterministically, in the correct time zone, and with an audit trail a regulator will accept — whether the gap between them satisfies the 10-hour minimum rest. Manual tracking across dynamic schedules, multi-timezone operations, and shifting duty definitions consistently introduces compliance risk, because the “10 hours” a spreadsheet subtracts is rarely the 10 hours the regulation means. This page turns that check into a repeatable pipeline: parse crew duty logs, normalise every timestamp, compute the rest window with timezone-aware arithmetic, apply a configurable threshold, and flag violations with a reproducible record. It applies the Rest Period Compliance Checks patterns from the broader Duty Time Validation & Rule Engines domain, assumes duty records arrive normalised through your flight data ingestion pipeline, and anchors the definitions of report, block-in, and off-duty to the shared crew duty time taxonomy.
Prerequisites
Before building the validator, confirm the following are in place:
- Python 3.11+ — for timezone-aware
datetimeand the standard-libraryzoneinfomodule (no third-party timezone dependency required). - Current IANA time zone database — so local rest-window calculations resolve daylight-saving transitions correctly; on minimal Linux images install
tzdata. - UTC-normalised duty logs — every inbound timestamp already carries an explicit offset; naive local times are rejected at ingestion.
- Regulatory rule version pinned — the current eCFR text of 14 CFR § 117.25 (US) and the ORO.FTL.235 wording (EASA); record the amendment date you validated against.
- A facility-to-zone resolver — a lookup mapping airport ICAO/IATA codes to IANA zones, so the rest location’s local clock is always recoverable.
- Threshold configuration source — the base minimum and any contractual buffer stored as data, not hardcoded constants.
Regulatory baseline: what “10 hours” actually means
The 10-hour minimum is a regulatory floor, not a universal constant. Under 14 CFR § 117.25(e), a flightcrew member must be given a minimum of 10 consecutive hours of rest immediately before the flight duty period begins, and that rest must provide an opportunity for at least 8 uninterrupted hours of sleep. EASA ORO.FTL.235 sets a comparable baseline — minimum rest before a duty is at least as long as the preceding duty, with a floor of 12 hours at home base and 10 hours away — and permits conditional reductions only when specific accommodation and travel criteria are met. Because contractual agreements and internal fatigue policies frequently add a 15-to-45-minute operational buffer above the regulatory minimum, the validator must treat the threshold as a configurable parameter rather than a hardcoded integer, so compliance teams can adjust it without redeploying the core logic. The same divergence is where a US engine and its EASA FTL compliance counterpart part ways while sharing one calculation core.
Step 1 — Normalise every timestamp to timezone-aware UTC
Crew scheduling systems export duty logs in heterogeneous formats, mixing UTC, local time, and ambiguous offset strings. The first step is strict normalisation: parse each timestamp, reject anything without an explicit offset, and store the absolute instant in UTC while retaining the rest facility’s IANA zone as metadata. Relying on system-local offsets or naive datetime objects guarantees failure during daylight-saving transitions or when crew operate across hemispheres.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
FACILITY_ZONES = {"KJFK": "America/New_York", "EGLL": "Europe/London"}
def to_utc(raw: str) -> datetime:
"""Parse an ISO 8601 string and require an explicit offset."""
parsed = datetime.fromisoformat(raw)
if parsed.tzinfo is None:
raise ValueError(f"Naive timestamp rejected at ingestion: {raw!r}")
return parsed.astimezone(timezone.utc)
def resolve_zone(icao: str) -> ZoneInfo:
"""Map a rest-facility code to an IANA zone; unresolved codes are flagged, not guessed."""
if icao not in FACILITY_ZONES:
raise KeyError(f"Unresolved rest-facility zone for {icao!r}")
return ZoneInfo(FACILITY_ZONES[icao])
Verify: to_utc("2026-03-07T21:30:00-05:00") returns datetime(2026, 3, 8, 2, 30, tzinfo=timezone.utc), and to_utc("2026-03-07T21:30:00") raises ValueError. Missing zone metadata is the most common ingestion failure — logging unresolved codes for manual review beats letting a silent calculation error through. See the official Python zoneinfo documentation for the resolver semantics.
Step 2 — Load the threshold as configurable data
Hardcoding 10 into the comparison makes every contractual change a code deployment. Instead, resolve the base minimum and buffer from configuration keyed by fleet, base, or agreement, so the same engine serves multiple thresholds and every verdict records which one applied.
from dataclasses import dataclass
@dataclass(frozen=True)
class RestThreshold:
base_hours: float
buffer_minutes: int
citation: str
@property
def required_minutes(self) -> float:
return self.base_hours * 60 + self.buffer_minutes
THRESHOLDS = {
"FAA_DOMESTIC": RestThreshold(10.0, 0, "14 CFR 117.25(e)"),
"FAA_CONTRACT_PLUS": RestThreshold(10.0, 30, "14 CFR 117.25(e) + CBA buffer"),
"EASA_AWAY_BASE": RestThreshold(10.0, 0, "ORO.FTL.235"),
}
Verify: THRESHOLDS["FAA_CONTRACT_PLUS"].required_minutes equals 630.0, and swapping the active policy key changes the requirement with no edit to the validation function below.
Step 3 — Compute the rest window and flag shortfalls
The rest window runs from duty end — block-in plus a configurable post-flight debrief allowance — to the next report time. Apply timezone-aware subtraction to capture exact minute-level deltas, keep the arithmetic in UTC to eliminate DST edge cases, and only convert to the facility’s local clock for the audit context the regulation cares about. When the computed value falls below the configured threshold, emit a structured violation record carrying the crew identifier, pairing ID, shortfall in minutes, and the exact clause triggered.
from dataclasses import dataclass
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import logging
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class DutySegment:
crew_id: str
pairing_id: str
report_time_utc: datetime
block_in_utc: datetime
rest_location_tz: str
debrief_minutes: int = 30
def compute_rest_delta(
segment: DutySegment,
next_report_utc: datetime,
threshold: RestThreshold,
) -> dict:
"""Validate the rest window between a completed duty and the next report time."""
if (
segment.block_in_utc.tzinfo is None
or next_report_utc.tzinfo is None
):
raise ValueError("Timestamps must be timezone-aware (UTC).")
rest_location = ZoneInfo(segment.rest_location_tz)
# Duty end = block-in + post-flight debrief allowance.
duty_end_utc = segment.block_in_utc + timedelta(minutes=segment.debrief_minutes)
# Rest runs from this duty's end until the crew next reports for duty.
rest_delta = (next_report_utc - duty_end_utc).total_seconds() / 60.0
shortfall = max(0.0, threshold.required_minutes - rest_delta)
if shortfall > 0:
logger.warning(
"Rest violation: %s | pairing %s | shortfall %.1f min",
segment.crew_id, segment.pairing_id, shortfall,
)
return {
"crew_id": segment.crew_id,
"pairing_id": segment.pairing_id,
"rest_delta_minutes": round(rest_delta, 1),
"required_minutes": threshold.required_minutes,
"shortfall_minutes": round(shortfall, 1),
"compliant": shortfall == 0,
# Local wall-clock start of the rest period for audit context.
"rest_start_local": duty_end_utc.astimezone(rest_location).isoformat(),
"regulatory_reference": threshold.citation,
}
Decoupling the threshold from the violation layer lets these checks drop into an existing crew management platform without disrupting legacy scheduling workflows, and the standardized payload lets downstream systems parse, route, and remediate violations consistently.
Figure: Rest window measured from duty end (block-in plus debrief) to the next report time, compared against the configurable 10-hour threshold plus buffer.
Step 4 — Handle split rest, standby, and deadhead
Real-world scheduling rarely conforms to linear duty blocks. Split rests, standby assignments, and deadhead positioning fragment what looks like a continuous window, and a naive subtraction of two timestamps will pass rosters that are actually illegal. Model these as explicit rules over a normalised event stream rather than special cases in one conditional.
- Split rests — when a rest period is interrupted by a duty call, aggregate the contiguous segments and apply the minimum to each discrete block. If the combined duration meets the threshold but an individual segment falls below the uninterrupted-sleep requirement, flag a conditional violation.
- Standby and reserve — standby at a designated rest facility typically counts toward rest accumulation, whereas airport or home standby may not. Reference the facility classification metadata to apply the correct accrual rule.
- Deadhead travel — positioning flights are generally treated as duty time, so the clock for the subsequent 10-hour rest does not start until block-in at the final destination.
def effective_rest_start(segments: list[DutySegment]) -> datetime:
"""The rest clock starts at the latest block-in among chained duty/deadhead legs."""
if not segments:
raise ValueError("At least one duty segment is required.")
last = max(segments, key=lambda s: s.block_in_utc)
return last.block_in_utc + timedelta(minutes=last.debrief_minutes)
Verify: for a duty leg blocking in at 18:00Z followed by a deadhead blocking in at 20:00Z, effective_rest_start anchors the rest window to the 20:00Z leg — never the earlier one — so the positioning time is correctly excluded from rest.
Verification queries and assertions
After wiring the steps together, confirm the pipeline behaves deterministically at the boundary:
from datetime import datetime, timezone
seg = DutySegment(
crew_id="CM-1001",
pairing_id="PWM-0421",
report_time_utc=datetime(2026, 3, 8, 6, 0, tzinfo=timezone.utc),
block_in_utc=datetime(2026, 3, 8, 14, 0, tzinfo=timezone.utc),
rest_location_tz="America/New_York",
debrief_minutes=30,
)
# Next report 09:59 following day => 9h29m after the 14:30Z duty end: a shortfall.
short = compute_rest_delta(
seg,
datetime(2026, 3, 9, 0, 0, tzinfo=timezone.utc),
THRESHOLDS["FAA_DOMESTIC"],
)
assert short["compliant"] is False
assert short["shortfall_minutes"] == 30.0
# Push the next report out to a full 10 hours after duty end: compliant.
ok = compute_rest_delta(
seg,
datetime(2026, 3, 9, 0, 30, tzinfo=timezone.utc),
THRESHOLDS["FAA_DOMESTIC"],
)
assert ok["compliant"] is True and ok["shortfall_minutes"] == 0.0
The assertions pin the exact boundary the regulation draws: 9h29m of rest fails, 10h00m passes, and the shortfall is reported in minutes for the scheduler to close before publication.
Emit a deterministic audit trail
Compliance is not achieved through calculation alone; it requires verifiable, reproducible records. Every validation run should persist the input payload, the applied threshold key, the computed deltas, and the verdict. Hashing the inputs and configuration snapshot produces an execution fingerprint that lets compliance teams demonstrate exactly how a pairing was evaluated during an authority inspection or union audit. Scheduler integration should stay asynchronous: when a violation is flagged, publish an event to a message broker rather than blocking the scheduling UI, so downstream consumers can trigger re-optimisation, alert schedulers, or escalate to fatigue-risk officers without coupling validation to scheduling availability.
Failure modes and troubleshooting
- Naive timestamps silently drift across DST. If a
datetimereachescompute_rest_deltawithouttzinfo, the explicitValueErrorfires; if a naive local time slips past ingestion, a nominal 10-hour local window can be 9 or 11 real hours across a transition. Remediation: reject naive inputs at ingestion and keep all arithmetic in UTC. - Unresolved facility zone. A rest location whose ICAO/IATA code is missing from the resolver raises
KeyErrorin Step 1. Remediation: log the unresolved code for manual review rather than defaulting to UTC or system-local, which would corrupt the local audit timestamp. - Deadhead counted as rest. Anchoring the rest clock to the first block-in instead of the final positioning leg overstates the window and passes illegal pairings. Remediation: use
effective_rest_startover the full chain of duty and deadhead segments. - Threshold hardcoded in the comparison. Embedding
10(or600minutes) defeats contractual buffers and dual-jurisdiction reuse. Remediation: resolveRestThresholdfrom configuration and record the citation on every verdict. - Debrief allowance omitted from duty end. Comparing rest against raw block-in rather than block-in plus debrief overstates rest by the debrief minutes. Remediation: always derive duty end through the segment’s
debrief_minutes.
Frequently Asked Questions
How do I handle a rest window that spans a daylight-saving transition?
Compute the duration entirely in UTC — the timezone-aware instants already encode the absolute time, so subtraction yields true elapsed hours regardless of any local clock change. Convert to the facility zone only for display and audit context. The 10-hour minimum in § 117.25(e) is measured as consecutive real time, so a rest that “loses” an hour to a spring-forward transition still needs a full 10 real hours.
Should the 10-hour threshold ever be hardcoded?
No. Treat it as configurable data. Contractual agreements and internal fatigue policies routinely mandate 15-to-45-minute buffers above the regulatory floor, and EASA away-base rest can differ from the FAA figure. Resolving a RestThreshold per policy lets one engine serve every rule and records which threshold produced each verdict.
Does a split rest of two short blocks satisfy the requirement?
Not automatically. Even when the combined duration reaches 10 hours, each contiguous block must independently satisfy the uninterrupted-sleep-opportunity requirement. The validator aggregates segments but flags a conditional violation when any single block falls below the minimum, because two four-hour naps are not a qualifying rest.
When does the rest clock start after a deadhead flight?
At block-in of the final positioning leg. Deadhead segments are generally treated as duty time, so the subsequent 10-hour rest does not begin until the crew arrives at the destination. effective_rest_start anchors the window to the latest block-in across the chained legs so positioning time is excluded.
How do I make a past verdict reproducible for an audit?
Persist the input payload, the applied threshold key and citation, the computed deltas, and a hash of the inputs plus configuration snapshot. Because the threshold is versioned data rather than inline code, an inspector can replay the exact rule set in force when the schedule was published and reproduce the same compliant/violation result.
Related
- Rest Period Compliance Checks — the rule-engine layer this how-to plugs into.
- Crew Duty Time Taxonomy Mapping — shared definitions of report, block-in, and off-duty times.
- EASA FTL Compliance Frameworks — the ORO.FTL.235 rest rules for dual-jurisdiction carriers.
- Flight Data Ingestion & System Sync — how normalised duty logs reach this validator.
- Duty Time Validation & Rule Engines — the parent domain for the full validation pipeline.
Back to Rest Period Compliance Checks.