Validating 168-Hour Weekly Rest with SQL Window Functions

The exact task this guide solves is confirming, for every duty a crew member is assigned, that a qualifying long rest — at least 30 consecutive hours free from all duty — exists somewhere in the preceding 168 hours, exactly as §117.25(b) requires. This is the check a single-gap rest validator cannot see: a roster can satisfy every minimum-rest-before-duty comparison and still be illegal because no gap in the trailing week was long enough to count as the weekly rest. The rolling nature of the rule makes it far cleaner to express as a SQL window function than to hand-roll in application code. This page builds that query, handles the boundary cases, and reports the offending duties. It extends the rest period compliance checks and shares the rolling-window discipline of the wider duty time validation rule engines.

Prerequisites

Why the weekly-rest rule needs a rolling window

The rule is defined over any 168 consecutive hours, not a calendar week, so the check must ask, at each duty, whether the trailing week contained a rest of at least 30 hours. A calendar-week query aligned to Monday would pass a roster whose only long rest fell just outside the Monday boundary of the week that matters. The correct formulation anchors a 168-hour window at each duty’s report instant and takes the longest qualifying rest that ends within it.

Step 1 — Find the longest trailing rest per duty

A window function partitioned by crew, ordered by report instant, with a RANGE frame of 168 hours, computes the longest rest ending in each duty’s trailing week in a single pass.

SELECT
    d.crew_id,
    d.duty_id,
    d.report_utc,
    MAX(r.rest_hours) OVER (
        PARTITION BY d.crew_id
        ORDER BY d.report_utc
        RANGE BETWEEN INTERVAL '168 hours' PRECEDING AND CURRENT ROW
    ) AS longest_rest_in_window
FROM duty_period AS d
JOIN rest_period AS r
    ON r.crew_id = d.crew_id
   AND r.ends_utc <= d.report_utc
   AND r.ends_utc > d.report_utc - INTERVAL '168 hours';

Verify: a crew member whose longest trailing rest is 32 hours shows longest_rest_in_window = 32; one whose longest is 24 hours shows 24, which is the value the next step tests against the threshold.

Step 2 — Flag the weekly-rest violations

Wrapping the window query and comparing against the 30-hour threshold turns the longest-rest figure into a verdict. Duties whose trailing week held no 30-hour rest are the violations.

WITH windowed AS (
    SELECT
        d.crew_id, d.duty_id, d.report_utc,
        MAX(r.rest_hours) OVER (
            PARTITION BY d.crew_id ORDER BY d.report_utc
            RANGE BETWEEN INTERVAL '168 hours' PRECEDING AND CURRENT ROW
        ) AS longest_rest_in_window
    FROM duty_period AS d
    JOIN rest_period AS r
      ON r.crew_id = d.crew_id
     AND r.ends_utc <= d.report_utc
     AND r.ends_utc > d.report_utc - INTERVAL '168 hours'
)
SELECT crew_id, duty_id, report_utc, longest_rest_in_window
FROM windowed
WHERE longest_rest_in_window IS NULL         -- no rest at all in the window
   OR longest_rest_in_window < 30;           -- no qualifying long rest

Verify: the query returns exactly the duties whose preceding 168 hours lacked a 30-hour rest; a duty with a 30.0-hour rest in its window does not appear, and one with a 29.9-hour rest does.

Step 3 — Handle the empty-window boundary

A duty at the very start of a crew member’s history has no prior rest in its window, so the MAX is NULL. Treating NULL as a pass would silently exempt the first duties of every roster; treating it as a violation would flag legitimate history gaps. The correct behaviour is to distinguish “no data yet” from “data shows no qualifying rest”, which the IS NULL branch above surfaces explicitly for review rather than silently deciding.

-- Classify the boundary so it is reviewed, not silently passed or failed.
SELECT crew_id, duty_id,
       CASE
           WHEN longest_rest_in_window IS NULL THEN 'insufficient_history'
           WHEN longest_rest_in_window < 30    THEN 'weekly_rest_violation'
           ELSE 'compliant'
       END AS weekly_rest_status
FROM windowed;
Trailing 168-hour window and the qualifying weekly rest A duty at the right edge of a 168-hour trailing window. Within the window are several short rests and one long rest of 32 hours. Because at least one rest in the window reaches the 30-hour threshold, the duty satisfies the weekly-rest rule; had the longest been below 30 hours, it would be a violation. 168-hour trailing window duty report 11h 32 h — qualifies 12h 14h ✓ weekly rest met MAX(rest_hours) OVER (… RANGE 168h PRECEDING) ≥ 30
The window function takes the longest rest ending in the trailing 168 hours; one 32-hour rest satisfies the 30-hour weekly requirement even though every other gap is short.

Failure modes and troubleshooting

Frequently Asked Questions

Why a window function instead of application code?

Because the rule evaluates every duty against its own trailing week, which a single ordered pass with a RANGE frame does in the database far more efficiently than fetching each crew’s history and looping in Python. The window function also keeps the boundary semantics explicit and testable.

Does the weekly rest have to be a single block?

Yes. §117.25(b) requires 30 consecutive hours free from duty, so the check takes the longest single rest in the window, not the sum of several. Two 20-hour rests do not satisfy it.

How is a crew member’s first week handled?

Their earliest duties have no prior rest in the window, producing a NULL longest rest. That is classified as insufficient history and surfaced for review rather than being treated as either a pass or a violation.

Can this run inside a pairing optimiser?

The same rolling logic runs in Polars for in-process evaluation, but the SQL form is ideal for the nightly compliance sweep where the whole roster is already in the database. Both read the same 30-hour, 168-hour parameters.

Back to Rest Period Compliance Checks.