Cumulative Duty and Flight-Time Caps Reference

The exact task this guide solves is encoding every rolling cumulative cap — FAA and EASA, duty and flight time — as parameterised rows and evaluating them with one query instead of a bespoke check per limit. Cumulative caps are defined over any consecutive window, not calendar periods, and each regime meters a different quantity over a different interval, so the cap that binds a roster is rarely obvious. This page tabulates all of them, seeds them as data, and gives the SQL window function and the Polars rolling aggregation that evaluate them in a single pass. It is the numeric reference behind the regulatory reference tables section and supplies the caps the duty time validation rule engines enforce.

Prerequisites

Every cumulative cap in one table

Each row is a rolling window: a metric, a window length, and a cap. The FAA meters over rolling hours; EASA meters over rolling days.

Regime Metric Window Cap
FAA §117.23©(1) Flight duty period 168 consecutive hours 60 hours
FAA §117.23©(2) Flight duty period 672 consecutive hours 190 hours
FAA §117.23(b)(1) Flight time 672 consecutive hours 100 hours
FAA §117.23(b)(2) Flight time 365 consecutive days 1,000 hours
EASA ORO.FTL.210(a) Duty 7 consecutive days 60 hours
EASA ORO.FTL.210(b) Duty 14 consecutive days 110 hours
EASA ORO.FTL.210© Duty 28 consecutive days 190 hours
EASA ORO.FTL.210(d) Flight time 28 consecutive days 100 hours
EASA ORO.FTL.210(e) Flight time 12 calendar months 1,000 hours

The 672-hour FAA window and the 28-day EASA window are both roughly four weeks, but they are not interchangeable: the FAA counts FDP or flight-time hours over an exact 672-hour clock, while EASA counts duty or flight over calendar-aligned day windows. The 14-day EASA window has no FAA counterpart at all.

Step 1 — Seed the caps as parameterised rows

Storing the window length and cap as data means one evaluation loop serves every limit; adding a cap is an INSERT, never a new branch.

CUMULATIVE_CAPS = [
    {"regime": "FAA",  "metric": "fdp",    "window_hours": 168,      "cap_minutes": 60 * 60},
    {"regime": "FAA",  "metric": "fdp",    "window_hours": 672,      "cap_minutes": 190 * 60},
    {"regime": "FAA",  "metric": "flight", "window_hours": 672,      "cap_minutes": 100 * 60},
    {"regime": "FAA",  "metric": "flight", "window_hours": 365 * 24, "cap_minutes": 1000 * 60},
    {"regime": "EASA", "metric": "duty",   "window_hours": 7 * 24,   "cap_minutes": 60 * 60},
    {"regime": "EASA", "metric": "duty",   "window_hours": 14 * 24,  "cap_minutes": 110 * 60},
    {"regime": "EASA", "metric": "duty",   "window_hours": 28 * 24,  "cap_minutes": 190 * 60},
    {"regime": "EASA", "metric": "flight", "window_hours": 28 * 24,  "cap_minutes": 100 * 60},
]

Verify: the list holds eight rolling caps (the 12-calendar-month EASA flight cap is evaluated separately because a calendar month is not a fixed number of hours); every cap_minutes is an exact integer.

Step 2 — Evaluate every window in one SQL pass

A window function with an interval-bounded RANGE frame sums the metric over each cap’s trailing window for every duty. Parameterising the frame length from the cap row lets one statement evaluate all the hour-based windows.

SELECT
    d.crew_id,
    d.duty_id,
    c.regime,
    c.metric,
    c.window_hours,
    SUM(
        CASE c.metric
            WHEN 'fdp'    THEN d.fdp_minutes
            WHEN 'flight' THEN d.flight_minutes
            ELSE d.duty_minutes
        END
    ) OVER (
        PARTITION BY d.crew_id
        ORDER BY d.report_utc
        RANGE BETWEEN (c.window_hours * INTERVAL '1 hour') PRECEDING AND CURRENT ROW
    ) AS window_minutes,
    c.cap_minutes,
    (SUM(/* … */ d.duty_minutes) OVER (/* … */)) > c.cap_minutes AS breached
FROM duty_period AS d
JOIN cumulative_cap AS c ON c.ruleset_id = current_ruleset(d.report_utc::date, c.regime);

Rows where window_minutes exceeds cap_minutes are the violations. Because the window length comes from the cap row, the same statement evaluates the 168-hour, 672-hour, and every EASA day window without a line of per-cap code.

Step 3 — The in-process Polars equivalent

When the caps are checked inside a pairing optimiser rather than a reporting job, Polars evaluates the same rolling windows in memory. Each cap becomes one rolling aggregation keyed on the crew and anchored on the UTC instant.

import polars as pl


def evaluate_caps(duties: pl.DataFrame, caps: list[dict]) -> pl.DataFrame:
    frames = []
    for cap in caps:
        col = {"fdp": "fdp_minutes", "flight": "flight_minutes", "duty": "duty_minutes"}[cap["metric"]]
        rolled = (
            duties.sort("report_utc")
            .rolling(index_column="report_utc", period=f"{cap['window_hours']}h", group_by="crew_id")
            .agg(pl.col(col).sum().alias("window_minutes"))
            .with_columns(
                pl.lit(cap["regime"]).alias("regime"),
                pl.lit(cap["window_hours"]).alias("window_hours"),
                (pl.col("window_minutes") > cap["cap_minutes"]).alias("breached"),
            )
        )
        frames.append(rolled)
    return pl.concat(frames)

Verify: for a crew with 112 duty hours in a 14-day window, evaluate_caps marks the window_hours == 336 EASA row breached = True (6720 minutes against the 6600-minute cap) while leaving the 7-day and 28-day rows clear — the same result the cumulative duty caps compared scenario reaches by hand.

Failure modes and troubleshooting

Frequently Asked Questions

Why store the window length as data instead of writing one query per cap?

Because the caps change and multiply. Storing the window and cap as rows lets one statement evaluate the current FAA and EASA sets and absorb a future amendment as an INSERT. Per-cap queries drift out of sync the moment a limit is revised.

Are the FAA 672-hour and EASA 28-day windows the same check?

No. Both are about four weeks, but the FAA window is an exact 672-hour clock metering FDP or flight hours, while the EASA window is a calendar-aligned 28-day window metering duty or flight. A roster can pass one and fail the other.

How is the 12-calendar-month EASA flight cap handled?

Separately, because a calendar month is not a fixed number of hours. It is evaluated by truncating report instants to months and summing flight minutes over a trailing twelve-month span, rather than with a fixed-hour rolling frame.

Do these caps change with augmentation?

No. Augmentation raises the daily FDP ceiling but leaves the cumulative caps untouched. An augmented block still meters against the same rolling windows seeded here.

Back to Regulatory Reference Tables.