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
- Python 3.11+ with timezone-aware
datetime. - A crew duty history ordered by UTC report instant, with per-duty FDP and flight minutes.
- The §117.23 caps seeded as
(metric, window_hours, cap_minutes)rows from the regulatory reference tables. - The current §117.23 amendment recorded as the ruleset effective date.
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
Failure modes and troubleshooting
- Checking calendar weeks instead of rolling windows. A Monday–Sunday sum can pass while a Wednesday-anchored 168-hour window fails. Remediation: anchor a window at every duty, never on the calendar.
- Summing on local time. A date-line crossing reorders duties by local clock and corrupts the running sum. Remediation: sort and window on the UTC instant.
- Recomputing the whole history each run. Re-summing every window on every sweep is quadratic for large crews. Remediation: use the two-pointer sweep, which is linear, and cache the worst window per crew for incremental updates.
- Reporting the window total without the tipping duty. A verdict that says “over by two hours” but not which duty tipped it forces the scheduler to guess. Remediation: return the anchor and the tipping contribution.
- Boundary duty dropped. A duty whose report is exactly
window_hoursbefore the anchor sits on the inclusive edge; an off-by-one drops it. Remediation: use> anchor - spanfor the trailing edge and test the boundary duty.
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.
Related
- FAA Part 117 Rule Schema Design — the parent section and versioned rule set.
- Encoding §117.13 Table B Lookups in SQL — the daily-ceiling lookup beneath these cumulative caps.
- Cumulative Duty and Flight-Time Caps Reference — the seeded caps this evaluation reads.
- Duty Time Validation & Rule Engines — the rolling-window engines this feeds.
Back to FAA Part 117 Rule Schema Design.