Flight Time Calculation Algorithms

Flight time calculation algorithms form the computational backbone of crew scheduling and regulatory compliance systems. Unlike simple elapsed-time arithmetic, these algorithms must reconcile disparate movement sources, apply jurisdiction-specific thresholds, and produce deterministic outputs that survive an audit years later. This work sits inside the broader Duty Time Validation & Rule Engines domain, where the temporal metrics computed here are transformed into pass/fail compliance verdicts. For flight operations managers and Python automation builders, the engineering challenge is building resilient pipelines that handle timezone transitions, telemetry gaps, and live operational deviations without introducing heuristic drift that no inspector can later reproduce.

Temporal boundary-detection pipeline Movement events from ACARS, FMS and manual sources are normalized to UTC, then a deterministic state machine detects the OUT, OFF, ON and IN boundaries. A tolerance gate checks whether the sources agree: if they disagree beyond threshold the leg is routed to manual adjudication rather than silently overridden; if they agree the resolver computes block and air time, which is then fed to duty and rest validation. yes no Movement events ACARS · FMS · manual Normalize to UTC instants State machine detect OUT/OFF/ON/IN Sources agree? Compute block & air time Manual adjudication Feed duty & rest validation
Temporal boundary detection: a deterministic state machine isolates the measured window, and out-of-tolerance telemetry is routed to manual adjudication rather than silently overridden — the same inputs always yield the same durations.

The Calculation Problem This Topic Solves

The scoped problem is narrow and unforgiving: given a stream of movement events for a single leg, derive two legally distinct durations — block time and flight time — that every downstream rule engine will treat as ground truth. Block time runs from the moment the aircraft first moves under its own power (chocks-off) to the moment it comes to rest at the next point of landing (chocks-on); this is the “flight time” defined in 14 CFR §1.1 and the basis for FAA Part 117 accumulation. Flight time in the operational sense — takeoff roll to main-gear touchdown, sometimes called “air time” — is what many EASA operators track separately for maintenance and airborne-exposure metrics. Conflating the two is the single most common source of false violations in duty engines.

Three properties make this hard to encode correctly. First, the source events are noisy: ACARS out/off/on/in (OOOI) reports, FMS position logs, and crew-entered logbook times rarely agree to the minute, and any of them can be missing on a given leg. Second, the boundaries are ambiguous — a return-to-gate, a de-icing hold, or a taxi-back after a rejected takeoff all produce movement that is not flight. Third, the output must be reproducible: the same inputs must yield the same durations on every re-run, because an audit reconstructs the number from the stored events, not from a live sensor. An algorithm that rounds, interpolates, or silently prefers one source over another without recording why cannot defend its output.

Schema and Data Structure Design

The data model separates raw movement events from the derived durations computed over them. Keeping the two apart is what lets the calculation be re-run against corrected telemetry — for example when a delayed ACARS IN report arrives — without mutating the audit trail of what was known at evaluation time.

The core entities are the leg (one scheduled origin-destination operation) owning an ordered set of movement_event rows; each event carries an event_type (OUT, OFF, ON, IN), a source (ACARS, FMS, manual), a UTC instant, and the originating zone as metadata. A derived time_calculation row records the resolved block_minutes, air_minutes, the source_precedence that produced each, and a tolerance_flag when sources disagreed beyond threshold. The versioned calc_ruleset supplies source-precedence order and tolerance windows, keyed by effective date so a policy change is a diffable row rather than a code edit.

Flight time calculation schema entity-relationship diagram A leg emits many movement_event rows, each carrying an event_type of OUT, OFF, ON or IN, a source of ACARS, FMS or manual, a UTC timestamp and an originating-zone attribute. The same leg resolves one-to-one to a time_calculation row whose block_minutes, air_minutes, source_precedence and tolerance_flag are all derived — never entered by hand. A versioned calc_ruleset, keyed by effective date, supplies the source-precedence order and tolerance window that govern that resolution. emits 1 N resolves to 1 1 governs leg leg_idPK crew_idFK origin destination sched_date movement_event event_idPK leg_idFK event_type source ts_utc origin_zone event_type ∈ {OUT, OFF, ON, IN} · ts_utc always UTC time_calculation STORED leg_idPK · FK DERIVED — NEVER ENTERED block_minutes air_minutes source_precedence tolerance_flag calc_ruleset ruleset_idPK effective_from effective_to precedence_order tolerance_minutes versioned by effective date
Raw movement_event rows are kept separate from the derived time_calculation, so the durations can be recomputed against corrected telemetry without mutating the audit trail — and a versioned calc_ruleset makes every precedence or tolerance change a diffable row rather than a code edit.

Field naming, unit conventions, and the classification of positioning versus revenue segments all follow the shared crew duty time taxonomy mapping. Anchoring the schema to that vocabulary is what stops the resolver from counting a taxi-back as a landing, or a positioning deadhead as a revenue sector — the two mistakes that most often corrupt cumulative totals.

Regulatory Mapping

Every schema decision above is driven by a specific regulatory provision. For US carriers the controlling text is FAA Part 117, whose numeric schema is worked through in the FAA Part 117 rule schema design; for European operators the parallel is EASA FTL compliance. The mapping below is the ground truth the resolver and accumulator encode.

Because §1.1 anchors the regulated number to block time while operators frequently reason in air time, the algorithm must compute and store both and label which one each downstream check consumes. The exact boundary rules, edge cases, and a runnable resolver are detailed in Calculating Block Time vs Flight Time in Python.

Python Implementation Walkthrough

Production implementations express movement events as typed models so a malformed payload fails at the ingestion boundary rather than deep inside the accumulator. Using pydantic for the event contract guarantees that every timestamp is timezone-aware UTC before it reaches the resolver. Timezone handling relies on the standard-library zoneinfo and datetime modules documented in the Python datetime documentation.

from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, field_validator


class EventType(str, Enum):
    OUT = "OUT"   # chocks-off, block start
    OFF = "OFF"   # wheels-up, air start
    ON = "ON"     # wheels-down, air end
    IN = "IN"     # chocks-on, block end


class MovementEvent(BaseModel):
    leg_id: str
    event_type: EventType
    ts_utc: datetime
    source: str            # "acars" | "fms" | "manual"
    origin_zone: str       # e.g. "America/New_York"

    @field_validator("ts_utc")
    @classmethod
    def must_be_utc(cls, v: datetime) -> datetime:
        if v.tzinfo is None or v.utcoffset() != timezone.utc.utcoffset(None):
            raise ValueError("ts_utc must be timezone-aware UTC")
        return v

The durations are a pure function of the resolved boundary instants. Keeping the resolver pure — no I/O, no clock reads — is what makes every verdict reproducible from the stored events. The resolver picks one instant per boundary using a configured source precedence, records which source won, and refuses to invent a boundary that no source provided.

from datetime import timedelta

# From calc_ruleset: lower index wins when sources disagree within tolerance.
SOURCE_PRECEDENCE = ("acars", "fms", "manual")
TOLERANCE = timedelta(minutes=3)


def _resolve(events: list[MovementEvent], want: EventType) -> MovementEvent | None:
    candidates = [e for e in events if e.event_type is want]
    if not candidates:
        return None
    return min(candidates, key=lambda e: SOURCE_PRECEDENCE.index(e.source))


def compute_durations(events: list[MovementEvent]) -> dict[str, int | bool]:
    out, off = _resolve(events, EventType.OUT), _resolve(events, EventType.OFF)
    on, in_ = _resolve(events, EventType.ON), _resolve(events, EventType.IN)
    if out is None or in_ is None:
        raise ValueError("cannot compute block time without OUT and IN")

    block = int((in_.ts_utc - out.ts_utc).total_seconds() // 60)
    air = (
        int((on.ts_utc - off.ts_utc).total_seconds() // 60)
        if off is not None and on is not None
        else None
    )
    if block < 0 or (air is not None and air < 0):
        raise ValueError("negative duration: events out of chronological order")

    # Air time can never exceed block time; a breach signals a boundary error.
    flag = air is not None and air > block
    return {"block_minutes": block, "air_minutes": air, "tolerance_flag": flag}

Storing durations as integer minutes rather than floats is deliberate: it eliminates the floating-point drift that otherwise accumulates across a 28-day rolling window and quietly under-counts against the §117.23 caps. Every branch that could produce a suspect number raises or flags rather than returning a plausible-looking value, so bad telemetry surfaces at adjudication instead of hiding inside a legal-looking total.

Rolling Window and Temporal Aggregation

The per-leg calculation is stateless, but §117.23 and ORO.FTL.210 are not: they require rolling flight-time totals over spans measured in consecutive hours and days, not calendar buckets. Timezone drift is the dominant source of cumulative error, so every instant enters the aggregation layer stamped in ISO 8601 UTC. Where the data lives in PostgreSQL, frame-bounded window functions express the caps directly; see the PostgreSQL window functions reference for frame semantics.

-- Rolling 672-hour (28-day) flight-time total per crew member,
-- evaluated at each leg's block-in, per FAA 14 CFR §117.23(b).
SELECT
    crew_id,
    block_in_utc,
    SUM(block_minutes) OVER (
        PARTITION BY crew_id
        ORDER BY block_in_utc
        RANGE BETWEEN INTERVAL '672 hours' PRECEDING AND CURRENT ROW
    ) AS rolling_672h_minutes
FROM time_calculation tc
JOIN leg USING (leg_id)
ORDER BY crew_id, block_in_utc;

For pre-assignment validation inside the scheduler, the same logic runs in memory over Polars or Pandas frames, where a rolling group-by keyed on the block-in timestamp reproduces the SQL frame without a database round trip. Whichever engine runs it, the window boundary must be inclusive of the exact regulatory span — an off-by-one on the 672-hour edge silently drops a leg and lets an over-limit roster through.

import polars as pl

def rolling_flight_time(df: pl.DataFrame) -> pl.DataFrame:
    # df columns: crew_id, block_in_utc (datetime[UTC]), block_minutes (int)
    return (
        df.sort("block_in_utc")
        .rolling(index_column="block_in_utc", period="672h", group_by="crew_id")
        .agg(pl.col("block_minutes").sum().alias("rolling_672h_minutes"))
    )

Integration Points

This topic is one stage in a longer chain and depends on clean contracts with its neighbours:

Testing and Edge Cases

Because the resolver is time-sensitive and source-dependent, example-based tests miss the cases that matter. Property-based testing with hypothesis generates thousands of synthetic event sets and asserts invariants — for instance that resolved air_minutes never exceeds block_minutes, and that computing durations twice over the same events yields identical results. The boundary conditions that most often break flight-time implementations are specific:

Every source-precedence and tolerance change is version-controlled in the calc_ruleset so a policy revision is a reviewable diff, and the full suite runs against the latest ruleset before each deployment.

Explore This Topic in Depth

Back to Duty Time Validation & Rule Engines.

Explore this section