Modeling §117.23 Cumulative Duty Limits with Rolling Windows

The exact task this guide solves is enforcing the §117.23 cumulative caps correctly — as limits over any consecutive window, not calendar weeks — and pinpointing the specific duty whose addition tips a window past its cap so a scheduler can fix the real constraint. §117.23 caps flight-duty-period hours at 60 in any 168 consecutive hours and 190 in any 672 consecutive hours, and flight hours at 100 in any 672 consecutive hours. The word any is the whole difficulty: a roster that passes every Monday-to-Sunday check can still breach a window that starts on a Wednesday. This page models the windows, evaluates them incrementally, and returns the tipping duty. It implements part of the FAA Part 117 rule schema design and shares its rolling machinery with the duty time validation rule engines.

Prerequisites

Step 1 — Model “any consecutive window” correctly

The cap is not on a fixed calendar window; it is on every trailing window that ends at a duty. The correct evaluation anchors a window at each duty’s report instant and sums the metric over the preceding window_hours. Because the sum can only rise as duties are added and fall as they age out of the window, a two-pointer sweep evaluates every anchor in linear time.

from dataclasses import dataclass
from datetime import datetime, timedelta


@dataclass(frozen=True, slots=True)
class Duty:
    duty_id: str
    report_utc: datetime
    fdp_minutes: int
    flight_minutes: int


def worst_trailing_window(duties: list[Duty], metric: str, window_hours: int):
    """Return (worst_minutes, anchor_duty) over any trailing window."""
    span = timedelta(hours=window_hours)
    duties = sorted(duties, key=lambda d: d.report_utc)
    start = 0
    running = 0
    worst, worst_anchor = 0, None
    for end, anchor in enumerate(duties):
        running += getattr(anchor, metric)
        while duties[start].report_utc <= anchor.report_utc - span:
            running -= getattr(duties[start], metric)
            start += 1
        if running > worst:
            worst, worst_anchor = running, anchor
    return worst, worst_anchor

Verify: for a history whose densest 168-hour window sums to 3480 FDP minutes, worst_trailing_window(duties, "fdp_minutes", 168) returns (3480, <that duty>) — the anchor is the duty that closes the worst window.

Step 2 — Evaluate every cap and flag breaches

Each seeded cap runs through the same sweep. Comparing the worst window against the cap gives a verdict, and carrying the anchor duty makes the verdict actionable.

CAPS_117_23 = [
    ("fdp_minutes", 168, 60 * 60),
    ("fdp_minutes", 672, 190 * 60),
    ("flight_minutes", 672, 100 * 60),
]


def evaluate_117_23(duties: list[Duty]) -> list[dict]:
    verdicts = []
    for metric, window_hours, cap_minutes in CAPS_117_23:
        worst, anchor = worst_trailing_window(duties, metric, window_hours)
        verdicts.append({
            "metric": metric,
            "window_hours": window_hours,
            "worst_minutes": worst,
            "cap_minutes": cap_minutes,
            "breached": worst > cap_minutes,
            "tipping_duty": anchor.duty_id if worst > cap_minutes else None,
        })
    return verdicts

Verify: a roster with 61 FDP hours in its worst 168-hour window returns breached=True for the first cap with tipping_duty set to the duty that closed the window — the one a scheduler must move or shorten.

Step 3 — Find the exact tipping duty

Knowing a window is over is not enough; the scheduler needs the duty whose removal brings the window back under the cap. Within the worst window, the tipping duty is the last one added before the sum crossed the cap. Reconstructing it from the anchor and the window contents makes the remediation precise.

def tipping_contribution(duties: list[Duty], anchor: Duty, metric: str,
                         window_hours: int, cap_minutes: int) -> Duty | None:
    span = timedelta(hours=window_hours)
    window = [d for d in duties
              if anchor.report_utc - span < d.report_utc <= anchor.report_utc]
    running = 0
    for d in sorted(window, key=lambda d: d.report_utc):
        running += getattr(d, metric)
        if running > cap_minutes:
            return d      # first duty that pushes the window over the cap
    return None
Sliding 168-hour window and the tipping duty A row of duties along a time axis. A 168-hour trailing window slides right, summing flight-duty-period hours. While the sum stays at or below 60 hours the window is compliant; when a new duty pushes the sum above 60 the window breaches, and that duty is marked as the tipping contribution the scheduler must remove. time → tips over 168 h window sum ≤ 60 h · ok 168 h window sum > 60 h · breach
The 168-hour window slides across the history; it is compliant until a new duty pushes the trailing sum past 60 hours, and that duty — not the calendar week — is the constraint to fix.

Failure modes and troubleshooting

Frequently Asked Questions

What makes “any consecutive window” harder than a weekly check?

A weekly check evaluates a fixed number of windows aligned to the calendar. “Any consecutive window” means every possible 168-hour span must satisfy the cap, so the evaluation must anchor a window at each duty. The two-pointer sweep does this in linear time without enumerating every possible start.

Why return the tipping duty rather than just the total?

Because the total tells a scheduler the window is over but not what to change. The tipping duty is the specific assignment whose removal brings the window back under the cap, which turns an abstract breach into a concrete rebuild action.

Do the 168-hour and 672-hour windows interact?

They are independent caps evaluated by the same sweep with different parameters. A roster can pass the 168-hour FDP cap and fail the 672-hour one, or vice versa, so both are always evaluated.

How does this relate to the EASA cumulative caps?

EASA meters duty over rolling days rather than FDP over rolling hours, so the windows and metrics differ. The same sweep evaluates both once each regime’s caps are seeded, as shown in the cumulative duty caps compared scenario.

Back to FAA Part 117 Rule Schema Design.