Threshold Tuning & Alerting

Proactive compliance management in modern flight operations hinges on the precise calibration of operational limits and the timely delivery of actionable notifications. Threshold tuning and alerting translate the static regulatory boundaries enforced elsewhere in the Duty Time Validation & Rule Engines architecture into dynamic, context-aware guardrails that fire before a crew member is exposed to a violation. Rather than treating compliance as a post-assignment audit function, mature carriers embed configurable soft thresholds directly into pairing generation, roster publication, and irregular-operations recovery. This page scopes the specific engineering problem of turning hard regulatory caps into a tiered early-warning system: how the thresholds are modeled, how they map to the underlying FAR and EASA numbers, how they are evaluated over rolling windows without generating alert fatigue, and how the resulting signals route to schedulers without becoming an operational bottleneck.

The Scoped Problem: Turning Hard Caps Into Early Warnings

Every hard limit in a duty-time framework is a binary edge — a crew member is either inside §117.23’s cumulative caps or in violation of them. Schedulers cannot operate against binary edges alone, because by the time a hard block triggers, the disruptive decision (deadheading a replacement, cancelling a segment, calling reserve) is already expensive and late. The purpose of a threshold engine is to expose the approach to each edge as a continuous, monitorable quantity and to raise graduated signals at defensible margins below it.

The challenge is that a naive “warn at 90% of the limit” rule is worse than useless in production. It produces three failure classes that a real threshold engine exists to solve:

A correct threshold engine therefore separates three concerns that amateur implementations conflate: the limit (a regulatory constant), the margin (a tunable policy applied to that limit), and the alert lifecycle (the debounced, deduplicated, escalating state machine that decides whether a human is actually notified). The rest of this page builds each concern as an independently testable layer.

Schema Design: Modeling Limits, Margins, and Alert State

Effective threshold management begins with a decoupled rules layer that cleanly separates regulatory constants from operational policy. Hard limits — maximum flight duty periods, cumulative caps, minimum rest — are modeled as immutable constants sourced from the FAA Part 117 rule schema and its EASA equivalent. Margins are tunable policy rows layered on top, so a scheduling office can tighten a warning band without a code deploy and without ever mutating the regulatory value it references. Alert state is a third entity entirely: a stateful record that tracks whether a given crew/limit pair is currently informational, warning, critical, or cleared.

The entity relationships that make this separation enforceable are captured below.

Threshold engine schema entity-relationship diagram Four entities separate the three concerns of a threshold engine. RegulatoryLimit holds eCFR-derived caps (limit_code, window_seconds, cap_value, unit, jurisdiction) and is immutable and versioned. It constrains many ThresholdPolicy rows (policy_id, limit_code, info_pct, warn_pct, crit_pct, effective_from) — the only rows operators tune. Each ThresholdPolicy raises many AlertState rows (alert_id, policy_id, crew_id, tier, opened_at, cleared_at, last_value), the single mutable operational entity that tracks flapping and escalation. A CrewMember (crew_id, base, acclimatization_state) is the subject of many AlertState rows. constrains 1 N raises 1 N subject of 1 N RegulatoryLimit limit_codePK window_seconds cap_value unit jurisdiction ThresholdPolicy policy_idPK limit_codeFK info_pct warn_pct crit_pct effective_from AlertState alert_idPK policy_idFK crew_idFK tier opened_at cleared_at last_value CrewMember crew_idPK base acclimatization_state immutable · versioned operator-tunable mutable alert state
Three concerns kept in separate tables: an immutable regulatory cap, the tunable margin policy layered on it, and the single mutable per-crew alert record.

The RegulatoryLimit table holds the eCFR-derived numbers and never changes except through a versioned regulatory update; the ThresholdPolicy table holds info_pct, warn_pct, and crit_pct as decimals against that limit and carries an effective_from timestamp so tuning changes are themselves auditable. AlertState is the only mutable operational entity — one open row per (crew, limit) pair — which is what makes flapping suppression and escalation tractable. A tiered structure of informational (approaching the margin), warning, and critical (imminent or actual breach) is expressed purely as three columns on the policy row, so adding a fourth tier is a data change, not a schema migration.

Tiered alerting flow A rolling duty accumulation is compared against its hard regulatory limit as a percentage. At or above 80 percent it raises an informational scheduler flag; at or above 90 percent a warning to the dispatcher; at or above 100 percent a critical hard block. All three tiers fan into one routing layer that coalesces and delivers to scheduler dashboards, SMS gateways and the roster UI, so soft thresholds fire well before the hard limit blocks an assignment. ≥ 80% ≥ 90% ≥ 100% Duty accumulation rolling window sum % of hard limit Informational scheduler flag Warning dispatcher Critical hard block Routing layer dashboards · SMS · roster coalesced per dispatcher
Tiered alerting: soft thresholds fire informational and warning tiers before the hard regulatory limit triggers a block, then fan into one coalescing routing layer.

Regulatory Mapping: Which Limits Drive Which Thresholds

Threshold policy is only meaningful when each margin is anchored to a specific regulatory clause, because the shape of the risk differs by clause. Under FAA Part 117, the numbers a threshold engine most often guards are the cumulative limitations in §117.23. That section caps total flight time at 100 hours in any 672 consecutive hours and 1,000 hours in any 365 consecutive calendar days (§117.23(b)), and caps flight duty period at 60 FDP hours in any 168 consecutive hours and 190 FDP hours in any 672 consecutive hours (§117.23©). The unaugmented FDP table in §117.13, indexed by report time and number of segments, supplies the per-duty ceiling, while §117.25 governs the minimum rest that resets the shorter windows.

These clauses demand different margins. The 1,000-hour annual cap accumulates slowly, so an 80% informational trigger (800 hours) gives weeks of lead time and rarely flaps. The 60-hour-per-168-hour FDP cap moves fast during a heavy week, so a warning at 85% leaves only a few hours of slack and must be paired with tight debouncing. Encoding the margin as a per-limit policy row rather than a global constant is what makes this defensible. Carriers operating under both regimes reconcile these numbers against EASA FTL compliance frameworks, where ORO.FTL.210 and ORO.FTL.235 define analogous cumulative-duty and rest limits with different window widths — meaning the same crew member can carry two active ThresholdPolicy rows on two different RegulatoryLimit records, and the engine must surface whichever fires first. Mapping raw duty events onto the correct regulatory category before any percentage is computed depends on a consistent crew duty time taxonomy; a positioning leg mislabeled as flight time will corrupt the numerator against the wrong cap.

Python Implementation: Typed Policies and a Tiered Evaluator

A production-ready evaluator externalizes both the regulatory constants and the margins, consuming them at runtime rather than hardcoding percentages. Typed models make the three-layer separation explicit and give the validation suite something concrete to assert against. The following pydantic models capture a limit, a policy, and the structured verdict the evaluator emits.

from datetime import datetime, timedelta
from decimal import Decimal
from enum import Enum

from pydantic import BaseModel, Field, field_validator


class Tier(str, Enum):
    OK = "ok"
    INFO = "info"
    WARNING = "warning"
    CRITICAL = "critical"


class RegulatoryLimit(BaseModel):
    """Immutable, eCFR-derived cap. Never edited by schedulers."""

    limit_code: str            # e.g. "FAR117.23c1_FDP_168H"
    window: timedelta          # 168h, 672h, 365d ...
    cap_value: Decimal         # 60, 100, 190, 1000 ...
    unit: str                  # "fdp_hours" | "flight_hours"
    jurisdiction: str          # "FAA" | "EASA"


class ThresholdPolicy(BaseModel):
    """Tunable margins layered on a limit. Editable without a deploy."""

    limit_code: str
    info_pct: Decimal = Field(gt=0, le=1)
    warn_pct: Decimal = Field(gt=0, le=1)
    crit_pct: Decimal = Field(gt=0, le=1)
    effective_from: datetime

    @field_validator("crit_pct")
    @classmethod
    def _ordered(cls, crit: Decimal, info) -> Decimal:
        data = info.data
        if not (data["info_pct"] < data["warn_pct"] < crit):
            raise ValueError("margins must satisfy info < warn < crit")
        return crit


class Verdict(BaseModel):
    limit_code: str
    crew_id: str
    tier: Tier
    ratio: Decimal             # accumulated / cap_value
    accumulated: Decimal
    cap_value: Decimal
    evaluated_at: datetime

The evaluator itself is deliberately pure: given an accumulated value, a limit, and a policy, it returns a Verdict with no side effects. Keeping it stateless is what allows the same function to run inside a synchronous roster-build check and inside an asynchronous event worker without divergence.

def evaluate(
    accumulated: Decimal,
    limit: RegulatoryLimit,
    policy: ThresholdPolicy,
    crew_id: str,
    now: datetime,
) -> Verdict:
    ratio = (accumulated / limit.cap_value).quantize(Decimal("0.0001"))
    if ratio >= policy.crit_pct:
        tier = Tier.CRITICAL
    elif ratio >= policy.warn_pct:
        tier = Tier.WARNING
    elif ratio >= policy.info_pct:
        tier = Tier.INFO
    else:
        tier = Tier.OK
    return Verdict(
        limit_code=limit.limit_code,
        crew_id=crew_id,
        tier=tier,
        ratio=ratio,
        accumulated=accumulated,
        cap_value=limit.cap_value,
        evaluated_at=now,
    )

Because evaluate is pure and deterministic, the alert lifecycle — flap suppression, deduplication, escalation — lives in a separate stateful layer that compares each new Verdict.tier against the stored AlertState. Hysteresis is the key technique: a threshold opens when the ratio rises through the margin, but only clears once it falls a configurable band below the margin, so a value oscillating on the boundary does not thrash.

CLEAR_HYSTERESIS = Decimal("0.03")  # 3 percentage points below entry


def transition(prev_tier: Tier, verdict: Verdict, policy: ThresholdPolicy) -> Tier:
    """Apply hysteresis so a boundary-hugging ratio does not flap."""
    entry = {
        Tier.INFO: policy.info_pct,
        Tier.WARNING: policy.warn_pct,
        Tier.CRITICAL: policy.crit_pct,
    }
    if verdict.tier.value >= prev_tier.value:
        return verdict.tier  # escalation is always immediate
    # De-escalate only once clearly below the previous tier's entry band.
    threshold = entry.get(prev_tier)
    if threshold is not None and verdict.ratio < (threshold - CLEAR_HYSTERESIS):
        return verdict.tier
    return prev_tier

Alert routing is decoupled from evaluation via the Observer pattern: the lifecycle layer emits tier-change events onto a channel, and independent subscribers deliver to scheduler dashboards, SMS gateways, and the roster UI. This keeps notification-delivery failures from ever blocking a compliance decision, and it lets the routing side apply its own coalescing so a fan-out storm collapses into one grouped digest per dispatcher.

Rolling Windows: Computing the Numerator Correctly

The accumulated value fed into evaluate is a rolling-window overlap sum, not a naive count of rows, because §117.23 and its EASA analogues measure duty within a sliding window whose left edge advances continuously. Threshold accuracy is fundamentally constrained by the temporal resolution of that sum. Misalignment between calculation granularity and the evaluation window is a frequent source of false positives, particularly for multi-segment pairings that cross midnight, observe daylight-saving transitions, or span multiple regions. Threshold evaluators must therefore synchronize with the same Flight Time Calculation Algorithms that partition block-to-block time from positioning and ground duty, so the numerator counts exactly what the regulation counts.

In PostgreSQL, the cumulative-duty numerator for a 168-hour window is a frame-bounded window aggregate over duty intervals normalized to UTC:

SELECT
    crew_id,
    duty_end,
    SUM(fdp_seconds) OVER (
        PARTITION BY crew_id
        ORDER BY duty_end
        RANGE BETWEEN INTERVAL '168 hours' PRECEDING AND CURRENT ROW
    ) / 3600.0 AS fdp_hours_168h
FROM duty_period
WHERE crew_id = $1
ORDER BY duty_end;

The equivalent in-memory computation for a worker that already holds the event stream uses a time-indexed rolling sum, which is cheaper to recompute incrementally on each ingested event than round-tripping to the database:

import polars as pl


def rolling_fdp_hours(events: pl.DataFrame, window: str = "168h") -> pl.DataFrame:
    return (
        events.sort("duty_end")
        .with_columns(
            pl.col("fdp_seconds")
            .rolling_sum_by("duty_end", window_size=window)
            .over("crew_id")
            .truediv(3600.0)
            .alias("fdp_hours_168h")
        )
    )

Python implementations must normalize every timestamp with zoneinfo and strict UTC anchoring before the window is applied; relying on naive datetime arithmetic is the classic anti-pattern that lets a DST transition silently shift a window edge by an hour and fire a spurious critical alert. The window’s closing edge matters as much as its width: evaluating at “now” versus at the end of the last completed duty period produces different ratios during an active duty, and the policy must state which convention it uses so alerts are reproducible.

Integration Points: Where the Threshold Engine Sits

A threshold engine is only as reliable as the data pipeline feeding it, and it is only useful when its outputs reach the systems that can act on them. Upstream, it consumes the normalized event stream produced by flight data ingestion: rostering databases, dynamic flight-plan updates, maintenance deferrals, and real-time crew-availability feeds are reconciled there into the UTC-anchored duty events the accumulators sum. While batch reconciliation still serves end-of-day compliance reporting, event-driven evaluation is mandatory for operational alerting — the moment a pairing is modified, delayed, or swapped, an ingestion event should publish a lightweight payload (updated duty block, crew identifier, applicable jurisdiction) onto a broker such as Kafka or RabbitMQ, and a stateless worker re-evaluates only the affected crew and limits.

Laterally, the engine composes with its sibling validators rather than duplicating their logic. It calls into Rest Period Compliance Checks so that a crossing of a rest margin resets the shorter cumulative windows correctly, and it layers Fatigue Risk Scoring Models onto tier transitions so a warning-tier ratio driven by consecutive early starts or red-eye rotations is prioritized above one driven by a single benign long day. Downstream, every tier change is written to an immutable, hash-chained audit ledger governed by the system security access boundaries that gate who may tune a ThresholdPolicy — because a margin change is a safety-relevant action and must be attributable. In extreme disruptions, a circuit breaker can suspend informational routing while preserving warning and critical delivery, keeping the system a decision-support tool rather than a source of noise when schedulers are already saturated.

Testing and Edge Cases

Threshold logic sits directly on regulatory boundaries, so its tests must target the boundaries rather than the middle of the range. Property-based testing with hypothesis is the natural fit: generate arbitrary duty-event histories and assert invariants that must hold for every input, not just hand-picked examples.

from decimal import Decimal

from hypothesis import given, strategies as st


@given(ratio=st.decimals(min_value=0, max_value=2, places=4))
def test_tiers_are_monotonic(ratio: Decimal) -> None:
    """Tier can only rise as the ratio rises; margins never invert."""
    v = evaluate(ratio * CAP, LIMIT, POLICY, crew_id="X", now=NOW)
    if ratio >= POLICY.crit_pct:
        assert v.tier is Tier.CRITICAL
    elif ratio < POLICY.info_pct:
        assert v.tier is Tier.OK

The edge cases that break naive threshold engines concentrate around time and precedence:

Regression suites should additionally replay historical rosters that produced known real-world violations and assert that the tuned margins would have raised a warning at least one duty period earlier — the operational definition of a well-tuned threshold.

Where to Go Deeper

This topic is the calibration and notification layer that sits above the raw validators; the implementation guides most relevant to building it live in the sibling areas of this section. For the numerator that every ratio depends on, work through calculating block time vs flight time in Python, which fixes the segment-partitioning that a mislabeled positioning leg would otherwise corrupt. For the window-reset behavior that lets short-window thresholds recover, automating 10-hour rest period validation shows the rest checks the threshold engine calls into. To ground the RegulatoryLimit constants in a durable store, how to map FAR 117 duty limits to database schemas covers the immutable, versioned limit tables this engine reads. Together these guides supply the numerators, the resets, and the caps that threshold tuning turns into timely, low-noise alerts.

Conclusion

Threshold tuning and alerting transform regulatory compliance from a retrospective audit into a proactive operational discipline. The discipline is in the separation of concerns: immutable regulatory limits, tunable per-limit margins, and a hysteresis-driven alert lifecycle that suppresses flapping and coalesces fan-out. When those layers are calibrated against the correct §117.23 and EASA windows, fed by an accurate rolling-window numerator, and wired into the surrounding validation and audit machinery, flight operations teams gain hours of lead time before a hard block — and the precision of that early warning becomes a defining factor in airline safety, efficiency, and crew well-being.