Crew Duty Time Taxonomy Mapping

Crew duty time taxonomy mapping is the translation layer that turns raw operational telemetry into deterministic, auditable compliance logic. In production flight operations, duty periods, rest windows, and standby classifications are rarely uniform across scheduling platforms, crew management systems, and jurisdictional mandates — a “standby” in one vendor’s roster feed may be an active duty block in another, and a split-duty rest may look identical to an overnight layover unless the boundaries are modelled explicitly. A rigorous taxonomy resolves this fragmentation by standardizing how duty states are defined, calculated, and audited. This mapping discipline is a foundational subsystem within the broader Core Architecture & Regulatory Mapping domain, ensuring every pairing adjustment, reserve callout, and fatigue-risk assessment resolves against the correct regulatory schema without manual reconciliation or brittle legacy workarounds.

Duty-state machine A finite-state machine over canonical duty states. Entry begins in Rest. Rest transitions to Standby on assignment; Standby transitions to Flight Duty on report or pushback; Flight Duty and Ground Duty alternate across turnarounds and next sectors; both Standby (stand down) and Flight Duty (release / chocks on) return to Rest, from which the pairing may exit. Each state triggers a different regulatory calculation. Rest §117.25 / .235 Standby airport / hotel Flight Duty §117.13 FDP Ground Duty turnaround assigned report pushback turnaround next sector stand down release / chocks on entry exit

Figure: Duty-state machine: explicit transitions prevent misclassifying standby, ground duty, or rest, each of which triggers a different regulatory calculation.

The classification gap this layer closes

The specific engineering challenge is that regulations reason about named states — flight duty period, rest period, standby, split duty — while operational systems emit events: an ACARS OUT/OFF/ON/IN quartet, a crew-app “report” tap, a rostering row with a shift code. Nothing in that raw stream declares which regulatory state applies. The gap between an event stream and a legally meaningful state is exactly where compliance errors originate: an airport standby that converts to a flight duty period must inherit its start time from the standby report, not from pushback, or the flight duty period (FDP) will be understated and a cumulative-limit breach will pass silently.

A taxonomy closes this gap by defining a small, closed set of canonical states, the boundary triggers that open and close each one, and the modifier flags that alter the downstream calculation (night duty, acclimatization status, augmented crew, time-zone crossing). Once every event resolves to exactly one canonical state with unambiguous start and end instants, the rule engine becomes a pure function of that normalized history. This is why the taxonomy is built and tested before any limit arithmetic: it is the contract that the FAA Part 117 rule schema design and the EASA FTL compliance frameworks both consume, and both jurisdictions must see identical temporal boundaries for a given roster.

Duty-state schema design

The schema centres on a normalized enumeration rather than free-text labels or proprietary vendor codes. Primary canonical states are Flight Duty Period, Ground Duty, Airport Standby, Hotel/Home Standby, Split Duty, Augmented Operations, and Rest Period. Each state carries explicit boundary conditions — a start trigger (report time, pushback, callout), an end trigger (chocks on, release, stand-down) — and a set of modifier flags. The persisted model separates three concerns: the immutable duty event (what the feed reported), the derived duty segment (a classified, bounded interval), and the aggregate duty period (a chain of contiguous segments the regulation treats as one unit).

Duty-time taxonomy entity-relationship diagram Five entities model the classification pipeline. CrewMember has many DutyEvents (1:N). Many immutable DutyEvents roll up into one classified DutySegment (N:1); many DutySegments roll up into one DutyPeriod (N:1); each DutyPeriod is followed by exactly one RestPeriod (1:1). A DutyStateEnum — rest, airport_standby, hotel_standby, flight_duty, ground_duty, split_duty, augmented — constrains the state field of both DutyEvent and DutySegment. Every temporal field is a timezone-aware UTC instant. CrewMember roster subject crew_idPK report_tz base DutyEvent immutable · what fed reported event_idPK crew_idFK state start_utc end_utc trigger DutySegment classified interval segment_idPK state start_utc end_utc is_nightFLAG acclimatisedFLAG DutyPeriod contiguous chain period_idPK start_utc end_utc segment_count fdp_hours augmentedFLAG RestPeriod following · resets FDP row rest_idPK period_idFK start_utc end_utc min_rest_h 1:N N:1 N:1 1:1 follows DutyStateEnum closed canonical set · StrEnum rest airport_standby hotel_standby flight_duty ground_duty split_duty augmented state ∈ state ∈

The entity relationships matter because regulatory limits are computed at different levels. A single sector is a segment; §117.13 FDP limits apply to the period that groups those segments; and cumulative caps aggregate periods over rolling windows. Modelling these as one flat table forces every query to re-derive grouping logic, which is where subtle off-by-one boundary bugs live. Keeping raw events immutable also preserves audit lineage: when a compliance officer asks why a pairing was flagged, the system can replay the exact event stream, the classification decision, and the applied threshold.

Canonical field naming is standardized across the schema so that downstream modules — scheduling, payroll, compliance reporting — read identical semantics. Every temporal field is stored as timezone-aware UTC (start_utc, end_utc) with a separate report_tz IANA identifier retained for local-time-dependent rules such as the window-of-circadian-low and reporting-time-keyed FDP table lookups.

Regulatory mapping

The taxonomy exists to make specific regulatory sections computable, so each canonical state maps to the text that governs it.

For operators under European jurisdiction, the same canonical states re-map onto Regulation (EU) No 965/2012, Subpart FTL: ORO.FTL.105 (definitions), ORO.FTL.205 (maximum daily FDP with acclimatisation-dependent tables), ORO.FTL.235 (rest periods), and the cumulative duty limits in ORO.FTL.210. The critical design decision is that acclimatisation state — “acclimatised”, “unknown”, or a specific reference time — is a modifier flag on the state, not a separate code path, so the mapping layer can route the identical duty history to either the FAA or EASA schema without duplicating the state machine.

Python implementation walkthrough

Duty states are modelled as a StrEnum to guarantee immutability, type safety, and human-readable serialization at API boundaries:

from enum import StrEnum


class DutyState(StrEnum):
    REST = "rest"
    AIRPORT_STANDBY = "airport_standby"
    HOTEL_STANDBY = "hotel_standby"
    FLIGHT_DUTY = "flight_duty"
    GROUND_DUTY = "ground_duty"
    SPLIT_DUTY = "split_duty"
    AUGMENTED = "augmented"

Ingested telemetry is validated at the boundary with a Pydantic model before it can reach the classifier. Naive timestamps are rejected outright, because a missing offset is the single most common source of cross-jurisdictional miscalculation:

from datetime import datetime
from zoneinfo import ZoneInfo

from pydantic import BaseModel, field_validator


class DutyEvent(BaseModel):
    crew_id: str
    state: DutyState
    start_utc: datetime
    end_utc: datetime
    report_tz: str          # IANA identifier, e.g. "Europe/Berlin"
    segment_count: int = 0
    is_night: bool = False
    acclimatised: bool = True

    @field_validator("start_utc", "end_utc")
    @classmethod
    def require_aware_utc(cls, v: datetime) -> datetime:
        if v.tzinfo is None or v.utcoffset() is None:
            raise ValueError("timestamps must be timezone-aware")
        return v.astimezone(ZoneInfo("UTC"))

    @field_validator("report_tz")
    @classmethod
    def known_zone(cls, v: str) -> str:
        ZoneInfo(v)  # raises if the IANA key is unknown
        return v

Transitions between states are governed by a finite state machine that validates preconditions before allowing progression. This is what prevents a HOTEL_STANDBY from being silently promoted to FLIGHT_DUTY without first passing through a callout, and it is what carries the standby report time forward so the resulting FDP starts at the right instant:

ALLOWED: dict[DutyState, set[DutyState]] = {
    DutyState.REST: {DutyState.AIRPORT_STANDBY, DutyState.HOTEL_STANDBY,
                     DutyState.FLIGHT_DUTY},
    DutyState.AIRPORT_STANDBY: {DutyState.FLIGHT_DUTY, DutyState.REST},
    DutyState.HOTEL_STANDBY: {DutyState.AIRPORT_STANDBY,
                              DutyState.FLIGHT_DUTY, DutyState.REST},
    DutyState.FLIGHT_DUTY: {DutyState.GROUND_DUTY, DutyState.SPLIT_DUTY,
                            DutyState.REST},
    DutyState.GROUND_DUTY: {DutyState.FLIGHT_DUTY, DutyState.REST},
    DutyState.SPLIT_DUTY: {DutyState.FLIGHT_DUTY},
    DutyState.AUGMENTED: {DutyState.REST},
}


def apply_transition(current: DutyState, nxt: DutyState) -> DutyState:
    if nxt not in ALLOWED[current]:
        raise ValueError(f"illegal transition {current} -> {nxt}")
    return nxt

The classifier that assigns a canonical state to a raw event is kept as a pure function — it takes normalized input and returns a DutyState with no side effects — so it can be exhaustively unit-tested and re-run deterministically during audit replay.

Rolling-window and temporal aggregation patterns

The cumulative caps in §117.23 (and their EASA equivalents in ORO.FTL.210) are inherently windowed: no pairing is compliant or non-compliant in isolation. Once duty periods are normalized to UTC, the 168-hour and 672-hour look-backs are natural fits for SQL window functions over a RANGE frame:

SELECT
    crew_id,
    period_start_utc,
    SUM(fdp_hours) OVER (
        PARTITION BY crew_id
        ORDER BY period_start_utc
        RANGE BETWEEN INTERVAL '168 hours' PRECEDING AND CURRENT ROW
    ) AS fdp_168h,
    SUM(fdp_hours) OVER (
        PARTITION BY crew_id
        ORDER BY period_start_utc
        RANGE BETWEEN INTERVAL '672 hours' PRECEDING AND CURRENT ROW
    ) AS fdp_672h
FROM duty_period
ORDER BY crew_id, period_start_utc;

The same aggregation expressed with Polars keeps the calculation inside the Python pipeline for pre-publish validation, using a time-based rolling window that respects the UTC ordering:

import polars as pl

rolling = (
    duty_periods
    .sort("period_start_utc")
    .rolling(index_column="period_start_utc", period="168h", group_by="crew_id")
    .agg(pl.col("fdp_hours").sum().alias("fdp_168h"))
)
breaches = rolling.filter(pl.col("fdp_168h") > 60.0)

Two subtleties are specific to duty-time aggregation. First, the window is defined on the period start, but the cumulative cap is on hours accrued within the window, so periods that straddle the window boundary must be counted, not truncated — a RANGE frame keyed on the start instant handles this cleanly where a naive ROWS frame would not. Second, all arithmetic stays in UTC; local-time-dependent lookups (the §117.13 report-time row) are computed separately from the report_tz field and never mixed into the cumulative sum.

Integration points

The taxonomy sits between upstream feeds and downstream evaluators, so its contracts define how the surrounding subsystems interoperate:

Testing and edge cases

Because the taxonomy is a pure classification function over timestamps, it is an ideal target for property-based testing with hypothesis. The properties worth asserting are invariants of the state machine rather than specific outputs:

from hypothesis import given, strategies as st


@given(
    seq=st.lists(st.sampled_from(list(DutyState)), min_size=1, max_size=20)
)
def test_no_illegal_transitions_survive(seq):
    state = DutyState.REST
    for nxt in seq:
        if nxt in ALLOWED[state]:
            state = apply_transition(state, nxt)
        else:
            # illegal transitions must always raise, never coerce
            try:
                apply_transition(state, nxt)
            except ValueError:
                pass
            else:
                raise AssertionError("illegal transition was accepted")

The boundary conditions unique to this domain deserve dedicated regression cases: a duty period that crosses UTC midnight; a report time that falls on a daylight-saving transition in report_tz (a “missing” or “repeated” local hour); an airport standby that converts to flight duty exactly at the standby limit; a split-duty rest that is one minute short of the §117.15 qualifying threshold; and an acclimatisation state that flips between two sectors of the same period. Each of these has produced silent miscalculations in naive implementations, so each should assert against hand-verified expected boundaries and be replayed against historical pairing data before any deploy.

Continue in this area

A rigorously engineered crew duty time taxonomy turns fragmented operational data into a predictable, auditable compliance asset. By standardizing state definitions, routing evaluations through jurisdiction-specific schemas, and enforcing strict security and fallback protocols, flight operations teams scale scheduling complexity without compromising regulatory adherence. For authoritative references, the official Python zoneinfo documentation covers IANA time-zone handling, and the full text of 14 CFR Part 117 governs the cumulative limits and rest requirements the taxonomy is built to enforce.

Back to Core Architecture & Regulatory Mapping.

Explore this section