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.
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.
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_atthe 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 exactoverlap_msforward 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
RWTorRMTstill overlaps and is still recorded, but it is not in the always-eligible set. Itsmakegood_eligibleflag isFalseat 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_OVERLAPbefore it can create a phantom entry, and a spot with a non-positiveduration_msfails 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:
{
"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.
Related
- Spot Scheduling Validation & Rule Engines — the parent architecture that defines the taxonomy, contracts, and idempotency guarantees this handler inherits.
- Automating Make-Good Routing for Preemptions — the recovery subsystem that consumes eligible displacement entries and routes them to compensatory inventory.
- As-Run Reconciliation and Discrepancy Handling — the ledger consumer that reconciles EAS displacement records against what actually aired.
- SCTE-35 Ad Signaling for Spot Insertion — the splice-point boundaries that align an interrupt window to real break edges.