FAA Part 117 Table B FDP Limits as Queryable Data
The exact task this guide solves is turning the §117.13 Table B grid — the ten report-time bands and seven segment columns that set the maximum unaugmented flight duty period — from a printed table into effective-dated rows a rule engine can query in constant time. Table B is the single most-consulted lookup in an FAA compliance system, and inlining its ninety values as nested conditionals is how engines end up with an off-by-one on a band boundary that silently returns the wrong ceiling. This page seeds the whole grid as data, implements the band-and-segment resolver, and pins every cell with assertions. It is the concrete reference behind the regulatory reference tables section and feeds the same numbers the FAA Part 117 rule schema design consumes.
Prerequisites
- Python 3.11+ — for
datetime.timeand typed models. - The reference schema in place — an
fdp_limit_celltable keyed by ruleset, band, and segment, as defined in the regulatory reference tables. - Acclimatised report time — the crew member’s local time in their acclimatised theatre.
- The current §117.13 amendment — record the eCFR amendment date the seed reflects.
The full Table B grid
Table B gives the maximum flight duty period in hours for an acclimatised crew member in unaugmented operations, keyed on the scheduled start time (acclimatised) and the number of flight segments.
| Start (acclimated) | 1 | 2 | 3 | 4 | 5 | 6 | 7+ |
|---|---|---|---|---|---|---|---|
| 0000–0359 | 9 | 9 | 9 | 9 | 9 | 9 | 9 |
| 0400–0459 | 10 | 10 | 10 | 10 | 9 | 9 | 9 |
| 0500–0559 | 12 | 12 | 12 | 12 | 11.5 | 11 | 10.5 |
| 0600–0659 | 13 | 13 | 12 | 12 | 11.5 | 11 | 10.5 |
| 0700–1159 | 14 | 14 | 13 | 13 | 12.5 | 12 | 11.5 |
| 1200–1259 | 13 | 13 | 13 | 13 | 12.5 | 12 | 11.5 |
| 1300–1659 | 12 | 12 | 12 | 12 | 11.5 | 11 | 10.5 |
| 1700–2159 | 12 | 12 | 11 | 11 | 10 | 9 | 9 |
| 2200–2259 | 11 | 10 | 10 | 9 | 9 | 9 | 9 |
| 2300–2359 | 10 | 10 | 9 | 9 | 9 | 9 | 9 |
Every value is a whole or half hour, so all ninety cells are exact integer minutes — the 11.5-hour cells are 690 minutes, the 12.5-hour cells 750 minutes — which is why the reference layer stores minutes, never float hours.
Step 1 — Seed the grid as rows
Each band becomes a row keyed by its inclusive start and exclusive end, carrying the seven segment values in minutes. Encoding the band edges as time objects lets the resolver compare directly without string parsing.
from datetime import time
# (band_start, band_end_exclusive): [seg1..seg7] in minutes
TABLE_B = {
(time(0, 0), time(4, 0)): [540, 540, 540, 540, 540, 540, 540],
(time(4, 0), time(5, 0)): [600, 600, 600, 600, 540, 540, 540],
(time(5, 0), time(6, 0)): [720, 720, 720, 720, 690, 660, 630],
(time(6, 0), time(7, 0)): [780, 780, 720, 720, 690, 660, 630],
(time(7, 0), time(12, 0)): [840, 840, 780, 780, 750, 720, 690],
(time(12, 0), time(13, 0)):[780, 780, 780, 780, 750, 720, 690],
(time(13, 0), time(17, 0)):[720, 720, 720, 720, 690, 660, 630],
(time(17, 0), time(22, 0)):[720, 720, 660, 660, 600, 540, 540],
(time(22, 0), time(23, 0)):[660, 600, 600, 540, 540, 540, 540],
(time(23, 0), time(0, 0)): [600, 600, 540, 540, 540, 540, 540],
}
Verify: the 0700–1159 row reads [840, 840, 780, 780, 750, 720, 690], i.e. 14:00, 14:00, 13:00, 13:00, 12:30, 12:00, 11:30 — the busiest daytime band where the ceiling is highest.
Step 2 — Resolve a band and clamp the segment count
The resolver finds the band covering the report time and reads the segment column, clamping any leg count above seven down to the 7+ column. The final 2300–2359 band wraps midnight, so it matches when the report is at or after 23:00 or before its exclusive end of 00:00.
def resolve_table_b(report: time, segments: int) -> int:
"""Maximum unaugmented FDP in minutes for an acclimatised report and leg count."""
seg_index = min(max(segments, 1), 7) - 1
for (lo, hi), minutes in TABLE_B.items():
covers = (lo <= report < hi) if lo < hi else (report >= lo or report < hi)
if covers:
return minutes[seg_index]
raise LookupError(f"no Table B band covers {report!r}")
Verify: resolve_table_b(time(5, 30), 4) returns 720 (12:00). A twelve-segment day clamps to the 7+ column, so resolve_table_b(time(5, 30), 12) returns 630 (10:30), identical to resolve_table_b(time(5, 30), 7) — the clamp collapses every leg count above seven onto the final column.
Step 3 — Assert every boundary
The cells that break in production are the band edges, so the assertions pin both sides of each boundary rather than sampling the interior.
# Band boundaries are inclusive-low, exclusive-high.
assert resolve_table_b(time(5, 0), 1) == 720 # first instant of 0500-0559
assert resolve_table_b(time(4, 59), 1) == 600 # last instant of 0400-0459
assert resolve_table_b(time(7, 0), 1) == 840 # daytime peak opens at 0700
assert resolve_table_b(time(11, 59), 7) == 690 # still in the 0700-1159 band
assert resolve_table_b(time(23, 30), 3) == 540 # midnight-wrapping band
assert resolve_table_b(time(0, 0), 1) == 540 # 0000 opens the first band
Each assertion fixes a boundary a naive <=/< swap would move by one minute — the failure mode that returns a neighbouring band’s ceiling and, once a quarter, an illegal duty day.
Failure modes and troubleshooting
- Off-by-one on a band edge. Swapping an inclusive low for an exclusive one moves a report at exactly 05:00 into the wrong band. Remediation: keep bands inclusive-low, exclusive-high, and assert both edges as above.
- Forgetting the midnight wrap. The 2300–2359 band’s exclusive end is 00:00, which sorts before its start; a naive
lo <= t < hinever matches it. Remediation: special-caselo > hito matcht >= lo or t < hi. - Feeding departure-station time. Table B keys on acclimatised time; using the origin wall clock for a crew acclimatised elsewhere reads the wrong band. Remediation: resolve the acclimatised theatre first.
- Storing float hours. A 12.5-hour cell stored as
12.5accumulates rounding error over a fleet sweep. Remediation: store750minutes. - Treating 7+ as exactly 7. A nine-segment day is legal and reads the 7+ column; raising on segments above seven rejects valid input. Remediation: clamp to seven, never raise.
Frequently Asked Questions
Does Table B apply to augmented operations?
No. Table B is the unaugmented grid under §117.13. Augmented operations with a relief crew use the separate Table C under §117.17, which is seeded as its own effective-dated grid and resolved by rest-facility class, not by this resolver.
Why is the ceiling highest for a 0700–1159 report?
Because a mid-morning report avoids encroaching on the window of circadian low, so the regulation permits the longest duty day — up to 14 hours for one or two segments. Reports before 05:00 or after 22:00 clip the WOCL and are held near the 9-hour floor.
What does the 7+ column mean for a ten-segment day?
The same limit as a seven-segment day. Table B stops distinguishing segment counts at seven, so every day with seven or more segments reads the final column. The resolver enforces this by clamping.
How do I roll the grid forward when the FAA amends it?
Insert a new effective-dated ruleset with the amended cells and set the previous ruleset’s effective_to. The resolver selects by operation date, so historical verdicts continue to resolve against the grid that was in force when the schedule was published.
Related
- Regulatory Reference Tables — the parent section and schema this grid seeds.
- Cumulative Duty and Flight-Time Caps Reference — the rolling-window caps that sit alongside Table B.
- FAA Part 117 Rule Schema Design — how the grid becomes part of the versioned rule set.
- Maximum FDP for an Early Morning Report — Table B applied against the EASA table in a worked scenario.
Back to Regulatory Reference Tables.