Scoring Sleep Debt from Duty History with Pandas

The exact task this guide solves is turning a crew member’s sequence of rest windows into a running sleep-debt figure that a fatigue model can consume, using pandas to keep the whole history in one vectorised computation. Sleep debt is the accumulated shortfall between the sleep a crew member needed and the sleep opportunity they actually had, and it is the single most useful summary of chronic fatigue risk — a crew can be legal on every individual rest yet be carrying days of accumulated debt. This page derives sleep opportunity from rest windows, applies a nightly need, accumulates the debt with a bounded recovery model, and produces a per-day series. It feeds the homeostatic input of the three-process fatigue model within the fatigue risk scoring models section.

Prerequisites

From rest window to sleep opportunity to debt

A rest window is not sleep. Some of it is spent commuting to accommodation, winding down, and waking, and an interrupted window yields less usable sleep still. The model therefore estimates sleep opportunity as the window duration scaled by an efficiency factor and discounted for interruptions, then compares it against the nightly need. Each day’s shortfall adds to a running debt, and days with surplus sleep pay some of it back — but recovery is bounded, because one long sleep does not erase a week of deprivation.

Step 1 — Estimate sleep opportunity per rest

Working in a pandas frame, the sleep opportunity is the window hours times efficiency, reduced further when the window was interrupted.

import pandas as pd


def sleep_opportunity(rests: pd.DataFrame, efficiency: float = 0.85,
                      interrupt_penalty: float = 0.75) -> pd.DataFrame:
    df = rests.copy()
    df["window_hours"] = (
        (df["ends_utc"] - df["starts_utc"]).dt.total_seconds() / 3600.0
    )
    df["sleep_hours"] = df["window_hours"] * efficiency
    df.loc[df["interrupted"], "sleep_hours"] *= interrupt_penalty
    return df

Verify: a clean 10-hour rest yields 10 * 0.85 = 8.5 sleep hours; the same window flagged interrupted yields 8.5 * 0.75 ≈ 6.4 — the interruption pushes an apparently adequate rest below the nightly need.

Step 2 — Reduce to a daily series and compute the shortfall

Fatigue accumulates by day, so the per-rest sleep hours are aggregated to the acclimatised calendar day, and each day’s shortfall is the nightly need minus the sleep obtained.

def daily_shortfall(rests: pd.DataFrame, nightly_need: float = 8.0) -> pd.DataFrame:
    df = sleep_opportunity(rests)
    df["day"] = df["ends_utc"].dt.tz_convert("UTC").dt.floor("D")
    daily = df.groupby("day", as_index=False)["sleep_hours"].sum()
    daily["shortfall"] = nightly_need - daily["sleep_hours"]
    return daily

Verify: a day with a single 6.4-hour interrupted sleep shows a shortfall of 1.6 hours against an 8-hour need; a day with 9 hours of sleep shows a negative shortfall of -1.0, i.e. a surplus available for recovery.

Step 3 — Accumulate debt with bounded recovery

The running debt adds each day’s shortfall but caps how much a surplus can repay, so a single long sleep cannot wipe out chronic debt, and the debt never goes negative. A bounded cumulative loop expresses this cleanly.

def accumulate_debt(daily: pd.DataFrame, max_recovery_per_day: float = 2.0) -> pd.DataFrame:
    debt = 0.0
    out = []
    for shortfall in daily["shortfall"]:
        if shortfall > 0:
            debt += shortfall                          # deprivation adds fully
        else:
            debt += max(shortfall, -max_recovery_per_day)  # recovery is capped
        debt = max(debt, 0.0)                          # debt cannot be negative
        out.append(round(debt, 2))
    result = daily.copy()
    result["cumulative_debt"] = out
    return result

Verify: three consecutive 1.6-hour shortfalls build a debt of 4.8 hours; a following 9-hour night (a −1.0 surplus) reduces it to 3.8, not to zero — the cap keeps one good night from erasing three bad ones.

Cumulative sleep debt with bounded recovery A bar-and-line chart over six days. Daily bars show sleep shortfall or surplus; the line shows cumulative sleep debt. Debt rises through several deprived days, then a single surplus day repays only a capped amount, so the debt declines partially rather than resetting to zero. debt 0 D1D2D3 D4D5D6 surplus repays only the capped amount cumulative debt
Debt climbs through deprived days; a single surplus night repays only the capped recovery amount, so chronic debt persists — exactly the signal a duty-time check alone cannot see.

Failure modes and troubleshooting

Frequently Asked Questions

Why is sleep debt more useful than a single rest check?

Because a crew member can pass every individual minimum-rest check while accumulating days of shortfall. Sleep debt summarises the chronic picture that per-rest checks miss, which is what distinguishes a sustainable roster from a legal-but-exhausting one.

How is sleep opportunity estimated from a rest window?

By scaling the window duration by an efficiency factor — the realistic fraction slept after commuting and settling — and discounting further when the window was interrupted. The result is an estimate of usable sleep, not clock time in the accommodation.

Why cap recovery from a surplus day?

Because physiology does not repay chronic debt in a single night. Capping the daily recovery models the fact that one long sleep helps but does not erase a week of deprivation, keeping the debt figure honest.

Does this replace the three-process model?

No — it feeds it. The cumulative debt informs the homeostatic starting point of the three-process model, which then predicts alertness across a specific duty. Debt is the chronic input; the three-process curve is the acute prediction.

Back to Fatigue Risk Scoring Models.