Duty Time Validation & Rule Engines
Duty time validation sits at the intersection of regulatory compliance, operational efficiency, and crew safety, and it is one of the hardest correctness problems in flight operations software. Modern carriers have moved decisively away from manual roster auditing toward automated validation pipelines that scale across global networks, thousands of daily pairings, and multiple concurrent regulatory jurisdictions. For flight operations managers, crew schedulers, and aviation compliance teams, the mandate is unambiguous: deploy deterministic rule engines that translate complex flight time limitation frameworks into executable, auditable logic. This page is the anchor for how those engines are designed, built in Python, tested against real regulatory edge cases, and hardened for audit — and it connects to the deeper implementation topics that follow.
Why Duty Time Validation Resists Naive Automation
A duty time rule engine looks deceptively simple from the outside: submit a proposed pairing, receive a pass or fail. The difficulty lives in the details that a spreadsheet or a handful of if statements can never capture reliably. Regulatory frameworks such as FAA Part 117 and EASA CS-FTL are, in effect, temporal state machines. They define permissible flight duty periods (FDPs), cumulative flight and duty caps, mandatory rest windows, and fatigue mitigation thresholds — and every one of those limits is a function of context that changes as the roster changes.
Consider what a single FDP limit actually depends to: the crew member’s acclimatization state, the local time at the reporting airport, the number of flight segments, whether the crew is augmented, and whether standby or split-duty provisions apply. Change the report time by fifteen minutes and the maximum FDP can drop by a full hour. Cross the International Date Line and a naive date subtraction silently corrupts a 168-hour rolling window. Apply daylight saving time at the rest location and an “obvious” ten-hour rest can be nine or eleven real hours. These are not exotic corner cases; they occur every day on any medium-haul network.
The engineering consequences are concrete. A production engine must:
- Separate regulatory logic from operational data so a rule change never requires an application redeploy.
- Normalize every timestamp to a single unambiguous reference frame before any arithmetic runs.
- Evaluate overlapping constraints deterministically, so the same input always yields the same verdict and the same violation metadata.
- Preserve a tamper-evident record of exactly which rule version produced each decision.
- Degrade to a conservative, safe answer when upstream data or downstream services are unavailable.
Everything below builds toward those five properties. The reference architecture, data model, and Python patterns are the machinery; the validation, audit, and degradation strategies are what make the machinery trustworthy in front of a regulator.
Reference Architecture for a Duty Time Rule Engine
The end-to-end pipeline moves from raw operational events to a signed compliance verdict. Data arrives asynchronously from ACARS feeds, crew management platforms, and flight planning tools; it is normalized, accumulated into rolling windows, evaluated by a constraint solver, and written to an immutable ledger. The upstream normalization and identifier resolution are covered in depth under flight data ingestion, which is the system of record this engine consumes.
Two architectural decisions dominate everything that follows. First, the solver is deterministic and stateless with respect to the accumulators: given the same event history and the same rule version, it must produce byte-identical output. Second, the accumulators are the only stateful component, and they are keyed so that late-arriving or resubmitted events reconcile rather than double-count. A resilient ingestion layer attaches operational metadata — aircraft type, crew position, applicable jurisdiction — at the boundary, so the solver never has to guess which regulatory branch applies.
Data Model Fundamentals: Events, Accumulators, and UTC Normalization
The core entities are small in number but strict in definition. A duty event is any state change that consumes or resets duty and rest — report, block-off, block-on, release, rest start, standby callout. A pairing is an ordered sequence of duty events for one crew member. An accumulator is a derived, windowed aggregate over those events. A verdict is the solver’s decision plus the full evidentiary context that produced it.
Temporal precision is the first-class concern. Every event carries an instant stored as timezone-aware UTC, plus the IANA zone of the physical location, because some limits are evaluated in UTC (cumulative caps) while others are evaluated in local time (FDP start, night duty windows). Storing only a local wall-clock time, or only a naive UTC offset, is the single most common source of compliance drift. The normalization contract is: parse to an aware instant using Python’s zoneinfo module, never with fixed offsets, so that DST transitions and historical zone changes resolve correctly.
A rolling window sum is the mathematical heart of the accumulators. For a window of width closing at instant , the duty accumulated is the total overlap of each duty interval with the window:
Because this is an overlap sum and not a simple membership count, partially-in-window duties are prorated correctly — which is exactly what §117.23 and ORO.FTL.210 require. The same shape computes flight time, night duty, and rest, changing only which interval type is summed.
A minimal, typed representation of the normalized event stream keeps the contract explicit:
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
from zoneinfo import ZoneInfo
class EventKind(str, Enum):
REPORT = "report"
BLOCK_OFF = "block_off"
BLOCK_ON = "block_on"
RELEASE = "release"
REST_START = "rest_start"
@dataclass(frozen=True, slots=True)
class DutyEvent:
pairing_id: str
sequence: int
kind: EventKind
instant_utc: datetime # tz-aware, always UTC
location_zone: str # IANA zone, e.g. "America/New_York"
def local(self) -> datetime:
return self.instant_utc.astimezone(ZoneInfo(self.location_zone))
def window_overlap(intervals, window_end, window_width):
"""Sum of interval overlap with [window_end - width, window_end]."""
start = window_end - window_width
total = timedelta(0)
for s, e in intervals:
lo, hi = max(s, start), min(e, window_end)
if hi > lo:
total += hi - lo
return total
Because block time, positioning segments, and chocks events must be reduced to regulatory baselines before any check runs, the precise arithmetic lives in Flight Time Calculation Algorithms. Getting that reduction wrong — mishandling deadhead segments or jurisdiction-specific rounding — quietly poisons every downstream accumulator, which is why it is treated as its own topic rather than inlined here.
Mapping FAA Part 117 and EASA FTL onto the Engine
The engine’s value comes from expressing regulation as data, not code. Each jurisdiction contributes a versioned rule set that the solver loads at evaluation time. The mapping below shows how the two dominant frameworks resolve to the same accumulator-and-limit machinery while differing in their constants and lookup tables.
| Concept | FAA Part 117 | EASA ORO.FTL |
|---|---|---|
| Flight time cap | 100 hrs / 672 hrs; 1,000 hrs / 365 days (§117.11) | 100 hrs / 28 days; 1,000 hrs / 12 months (ORO.FTL.210) |
| Duty cap | 60 hrs / 168 hrs; 190 hrs / 672 hrs (§117.23) | 60 hrs / 7 days; 110 hrs / 14 days; 190 hrs / 28 days (ORO.FTL.210) |
| Max FDP lookup | Table B by report time × segments (§117.13) | FDP table by acclimatized report time × sectors (ORO.FTL.205) |
| Minimum rest | 10 hrs, 8 hrs sleep opportunity (§117.25) | ≥ preceding duty or 12 hrs, whichever greater (ORO.FTL.235) |
| Definitions | Acclimated, FDP, rest (§117.3) | Acclimatised, FDP, WOCL (ORO.FTL.105) |
The FAA framework and its database representation are detailed under FAA Part 117 rule schema design; the European counterpart, including the acclimatization state machine that drives the FDP table lookup, lives under EASA FTL compliance. Both depend on a shared vocabulary so that a “duty period” means the same thing in a payroll export as it does in a compliance verdict — that shared vocabulary is the crew duty time taxonomy.
Encoding a jurisdiction as data looks like this — the solver never learns the numbers, it only learns how to apply them:
from pydantic import BaseModel, Field
class CumulativeLimit(BaseModel):
label: str
window_hours: int = Field(gt=0)
max_duty_hours: float | None = None
max_flight_hours: float | None = None
class RuleSet(BaseModel):
jurisdiction: str # "FAA-117" | "EASA-FTL"
version: str # semantic version, pinned per verdict
cumulative_limits: list[CumulativeLimit]
min_rest_hours: float
fdp_table_id: str # reference into the FDP lookup store
FAA_117 = RuleSet(
jurisdiction="FAA-117",
version="2026.01",
cumulative_limits=[
CumulativeLimit(label="flight_672h", window_hours=672, max_flight_hours=100),
CumulativeLimit(label="duty_168h", window_hours=168, max_duty_hours=60),
CumulativeLimit(label="duty_672h", window_hours=672, max_duty_hours=190),
],
min_rest_hours=10,
fdp_table_id="part117-table-b",
)
The rolling caps translate directly into a SQL window function when the accumulators are materialized in Postgres, which is how large fleets evaluate cumulative limits without replaying full event history per request:
SELECT
crew_id,
duty_end,
SUM(duty_seconds) OVER (
PARTITION BY crew_id
ORDER BY duty_end
RANGE BETWEEN INTERVAL '168 hours' PRECEDING AND CURRENT ROW
) / 3600.0 AS duty_hours_168h
FROM duty_periods
ORDER BY crew_id, duty_end;
Python Implementation Patterns
Three patterns recur in every mature engine: typed boundary models, an explicit state machine for duty status, and asynchronous evaluation that keeps the solver pure.
Typed boundary models. Pydantic (or dataclasses with validators) guards the edge of the system. Every inbound pairing is coerced and validated before it reaches the solver, so malformed timestamps, missing zones, or impossible segment orderings fail loudly at ingestion rather than silently at decision time. This is also where idempotency keys — (pairing_id, sequence) — are enforced so resubmissions reconcile.
Explicit state machine. Duty status is not a boolean. A crew member transitions through RESTED → ON_DUTY → IN_FLIGHT → POST_FLIGHT → RESTING, with side branches for standby and split duty. Modeling these transitions explicitly means the engine can reject physically impossible sequences (a block-off with no preceding report) and can compute rest correctly across split-duty provisions, where an in-duty rest can partially restore the FDP clock.
Asynchronous evaluation, pure solver. I/O — loading rule sets, hydrating accumulators, writing the ledger — is async; the solver itself is a pure function of (events, ruleset). This separation is what makes the engine testable and deterministic.
async def evaluate_pairing(pairing: Pairing, ruleset: RuleSet) -> Verdict:
events = await normalize(pairing) # I/O: async
accumulators = await hydrate_windows(events) # I/O: async
verdict = solve(events, accumulators, ruleset) # pure, deterministic
await ledger.append(verdict) # I/O: async
return verdict
def solve(events, accumulators, ruleset) -> "Verdict":
violations = []
for limit in ruleset.cumulative_limits:
window = timedelta(hours=limit.window_hours)
if limit.max_duty_hours is not None:
used = accumulators.duty(window).total_seconds() / 3600
if used > limit.max_duty_hours:
violations.append(("HARD", limit.label, used, limit.max_duty_hours))
return Verdict(ok=not any(v[0] == "HARD" for v in violations),
rule_version=ruleset.version,
violations=violations)
The engine exposes this behind a versioned REST or gRPC endpoint that returns a structured verdict — pass/fail plus granular violation metadata — so schedulers can resolve conflicts algorithmically rather than by manual audit. Mandatory rest evaluation, with its split-rest and compensatory-rest logic, is intricate enough to warrant its own treatment under Rest Period Compliance Checks.
Compliance Validation Strategy
An engine that is merely “usually right” is a liability. The validation strategy has three layers: a regulatory regression suite, property-based testing, and an explicit edge-case corpus.
The regression suite pins known-good verdicts for a library of real pairings against each rule version. When a regulatory parameter changes, the suite makes the blast radius visible: exactly which historical pairings would now decide differently, and why. This is the difference between a defensible “we updated to AMC revision X on date Y” and an unexplained change in behavior.
Property-based testing with hypothesis attacks the invariants that must hold for all inputs, not just the examples an engineer thought of. The most valuable properties are monotonicity (adding duty never reduces accumulated duty), determinism (identical inputs yield identical verdicts), and window correctness (an event exactly on a window boundary is counted per the regulation’s inclusive/exclusive rule).
from hypothesis import given, strategies as st
@given(events=st.lists(duty_event_strategy(), min_size=1, max_size=50))
def test_duty_accumulation_is_monotonic(events):
ordered = sorted(events, key=lambda e: e.instant_utc)
running = timedelta(0)
for prefix_len in range(1, len(ordered) + 1):
total = accumulate_duty(ordered[:prefix_len])
assert total >= running # never decreases
running = total
@given(events=st.lists(duty_event_strategy(), min_size=1))
def test_solver_is_deterministic(events):
assert solve(events, FAA_117) == solve(list(events), FAA_117)
The edge-case corpus is niche-specific and must be maintained deliberately. It includes: a duty that crosses midnight local time; a rest period spanning a spring-forward DST transition (23 real hours of wall clock) and an autumn fall-back (25 hours); a 365-day cumulative window that includes February 29 on a leap year; an eastbound pairing that crosses the date line so UTC ordering and local ordering diverge; and an acclimatization reset mid-rotation that changes which FDP table row applies. Each of these has broken a production engine somewhere; each belongs in the corpus with a pinned expected verdict.
Security and Audit Requirements
A compliance verdict is a legal artifact, so the record of how it was produced must be tamper-evident and access-controlled. Two mechanisms carry most of the weight: role-based access control on the mutation surface, and a hash-chained audit log.
Only authorized dispatchers, compliance officers, and automated schedulers may submit pairings or override parameters, and every such action is attributed to an identity. Overrides — the deliberate acceptance of a soft-threshold breach — are first-class, signed events, never silent edits. The audit log itself is append-only, and each entry commits to the previous one via a hash chain, so any retroactive alteration breaks verification for every subsequent entry:
import hashlib
import json
def chain_entry(prev_hash: str, verdict: dict) -> dict:
body = json.dumps(verdict, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256((prev_hash + body).encode()).hexdigest()
return {"prev": prev_hash, "verdict": verdict, "hash": digest}
Every verdict records the input payload, the exact rule version applied, the temporal state of all rolling accumulators at decision time, and the final disposition. That level of traceability is what satisfies FAA and EASA audit requirements and gives internal quality assurance the forensic trail to investigate any scheduling anomaly. The broader access-boundary design — how these controls compose with the rest of the platform — is documented under System Security & Access Boundaries.
Graceful Degradation and Fallback Logic
Validation cannot become a single point of failure for an entire operation. Network partitions, upstream data corruption, or a temporarily unreachable rule store must not halt scheduling — but they also must not produce an unsafe optimistic answer. The correct failure mode is conservative: when the engine cannot prove a pairing is legal, it must not approve it automatically.
Concretely, when the live rule store is unreachable the engine falls back to a cached, signed rule snapshot and marks every verdict produced under fallback as provisional, requiring later reconciliation once the authoritative store returns. When accumulator hydration is degraded, the engine assumes the most restrictive plausible prior state rather than an empty one, so a missing history can never manufacture spare duty hours. For widespread anomalies or an emergency regulatory directive, operators need an immediate circuit-breaker that suspends automated approvals and routes everything to manual review — and every such state change is itself a signed, hash-chained audit event.
async def evaluate_with_fallback(pairing, store):
try:
ruleset = await store.load_current()
return await evaluate_pairing(pairing, ruleset)
except StoreUnavailable:
snapshot = load_signed_snapshot() # conservative cached rules
verdict = await evaluate_pairing(pairing, snapshot)
verdict.provisional = True # never auto-publish
await queue_for_reconciliation(verdict)
return verdict
This transforms the engine from a brittle gatekeeper into a resilient advisor: it keeps operating under degradation, it never trades safety for availability, and it leaves a complete record of every decision made while degraded.
The Validation Domain in Depth
The topics below expand each stage of the pipeline into its own detailed treatment. Together they cover the full path from raw block times to tuned operational alerts.
- Flight Time Calculation Algorithms — how block time, flight time, chocks events, and positioning segments are normalized to regulatory baselines, including jurisdiction-specific rounding and deadhead handling that feed every downstream accumulator.
- Rest Period Compliance Checks — evaluating minimum rest, split-rest, and compensatory-rest triggers in local time across DST and date-line transitions, where naive arithmetic silently miscounts hours.
- Fatigue Risk Scoring Models — forward-looking scoring that flags pairings which are legally compliant yet approach physiological limits, using circadian and cumulative-disruption signals alongside the hard rule checks.
- Threshold Tuning & Alerting — configurable warning boundaries (for example, cumulative duty crossing 90% of a legal cap) that route notifications to scheduling dashboards without any code change.
Related
- Flight Time Calculation Algorithms
- Rest Period Compliance Checks
- Fatigue Risk Scoring Models
- Threshold Tuning & Alerting
- Core Architecture & Regulatory Mapping
- Flight Data Ingestion & System Sync
Back to Home.