Handling EAS Emergency Interrupts in Ad Scheduling

When the Emergency Alert System fires, it does not ask the traffic department’s permission. An Emergency Action Notification, a state-level tornado warning relayed over IPAWS, or a Required Weekly Test seizes the station’s audio and video path through the ENDEC and overrides whatever is on air — including a fully-sold commercial break. That override is federally mandated under FCC Part 11, so the station cannot prevent it; it can only account for it. The spots that were airing, or were scheduled to air, inside the interrupt window did not deliver. Each one is now a contractual debt that must be detected, marked, routed to compensatory inventory, and reconciled against the as-run log — automatically, because a busy severe-weather night can generate dozens of activations and no traffic manager can chase them by hand before the billing cycle closes. This page treats EAS interrupt handling as a deterministic subsystem within Spot Scheduling Validation & Rule Engines: it ingests an activation signal, computes the interrupt window, identifies displaced spots by overlap, and emits an immutable reconciliation record that feeds make-good routing and billing. It is written for two readers at once — traffic managers who need the plain-language reasoning behind each disposition, and Python automation builders who need a deployable module with strict typing, Pydantic v2 validation, and timezone-aware UTC handling throughout.

EAS interrupt handling sits between the playout telemetry that reports what aired and the recovery logic that makes advertisers whole. It hands its output directly to make-good routing for preemptions, which treats an EAS activation as a non-negotiable, always-eligible preemption reason, and it feeds the same immutable ledger consumed by as-run reconciliation and discrepancy handling. Where the interrupt collides with a signalled ad break, the splice boundaries come from SCTE-35 ad signaling, and every displaced spot carries the normalized code assigned by billing code normalization so the recovery bills against the same account it debited.

Concept & Data Model

An EAS interrupt is a bounded window of federally mandated preemption. Everything downstream depends on pinning that window to a precise start and end in UTC, because the set of displaced spots is nothing more than the schedule intersected with that interval. The subsystem operates on four entities, each with a stable schema so detection stays decoupled from routing and billing.

Entity Purpose Key fields Constraints
EASActivation The normalized alert signal from the ENDEC / IPAWS decoder activation_id, event_code, originator, sent_at, expires_at event_code from FCC vocabulary; sent_at timezone-aware UTC
InterruptWindow The realized on-air preemption interval window_id, activation_id, started_at, ended_at, duration_ms ended_at > started_at; both UTC; duration_ms > 0
DisplacedSpot A scheduled spot the window overlapped spot_id, house_number, isci, scheduled_start, overlap_ms, disposition overlap_ms > 0; disposition in a fixed enum
ReconciliationEntry The immutable record tying activation → spot → recovery entry_id, activation_id, spot_id, overlap_ms, makegood_eligible, recorded_at append-only; entry_id deterministic

The event_code field draws from the FCC’s SAME vocabulary — EAN (Emergency Action Notification, the national presidential alert), NPT (National Periodic Test), RMT (Required Monthly Test), RWT (Required Weekly Test), and threat codes such as TOR (tornado warning) or SVR (severe thunderstorm). The originator distinguishes the source: PEP for a national Primary Entry Point, WXR for the National Weather Service, CIV for civil authorities, and EAS for another broadcast participant. These two fields together decide the interrupt’s priority tier and whether displaced spots are make-good eligible. An EAN is the highest tier — it preempts everything, may run indefinitely, and always owes make-goods on displaced commercial inventory. A RWT is a scheduled test of at most a few seconds; it still displaces a spot if it lands inside a break, but its disposition and eligibility are governed by the order terms rather than an automatic federal debt.

The disposition on a displaced spot is not binary. A spot whose entire scheduled duration fell inside the window is FULLY_DISPLACED; one the window clipped at the head or tail is PARTIALLY_DISPLACED; a zero- or negative-overlap match that survives to this stage is NO_OVERLAP and is dropped before it can create a phantom debt. Partial displacement matters because a spot that aired 25 of its 30 seconds before the alert cut in may still count as delivered under some contracts and as a make-good under others — the disposition carries that nuance forward instead of collapsing it.

Every spot in an active break moves through a small state machine. It begins AIRING; when an activation is detected it enters an INTERRUPTED break; once the window is finalized and the overlap computed it becomes DISPLACED; from there a make-good-eligible spot is ROUTED to recovery while every displaced spot — routed or not — is RECONCILED into the immutable ledger.

EAS interrupt state machine from airing spot to reconciled record A spot begins in the AIRING state. When an EAS activation is detected it enters the INTERRUPTED state as the alert seizes the on-air path. When the interrupt window closes at end-of-message and its overlap with the spot is computed, the spot becomes DISPLACED. A decision gate asks whether the spot is make-good recoverable. Recoverable spots are ROUTED to make-good recovery and then RECONCILED into the immutable ledger. Non-recoverable spots, such as those displaced by a scheduled test under order terms that owe no debt, pass straight to RECONCILED. Every displaced spot ends in the RECONCILED state regardless of routing outcome, so the as-run ledger records the full set. AIRING spot on the break INTERRUPTED EAS seizes air path DISPLACED overlap computed activation EOM make-good eligible? yes ROUTED to make-good recovery no RECONCILED immutable ledger entry always logged

Figure — EAS interrupt state machine: a spot moves from AIRING through INTERRUPTED to DISPLACED once the window is finalized; recoverable spots are ROUTED to make-good, and every displaced spot is RECONCILED into the immutable ledger.

Implementation Approach

Three design decisions shape a production EAS interrupt handler, and each is a trade-off worth stating explicitly.

Window-first, spot-second. The temptation is to react to each preemption telemetry event spot by spot. That fails on an EAN that runs for twenty minutes across three breaks, because there is no single “the spot stopped” event — there is one alert that spans many placements. The handler instead materializes the InterruptWindow first, from the activation’s sent_at and the decoder’s end-of-message signal, then intersects that fixed interval against the schedule. Displacement becomes a set operation over a known window rather than a race against streaming events, which makes the result deterministic and replayable.

Overlap in milliseconds, boundaries in UTC. A spot is displaced only to the degree the window actually covered it, and that degree drives the disposition and any pro-rated billing credit. Both the window and the schedule are normalized to timezone-aware UTC before any arithmetic runs, because a naive local timestamp that crosses a Daylight Saving boundary distorts the overlap by an hour and can silently flip a FULLY_DISPLACED spot to NO_OVERLAP. Every model rejects naive datetimes at the boundary rather than guessing a zone.

Detect and reconcile here; recover elsewhere. The handler’s job ends at an immutable ReconciliationEntry and a routing hand-off. It never mutates playout and never places the make-good itself — that belongs to make-good routing, which owns eligibility scoring and inventory selection. Keeping detection pure means the same activation replayed during an audit produces byte-identical displaced-spot sets and ledger entries, which is the property FCC public-inspection and SOC 2 reproducibility reviews depend on. It also keeps the handler stateless enough to scale horizontally across a severe-weather surge.

Before any of this, the handler validates that the activation is real and well-formed. An unrecognized event_code, an expires_at earlier than sent_at, or a naive timestamp is a hard rejection routed to a dead-letter path, never a best-effort guess — because a phantom window fabricates debts, and a missed window hides real ones.

Production Python Implementation

The module below is deployable as a library or a worker. It uses Pydantic v2 for schema enforcement, strict type hints throughout, deterministic entry keys so replays are idempotent, and structured logging in the traffic-ops format timestamp | level | module | spot_id.

python
from __future__ import annotations

import hashlib
import logging
from datetime import datetime, timezone
from enum import Enum

from pydantic import BaseModel, Field, field_validator, model_validator

# --- Structured logging: timestamp | level | module | spot_id ---------------
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("eas_interrupt_handler")


def _log(level: int, spot_id: str, msg: str) -> None:
    # Prepend the spot_id so every audit line is greppable by placement.
    logger.log(level, "%s | %s", spot_id, msg)


class EventCode(str, Enum):
    EAN = "EAN"    # Emergency Action Notification — national, indefinite, top tier
    NPT = "NPT"    # National Periodic Test
    RMT = "RMT"    # Required Monthly Test
    RWT = "RWT"    # Required Weekly Test — short, scheduled
    TOR = "TOR"    # Tornado warning
    SVR = "SVR"    # Severe thunderstorm warning


class Disposition(str, Enum):
    FULLY_DISPLACED = "fully_displaced"
    PARTIALLY_DISPLACED = "partially_displaced"
    NO_OVERLAP = "no_overlap"          # Dropped before it can create a debt


# Codes that always owe a make-good on displaced commercial inventory.
_ALWAYS_ELIGIBLE: frozenset[EventCode] = frozenset(
    {EventCode.EAN, EventCode.TOR, EventCode.SVR}
)


def _require_utc(v: datetime) -> datetime:
    # Naive datetimes silently corrupt overlap arithmetic across DST.
    if v.tzinfo is None:
        raise ValueError("timestamp must be timezone-aware (UTC)")
    return v.astimezone(timezone.utc)


class EASActivation(BaseModel):
    activation_id: str
    event_code: EventCode
    originator: str                    # PEP, WXR, CIV, EAS
    sent_at: datetime                  # When the alert seized the air path
    expires_at: datetime               # SAME-header validity horizon

    _v_sent = field_validator("sent_at")(classmethod(lambda cls, v: _require_utc(v)))
    _v_exp = field_validator("expires_at")(classmethod(lambda cls, v: _require_utc(v)))

    @model_validator(mode="after")
    def _check_ordering(self) -> "EASActivation":
        if self.expires_at <= self.sent_at:
            raise ValueError("expires_at must be after sent_at")
        return self


class InterruptWindow(BaseModel):
    window_id: str
    activation_id: str
    started_at: datetime               # sent_at of the activation
    ended_at: datetime                 # end-of-message from the ENDEC decoder

    _v_start = field_validator("started_at")(classmethod(lambda cls, v: _require_utc(v)))
    _v_end = field_validator("ended_at")(classmethod(lambda cls, v: _require_utc(v)))

    @model_validator(mode="after")
    def _positive_duration(self) -> "InterruptWindow":
        if self.ended_at <= self.started_at:
            raise ValueError("ended_at must be after started_at")
        return self

    @property
    def duration_ms(self) -> int:
        return int((self.ended_at - self.started_at).total_seconds() * 1000)


class ScheduledSpot(BaseModel):
    spot_id: str
    house_number: str
    isci: str
    scheduled_start: datetime
    duration_ms: int = Field(gt=0)     # Zero-duration spots are malformed

    _v_start = field_validator("scheduled_start")(
        classmethod(lambda cls, v: _require_utc(v))
    )

    @property
    def scheduled_end(self) -> datetime:
        from datetime import timedelta
        return self.scheduled_start + timedelta(milliseconds=self.duration_ms)


class DisplacedSpot(BaseModel):
    spot_id: str
    house_number: str
    isci: str
    scheduled_start: datetime
    overlap_ms: int = Field(gt=0)
    disposition: Disposition


class ReconciliationEntry(BaseModel):
    entry_id: str                      # Deterministic: replay yields the same key
    activation_id: str
    spot_id: str
    event_code: EventCode
    overlap_ms: int
    makegood_eligible: bool
    recorded_at: datetime = Field(
        default_factory=lambda: datetime.now(timezone.utc)
    )


class EASInterruptHandler:
    """Detect displaced spots for an EAS window and emit an immutable ledger."""

    def __init__(self) -> None:
        self._entries: list[ReconciliationEntry] = []

    @staticmethod
    def _entry_id(activation_id: str, spot_id: str) -> str:
        # Deterministic key so a replayed activation reconciles idempotently.
        seed = f"{activation_id}:{spot_id}"
        return hashlib.sha256(seed.encode()).hexdigest()[:24]

    @staticmethod
    def _overlap_ms(window: InterruptWindow, spot: ScheduledSpot) -> int:
        # Intersect [spot_start, spot_end) with [window_start, window_end).
        latest_start = max(window.started_at, spot.scheduled_start)
        earliest_end = min(window.ended_at, spot.scheduled_end)
        delta = (earliest_end - latest_start).total_seconds() * 1000
        return int(delta) if delta > 0 else 0

    def _classify(self, overlap_ms: int, spot_duration_ms: int) -> Disposition:
        if overlap_ms <= 0:
            return Disposition.NO_OVERLAP
        # Treat >=99% coverage as full: rounding on frame boundaries is expected.
        if overlap_ms >= spot_duration_ms * 0.99:
            return Disposition.FULLY_DISPLACED
        return Disposition.PARTIALLY_DISPLACED

    def reconcile(
        self,
        activation: EASActivation,
        window: InterruptWindow,
        schedule: list[ScheduledSpot],
    ) -> list[ReconciliationEntry]:
        eligible_code = activation.event_code in _ALWAYS_ELIGIBLE
        emitted: list[ReconciliationEntry] = []

        for spot in schedule:
            overlap = self._overlap_ms(window, spot)
            disposition = self._classify(overlap, spot.duration_ms)
            if disposition is Disposition.NO_OVERLAP:
                continue  # Not covered by the window; no debt, no ledger noise.

            entry = ReconciliationEntry(
                entry_id=self._entry_id(activation.activation_id, spot.spot_id),
                activation_id=activation.activation_id,
                spot_id=spot.spot_id,
                event_code=activation.event_code,
                overlap_ms=overlap,
                # Federal codes always owe; test codes defer to order terms
                # resolved downstream, so we record eligibility, not a route.
                makegood_eligible=eligible_code,
            )
            self._entries.append(entry)
            emitted.append(entry)
            _log(
                logging.INFO,
                spot.spot_id,
                f"displaced by {activation.event_code.value} "
                f"({activation.activation_id}) | overlap={overlap}ms "
                f"| {disposition.value} | eligible={eligible_code}",
            )

        if not emitted:
            _log(
                logging.WARNING,
                activation.activation_id,
                "window overlapped no scheduled spots | nothing to reconcile",
            )
        return emitted

The handler never routes and never bills. It returns ReconciliationEntry records and logs each displacement; a separate adapter (see Integration Points) forwards eligible entries to make-good routing and appends the full set to the as-run ledger. That separation keeps detection pure and testable, and it means a replayed activation is re-reconciled with identical entry_id values that downstream systems recognise and discard.

Validation & Edge Cases

Broadcast operations produce boundary conditions that a naive handler mishandles. Each of the following is exercised by the implementation above.

  • The indefinite EAN. A national activation may run far longer than any single break and carry no advance expires_at the station trusts. The handler keys displacement off the decoder’s actual end-of-message rather than the SAME header’s validity horizon, so a twenty-minute national alert correctly displaces every spot across every break it spanned, not just the one that happened to be airing when it started.
  • Timezone and DST offsets. Overlap is computed in UTC milliseconds after every model normalizes its timestamps. A local schedule crossing the spring-forward boundary would otherwise gain or lose an hour of apparent overlap and mis-disposition a spot; the Pydantic validators reject naive datetimes outright.
  • Partial displacement at the head or tail. A spot that aired 25 of 30 seconds before the alert cut in is PARTIALLY_DISPLACED, not fully lost. Carrying the exact overlap_ms forward lets billing apply a pro-rated make-good credit or, under contracts that count near-complete airings as delivered, close the spot with no debt — a decision the ledger preserves rather than pre-empting.
  • Scheduled tests versus real threats. An RWT or RMT still overlaps and is still recorded, but it is not in the always-eligible set. Its makegood_eligible flag is False at this stage, deferring the debt question to the order terms resolved downstream, so a routine weekly test does not fabricate a flood of spurious make-good obligations.
  • Zero-overlap and zero-duration guards. A schedule row whose interval does not actually intersect the window is dropped as NO_OVERLAP before it can create a phantom entry, and a spot with a non-positive duration_ms fails validation at the boundary rather than producing a divide-by-zero in the disposition classifier.
  • SCTE-35 break alignment. When the interrupt lands mid-break, the true spot boundaries come from the splice points described in SCTE-35 ad signaling; aligning the window to signalled boundaries prevents a one-frame straddle from being scored as a partial displacement of a spot that had effectively finished.

Integration Points

Upstream, the handler consumes normalized activations. Raw SAME-header and CAP payloads from the ENDEC or the IPAWS decoder are mapped into the EASActivation and InterruptWindow schemas by ingestion adapters, and the schedule it intersects is the same canonical shape produced by the Avion & Avstar ingestion pipelines so the handler works against one spot schema rather than vendor-specific rows.

Downstream, an adapter turns each eligible ReconciliationEntry into a preemption signal for make-good routing and appends every entry to the as-run ledger. The wire contract is deliberately small and carries the deterministic key so a duplicate delivery is a no-op at the receiver:

json
{
  "message_type": "eas.displacement.v1",
  "entry_id": "b41f7c9a2e0d5c817a3e6b04",
  "activation_id": "EAS-2026-0714-TOR-0007",
  "event_code": "TOR",
  "spot_id": "SP-2026-0714-0192",
  "house_number": "HN-44821",
  "isci": "ABCD1234",
  "overlap_ms": 30000,
  "disposition": "fully_displaced",
  "makegood_eligible": true,
  "recorded_at": "2026-07-14T21:03:11Z"
}

The make-good router consumes this with event_code mapped to its EAS_ACTIVATION preemption reason, which it treats as always routable, while the billing system joins on spot_id and the normalized code to credit the recovery against the same account the original spot debited. Because the reconciliation ledger is the single source of truth, both consumers read the same immutable record and can never disagree about which spots the alert displaced. The detailed discrepancy resolution — an entry the ledger recorded but the as-run log does not corroborate, or vice versa — is handled by as-run reconciliation and discrepancy handling, which treats this feed as one of its authoritative inputs.

Compliance & Audit Considerations

EAS interrupt handling is compliance-critical on two fronts at once: the FCC governs the alert itself, and the advertiser contracts govern the recovery. Three obligations apply directly.

FCC Part 11 test-and-log discipline. Stations must originate and log Required Weekly and Monthly Tests and relay national activations. Because the handler records RWT, RMT, and NPT windows alongside real threats, the same ledger that drives make-good recovery doubles as evidence that tests fired and were accounted for — including which commercial inventory, if any, they touched. The event_code and originator are preserved verbatim so the record maps cleanly to the station’s Part 11 obligations.

Immutable audit trail. Every displacement is emitted on the timestamp | level | module | spot_id log line and written to append-only storage as a ReconciliationEntry. The deterministic entry_id ties a make-good back to the exact activation that caused it, which is precisely what a billing reconciliation or a regulatory review needs to reconstruct why compensatory inventory aired. Nothing is mutated in place; a superseding decision is a new entry, never an edit.

Billing lineage. The recovered airing must bill against the same normalized code as the spot it displaced, so the handler carries house_number and isci through untouched and defers code resolution to billing code normalization. Keeping the handler pure — activation in, entries out, no hidden mutation — means the ledger is the join key billing and audit both rely on.

Troubleshooting & Common Errors

Error code Root cause Remediation
ERR_NAIVE_TIMESTAMP An activation or schedule row carried a timezone-naive datetime Fix the ingestion adapter to attach UTC (or the station’s zone converted to UTC) before validation; never let the handler infer a zone
ERR_WINDOW_INVERTED ended_at is at or before started_at, usually a missed end-of-message Recover the true EOM from the ENDEC log; if absent, cap the window at the next activation or the break boundary and flag for review
ERR_NO_OVERLAP The window did not intersect any scheduled spot Expected for tests fired in non-commercial time; if a real threat logs this, verify the schedule feed covered the alert period
ERR_UNKNOWN_EVENT_CODE The SAME header carried a code outside the configured vocabulary Extend the EventCode enum deliberately after confirming the code’s tier; do not silently coerce an unknown code to eligible
ERR_DUPLICATE_ENTRY An activation replayed after its entries were already written Expected under at-least-once delivery — the matching entry_id makes the second write a no-op; verify the ledger deduplicates on that key

The ERR_WINDOW_INVERTED case deserves particular attention. A missing end-of-message is the most common real-world fault on a busy severe-weather night, because chained relays and decoder resets can drop the EOM. When it appears, the fault is upstream in the ENDEC feed, not the handler — resolving it by bounding the window against the next known boundary is safer than admitting an open-ended interval that would displace the entire remaining log.