diff --git a/components/airport-operations-board.tsx b/components/airport-operations-board.tsx
index 4fc1f29b7..f1ab8c6f8 100644
--- a/components/airport-operations-board.tsx
+++ b/components/airport-operations-board.tsx
@@ -14,6 +14,10 @@ import {
} from "@/lib/airport-intelligence/v3";
import { buildAirportArrivalSequence } from "@/lib/airport-intelligence/arrival-sequence-v7";
import { buildAirportArrivalFlowIntelligence } from "@/lib/airport-intelligence/arrival-flow-v8";
+import { buildAirportRunwayChangeEvidenceD2 } from "@/lib/airport-intelligence/runway-change-evidence-d2";
+import { buildAirportApproachEvidenceD3 } from "@/lib/airport-intelligence/approach-evidence-d3";
+import { buildAirportContextD4 } from "@/lib/airport-intelligence/airport-context-d4";
+import { airportDText } from "@/lib/i18n/airport-d-extras";
import type { AirportOperationsControllerState } from "@/components/airport-operations-controller";
import type { AirportLiveTrafficControllerState } from "@/components/airport-live-traffic-controller";
import type { AirportMovement } from "@/lib/server/airport-movements";
@@ -341,6 +345,10 @@ export function AirportOperationsBoard({
runwayFlow,
referenceTime: arrivalSequence.generatedAt ?? operations?.generatedAt ?? null,
});
+ const runwayEvidenceD2 = buildAirportRunwayChangeEvidenceD2(runwayFlow, arrivalFlow);
+ const approachEvidenceD3 = buildAirportApproachEvidenceD3({traffic: activeTraffic, operations});
+ const airportContextD4 = buildAirportContextD4({runway: runwayEvidenceD2, arrival: arrivalFlow, approach: approachEvidenceD3, windAvailable: Boolean(weather?.metar)});
+ const airportDCopy = airportDText(t.locale);
const terminalDemandHorizon = operations?.terminalDemandHorizon ?? null;
const timeline = buildAirportOperationsTimeline(operations);
const runwayShare = runway.inferredShare === null ? null : `${Math.round(runway.inferredShare * 100)} %`;
@@ -653,6 +661,49 @@ export function AirportOperationsBoard({
{t.airport.liveBoardV8Disclaimer}
+
+
+
AIRPORT INTELLIGENCE / D2
{airportDCopy.runwayHeading}
+
{runwayEvidenceD2.state === "OBSERVED_TRANSITION" ? airportDCopy.observedTransition
+ : runwayEvidenceD2.state === "PREDICTED_DIVERGENCE" ? airportDCopy.predictedDivergence
+ : runwayEvidenceD2.state === "OBSERVED_STABLE" ? airportDCopy.observedStable
+ : airportDCopy.unknown}
+
+ {airportDCopy.runwayDetail(runwayEvidenceD2.previousRunway, runwayEvidenceD2.observedRunway, runwayEvidenceD2.observedSamples)}
+ {airportDCopy.runwayDisclaimer}
+
+
+
+
+
AIRPORT INTELLIGENCE / D3
{airportDCopy.approachHeading}
+
{approachEvidenceD3.items.length}
+
+ {approachEvidenceD3.items.length ?
+ {approachEvidenceD3.items.map((item) => -
+
+ {item.label}
+ {item.state === "REAPPROACH_EVIDENCE" ? airportDCopy.reapproach
+ : item.state === "GO_AROUND_EVIDENCE" ? airportDCopy.goAround
+ : item.state === "HOLDING_EVIDENCE" ? airportDCopy.holding
+ : item.state === "FINAL_APPROACH_EVIDENCE" ? airportDCopy.finalApproach
+ : item.state === "APPROACH_EVIDENCE" ? airportDCopy.approach : airportDCopy.liveOnly}
+
+
)}
+
: {airportDCopy.noApproach}
}
+ {airportDCopy.approachDisclaimer}
+
+
+
+
+
AIRPORT INTELLIGENCE / D4
{airportDCopy.contextHeading}
+
+ {airportContextD4.signals.length ?
+ {airportContextD4.signals.map((signal) => - {airportDCopy.contextSignals[signal]}
)}
+
: {airportDCopy.noContext}
}
+ {airportDCopy.contextAtcLink} ↗
+ {airportDCopy.contextDisclaimer}
+
+
{terminalDemandHorizon ? =60% dominant share
+for observed transition; otherwise it returns UNKNOWN. Weather is supporting
+context only and cannot prove an ATC runway assignment.
+
+D3 adds a bounded live approach-evidence panel over the correlated receiver
+snapshot, at most 250 already classified movements and up to 250 canonical
+FlightEvent exceptions from the existing movement query (no additional SQL).
+The latest observed approach is not replaced by an older exception event. A live
+stage without matching ICAO, flight instance, valid timestamp, and recent
+movement is `LIVE_ONLY`, never a verified approach transition. A re-approach
+requires a GO_AROUND followed by a later APPROACH in the **same Flight ID**.
+Missing or incomplete history does not imply landing or an absent event.
+
+D4 presents source-separated runway/arrival/wind/holding evidence alongside a
+link to the already existing airspace and ATC explorer. Airport-level ATC
+instructions are **not** correlated to individual aircraft in this panel;
+its `NO_ATC_CLEARANCE_EVIDENCE` limitation is mandatory. Nothing infers
+clearances, tuned frequencies, runway assignments, causal links or capacity.
+
+D5 regression coverage is in `tests/airport-runway-change-evidence-d2.test.ts`,
+`tests/airport-approach-evidence-d3.test.ts`,
+`tests/airport-context-d4.test.ts` and
+`tests/airport-intelligence-d5-release-boundary.test.ts`. Run these and the
+existing V7–V9 and D1 tests through PR CI/CodeQL, then verify main release
+validation, desktop/mobile browser gates and exact-SHA production deployment.
+The change introduces no new API, provider request, polling loop, database
+migration, persistence, stream or auto-PUBLIC graduation. Engineering release
+success is not evidence of independently validated real-world accuracy.
diff --git a/docs/cs/AIRPORT-OPERATIONS.md b/docs/cs/AIRPORT-OPERATIONS.md
index 597a7f715..e714f6aed 100644
--- a/docs/cs/AIRPORT-OPERATIONS.md
+++ b/docs/cs/AIRPORT-OPERATIONS.md
@@ -211,3 +211,32 @@ a ATC kontext bez dalšího paralelního dashboardu nebo polleru.
- **D5: Ověřovaný release.** Deterministické testy scénářů, screenshoty
a průběžné porovnání s nezávislou skutečností. Bez automatického
povolení PUBLIC predikcí.
+
+### Implementace a ověření D2–D5
+
+D2 přidává konzervativní vyhodnocení drah z již existujících, po jednotlivých
+letech deduplikovaných 15minutových oken V7 a readiness-gated PUBLIC predikcí V8.
+Odděluje pozorovanou změnu dráhy, nesoulad predikce a stabilní provoz. Pro
+pozorovanou změnu vyžaduje obě okna nejméně tři odlišné lety a dominantní
+podíl alespoň 60 %. Jinak vrací UNKNOWN. Vítr nepředstavuje potvrzení ATC.
+
+D3 využívá existující korelovaný živý provoz, nejvýše 250 klasifikovaných
+pohybů a 250 kanonických výjimek FlightEvent z již prováděného dotazu
+(bez další SQL operace). Novější pozorované přiblížení nesmí přepsat starší
+go-around. Živá fáze bez odpovídající ICAO identity, instance letu, času a
+čerstvého pohybu má stav `LIVE_ONLY`. Opakované přiblížení vyžaduje
+GO_AROUND následovaný APPROACH ve **stejné instanci Flight ID**. Neúplná
+historie neprokazuje přistání ani absenci události.
+
+D4 ukazuje oddělené podklady o dráze, předpovědi, větru a přiblížení a
+odkazuje na existující průzkumník vzdušných prostorů a ATC sektorů. Aktuální
+panel **nepřiřazuje** konkrétním letům pokyny ATC, řízené frekvence ani
+dráhy. Stav `NO_ATC_CLEARANCE_EVIDENCE` toto omezení explicitně zachovává.
+
+D5 zahrnuje testy `airport-runway-change-evidence-d2`,
+`airport-approach-evidence-d3`, `airport-context-d4` a
+`airport-intelligence-d5-release-boundary`. Po zelených PR kontrolách se
+ověřuje release validation, desktop/mobilní browser gate a produkční nasazení
+přesného SHA. Žádná nová API cesta, poller, externí žádost, DB migrace,
+persistenční zápis, SSE ani automatické povolení PUBLIC predikcí. Zelené CI
+samo o sobě není důkazem skutečné provozní přesnosti.
diff --git a/lib/airport-intelligence/airport-context-d4.ts b/lib/airport-intelligence/airport-context-d4.ts
new file mode 100644
index 000000000..97f23e467
--- /dev/null
+++ b/lib/airport-intelligence/airport-context-d4.ts
@@ -0,0 +1,48 @@
+import type { AirportArrivalFlowIntelligence } from "@/lib/airport-intelligence/arrival-flow-v8";
+import type { AirportRunwayChangeEvidenceD2 } from "@/lib/airport-intelligence/runway-change-evidence-d2";
+import type { ApproachEvidenceD3 } from "@/lib/airport-intelligence/approach-evidence-d3";
+
+export type AirportContextSignalD4 =
+ | "RUNWAY_TRANSITION_OBSERVED" | "PUBLIC_RUNWAY_DIFFERS" | "RUNWAY_STABLE"
+ | "WIND_SUPPORTS_OBSERVED" | "WIND_DIFFERS" | "WIND_UNAVAILABLE"
+ | "HOLDING_OR_GO_AROUND_OBSERVED" | "ARRIVAL_COMPRESSION_PREDICTED"
+ | "PARTIAL_RECEIVER_HISTORY";
+
+export interface AirportContextD4 {
+ version: "airport-context-d4";
+ signals: AirportContextSignalD4[];
+ source: { receiver: "OBSERVED_INFERRED"; prediction: "PUBLIC_ONLY"; wind: "CURRENT_CONTEXT" | "UNAVAILABLE"; atc: "NOT_CORRELATED" };
+ /** ATC sector overlap is not evidence of a clearance, active frequency or runway assignment. */
+ limitation: "NO_ATC_CLEARANCE_EVIDENCE";
+}
+
+/** Explain current airport observations without fabricating ATC assignments or new forecasts. */
+export function buildAirportContextD4(input: {
+ runway: AirportRunwayChangeEvidenceD2;
+ arrival: AirportArrivalFlowIntelligence;
+ approach: ApproachEvidenceD3;
+ windAvailable: boolean;
+}): AirportContextD4 {
+ const signals: AirportContextSignalD4[] = [];
+ if (input.runway.state === "OBSERVED_TRANSITION") signals.push("RUNWAY_TRANSITION_OBSERVED");
+ else if (input.runway.state === "PREDICTED_DIVERGENCE") signals.push("PUBLIC_RUNWAY_DIFFERS");
+ else if (input.runway.state === "OBSERVED_STABLE") signals.push("RUNWAY_STABLE");
+ if (!input.windAvailable || input.runway.windAlignment === "UNKNOWN") signals.push("WIND_UNAVAILABLE");
+ else if (input.runway.windAlignment === "ALIGNED") signals.push("WIND_SUPPORTS_OBSERVED");
+ else signals.push("WIND_DIFFERS");
+ if (input.approach.counts.goAround > 0 || input.approach.counts.holding > 0)
+ signals.push("HOLDING_OR_GO_AROUND_OBSERVED");
+ if (input.arrival.compression.state === "HIGH" || input.arrival.compression.state === "ELEVATED")
+ signals.push("ARRIVAL_COMPRESSION_PREDICTED");
+ if (!input.approach.complete) signals.push("PARTIAL_RECEIVER_HISTORY");
+ return {
+ version: "airport-context-d4", signals: signals.slice(0, 7),
+ source: {
+ receiver: "OBSERVED_INFERRED",
+ prediction: "PUBLIC_ONLY",
+ wind: input.windAvailable ? "CURRENT_CONTEXT" : "UNAVAILABLE",
+ atc: "NOT_CORRELATED",
+ },
+ limitation: "NO_ATC_CLEARANCE_EVIDENCE",
+ };
+}
diff --git a/lib/airport-intelligence/approach-evidence-d3.ts b/lib/airport-intelligence/approach-evidence-d3.ts
new file mode 100644
index 000000000..d96d96d3c
--- /dev/null
+++ b/lib/airport-intelligence/approach-evidence-d3.ts
@@ -0,0 +1,109 @@
+import type { AirportCorrelatedTrafficSnapshot } from "@/lib/airport-intelligence/v3";
+import type { AirportOperationsResponse } from "@/lib/server/airport-operations";
+import type { AirportMovement } from "@/lib/server/airport-movements";
+
+export type ApproachEvidenceState =
+ | "REAPPROACH_EVIDENCE" | "GO_AROUND_EVIDENCE" | "HOLDING_EVIDENCE"
+ | "FINAL_APPROACH_EVIDENCE" | "APPROACH_EVIDENCE" | "LIVE_ONLY";
+
+export interface ApproachEvidenceItemD3 {
+ icaoHex: string;
+ label: string;
+ state: ApproachEvidenceState;
+ flightId: number | null;
+ evidenceAt: string | null;
+ /** No historical landing status is inferred from an aircraft disappearing. */
+ basis: "OBSERVED_CORRELATED_MOVEMENT" | "FRESH_LIVE_STAGE_ONLY";
+}
+
+export interface ApproachEvidenceD3 {
+ version: "airport-approach-evidence-d3";
+ items: ApproachEvidenceItemD3[];
+ counts: { reapproach: number; goAround: number; holding: number; approach: number; liveOnly: number };
+ complete: boolean;
+}
+
+const MAX_ITEMS = 6;
+const MAX_MOVEMENT_AGE_MS = 20 * 60_000;
+const FUTURE_TOLERANCE_MS = 2 * 60_000;
+const EVENT_TYPES = new Set(["APPROACH", "GO_AROUND", "HOLDING"]);
+
+function validTime(value: string, reference: number): number | null {
+ const timestamp = Date.parse(value);
+ return Number.isFinite(timestamp) && timestamp <= reference + FUTURE_TOLERANCE_MS
+ && reference - timestamp <= MAX_MOVEMENT_AGE_MS
+ ? timestamp : null;
+}
+
+/** Read-only corroboration; a stage label alone is not a historical transition. */
+export function buildAirportApproachEvidenceD3(input: {
+ traffic: AirportCorrelatedTrafficSnapshot;
+ operations: AirportOperationsResponse | null;
+ now?: Date;
+}): ApproachEvidenceD3 {
+ const now = (input.now ?? new Date()).getTime();
+ const histories = new Map();
+ for (const movement of [
+ ...(input.operations?.eventEvidence ?? []).slice(0, 250),
+ ...(input.operations?.recentMovements ?? []).slice(0, 250),
+ ]) {
+ if (!EVENT_TYPES.has(movement.movement) || validTime(movement.observedAt, now) === null) continue;
+ const history = histories.get(movement.flightId) ?? [];
+ history.push(movement);
+ histories.set(movement.flightId, history);
+ }
+ for (const history of histories.values()) {
+ history.sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt)
+ || left.movement.localeCompare(right.movement));
+ }
+
+ const seen = new Set();
+ const items: ApproachEvidenceItemD3[] = [];
+ for (const observation of input.traffic.inbound.slice(0, 12)) {
+ const hex = observation.aircraft.icaoHex.trim().toUpperCase();
+ if (!hex || seen.has(hex) || observation.aircraft.onGround) continue;
+ seen.add(hex);
+ const matched = observation.movement;
+ const matchedAt = matched && matched.icaoHex.trim().toUpperCase() === hex
+ ? validTime(matched.observedAt, now) : null;
+ const confirmed = matchedAt !== null
+ && typeof observation.movementAgeSeconds === "number"
+ && observation.movementAgeSeconds >= 0
+ && observation.movementAgeSeconds <= MAX_MOVEMENT_AGE_MS / 1000;
+ const history = confirmed && matched ? (histories.get(matched.flightId) ?? [])
+ .filter((event) => event.icaoHex.trim().toUpperCase() === hex) : [];
+ const latestApproach = history.filter((event) => event.movement === "APPROACH")
+ .findLast((event) => Date.parse(event.observedAt) <= matchedAt!);
+ const priorGoAround = latestApproach && history.some((event) =>
+ event.movement === "GO_AROUND"
+ && Date.parse(event.observedAt) < Date.parse(latestApproach.observedAt));
+ let state: ApproachEvidenceState = "LIVE_ONLY";
+ if (confirmed && matched) {
+ if (matched.movement === "APPROACH" && priorGoAround) state = "REAPPROACH_EVIDENCE";
+ else if (matched.movement === "GO_AROUND" && observation.journey.stage === "GO_AROUND") state = "GO_AROUND_EVIDENCE";
+ else if (matched.movement === "HOLDING" && observation.journey.stage === "HOLDING") state = "HOLDING_EVIDENCE";
+ else if (matched.movement === "APPROACH" && observation.journey.stage === "FINAL") state = "FINAL_APPROACH_EVIDENCE";
+ else if (matched.movement === "APPROACH" && observation.journey.stage === "APPROACH") state = "APPROACH_EVIDENCE";
+ }
+ items.push({
+ icaoHex: hex, label: observation.aircraft.callsign || hex, state,
+ flightId: confirmed && matched ? matched.flightId : null,
+ evidenceAt: confirmed && matched ? matched.observedAt : null,
+ basis: state === "LIVE_ONLY" ? "FRESH_LIVE_STAGE_ONLY" : "OBSERVED_CORRELATED_MOVEMENT",
+ });
+ if (items.length >= MAX_ITEMS) break;
+ }
+ return {
+ version: "airport-approach-evidence-d3",
+ items,
+ counts: {
+ reapproach: items.filter((row) => row.state === "REAPPROACH_EVIDENCE").length,
+ goAround: items.filter((row) => row.state === "GO_AROUND_EVIDENCE").length,
+ holding: items.filter((row) => row.state === "HOLDING_EVIDENCE").length,
+ approach: items.filter((row) => ["APPROACH_EVIDENCE", "FINAL_APPROACH_EVIDENCE"].includes(row.state)).length,
+ liveOnly: items.filter((row) => row.state === "LIVE_ONLY").length,
+ },
+ complete: Boolean(input.operations?.complete && !input.operations?.truncated
+ && !input.operations?.eventEvidenceTruncated),
+ };
+}
diff --git a/lib/airport-intelligence/runway-change-evidence-d2.ts b/lib/airport-intelligence/runway-change-evidence-d2.ts
new file mode 100644
index 000000000..9fd4899c1
--- /dev/null
+++ b/lib/airport-intelligence/runway-change-evidence-d2.ts
@@ -0,0 +1,70 @@
+import type { AirportRunwayFlowIntelligence } from "@/lib/airport-intelligence/v3";
+import type { AirportArrivalFlowIntelligence } from "@/lib/airport-intelligence/arrival-flow-v8";
+
+export type AirportRunwayEvidenceState =
+ | "OBSERVED_TRANSITION" | "PREDICTED_DIVERGENCE" | "OBSERVED_STABLE" | "UNKNOWN";
+export type AirportRunwayEvidenceReason =
+ | "OBSERVED_WINDOW_CHANGE" | "PUBLIC_PREDICTION_DIFFERS"
+ | "OBSERVED_WINDOWS_STABLE" | "INSUFFICIENT_INDEPENDENT_EVIDENCE";
+
+export interface AirportRunwayChangeEvidenceD2 {
+ version: "airport-runway-evidence-d2";
+ state: AirportRunwayEvidenceState;
+ reason: AirportRunwayEvidenceReason;
+ previousRunway: string | null;
+ observedRunway: string | null;
+ predictedRunway: string | null;
+ previousSamples: number;
+ observedSamples: number;
+ predictedSamples: number;
+ windFavoredRunway: string | null;
+ /** Wind is context, not evidence of an ATC runway assignment. */
+ windAlignment: AirportRunwayFlowIntelligence["windAlignment"];
+}
+
+/** Read-only interpretation of already deduplicated, time-windowed runway evidence. */
+export function buildAirportRunwayChangeEvidenceD2(
+ observed: AirportRunwayFlowIntelligence,
+ arrival: AirportArrivalFlowIntelligence,
+): AirportRunwayChangeEvidenceD2 {
+ const previousStrong = observed.previous.samples >= 3
+ && (observed.previous.share ?? 0) >= 0.60;
+ const currentStrong = observed.current.samples >= 3
+ && (observed.current.share ?? 0) >= 0.60;
+ const predictedStrong = arrival.runwayAlignment.predictedSamples >= 2
+ && (arrival.runwayAlignment.predictedShare ?? 0) >= 0.60
+ && arrival.runwayAlignment.predictedRunway !== null;
+ const changeSupported = observed.state === "TRANSITIONING"
+ && previousStrong && currentStrong
+ && observed.transition?.from === observed.previous.runway
+ && observed.transition?.to === observed.current.runway
+ && observed.previous.runway !== observed.current.runway;
+ const stableSupported = observed.state === "STABLE" && previousStrong && currentStrong
+ && observed.previous.runway === observed.current.runway;
+
+ let state: AirportRunwayEvidenceState = "UNKNOWN";
+ let reason: AirportRunwayEvidenceReason = "INSUFFICIENT_INDEPENDENT_EVIDENCE";
+ if (changeSupported) {
+ state = "OBSERVED_TRANSITION";
+ reason = "OBSERVED_WINDOW_CHANGE";
+ } else if (stableSupported && predictedStrong
+ && arrival.runwayAlignment.predictedRunway !== observed.current.runway) {
+ state = "PREDICTED_DIVERGENCE";
+ reason = "PUBLIC_PREDICTION_DIFFERS";
+ } else if (stableSupported) {
+ state = "OBSERVED_STABLE";
+ reason = "OBSERVED_WINDOWS_STABLE";
+ }
+
+ return {
+ version: "airport-runway-evidence-d2", state, reason,
+ previousRunway: observed.previous.runway,
+ observedRunway: observed.current.runway,
+ predictedRunway: predictedStrong ? arrival.runwayAlignment.predictedRunway : null,
+ previousSamples: observed.previous.samples,
+ observedSamples: observed.current.samples,
+ predictedSamples: predictedStrong ? arrival.runwayAlignment.predictedSamples : 0,
+ windFavoredRunway: observed.windFavoredRunway,
+ windAlignment: observed.windAlignment,
+ };
+}
diff --git a/lib/i18n/airport-d-extras.ts b/lib/i18n/airport-d-extras.ts
new file mode 100644
index 000000000..56e11e11d
--- /dev/null
+++ b/lib/i18n/airport-d-extras.ts
@@ -0,0 +1,74 @@
+/** Small dedicated bilingual copy for read-only Airport Intelligence stages D2-D4. */
+export const airportDExtras = {
+ cs: {
+ contextHeading: "Provozní kontext letiště",
+ contextSignals: {
+ RUNWAY_TRANSITION_OBSERVED: "Pozorovaná změna používané dráhy",
+ PUBLIC_RUNWAY_DIFFERS: "Veřejná predikce dráhy se liší od pozorování",
+ RUNWAY_STABLE: "Pozorovaný provoz na dráze je stabilní",
+ WIND_SUPPORTS_OBSERVED: "Směr větru odpovídá pozorované dráze",
+ WIND_DIFFERS: "Směr větru neodpovídá dominantní pozorované dráze",
+ WIND_UNAVAILABLE: "Nelze spolehlivě porovnat vítr a dráhu",
+ HOLDING_OR_GO_AROUND_OBSERVED: "Zachycen holding nebo go-around",
+ ARRIVAL_COMPRESSION_PREDICTED: "Veřejné ETA ukazují zahuštění příletů",
+ PARTIAL_RECEIVER_HISTORY: "Historie přijímače není úplná",
+ },
+ contextAtcLink: "Prozkoumat vzdušný prostor a ATC sektory",
+ contextDisclaimer: "Kontext spojuje oddělené zdroje. Neprokazuje pokyny ATC, přidělenou dráhu ani příčinu provozních změn.",
+ noContext: "Bez dalších průkazných provozních signálů.",
+ approachHeading: "Evidence průběhu přiblížení",
+ reapproach: "Opakované přiblížení po go-around",
+ goAround: "Pozorovaný go-around",
+ holding: "Pozorovaný holding",
+ finalApproach: "Final s ověřeným přiblížením",
+ approach: "Pozorované přiblížení",
+ liveOnly: "Pouze živá fáze — bez historického potvrzení",
+ noApproach: "Žádné současné přílety s dostatečnými podklady.",
+ approachDisclaimer: "Pohyb musí souhlasit s ICAO identitou a časem. Neúplné záznamy neprokazují přistání ani přechod fáze.",
+ runwayHeading: "Evidence změny dráhy",
+ observedTransition: "Pozorovaná změna provozu",
+ predictedDivergence: "Predikce se liší od pozorování",
+ observedStable: "Pozorovaný provoz stabilní",
+ unknown: "Nedostatek průkazných dat",
+ runwayDetail: (previous: string | null, current: string | null, samples: number) =>
+ `${previous ?? "?"} → ${current ?? "?"} · nyní ${samples} letů`,
+ runwayDisclaimer: "Jde o porovnání pozorovaných pohybů a pouze veřejných predikcí, nikoliv potvrzenou volbu dráhy nebo pokyn ATC.",
+ },
+ en: {
+ contextHeading: "Airport operating context",
+ contextSignals: {
+ RUNWAY_TRANSITION_OBSERVED: "Observed runway traffic transition",
+ PUBLIC_RUNWAY_DIFFERS: "PUBLIC runway prediction differs from observations",
+ RUNWAY_STABLE: "Observed runway traffic remains stable",
+ WIND_SUPPORTS_OBSERVED: "Wind direction aligns with the observed runway",
+ WIND_DIFFERS: "Wind differs from the dominant observed runway",
+ WIND_UNAVAILABLE: "Wind/runway comparison unavailable",
+ HOLDING_OR_GO_AROUND_OBSERVED: "Holding or go-around observed",
+ ARRIVAL_COMPRESSION_PREDICTED: "PUBLIC ETAs indicate compressed arrivals",
+ PARTIAL_RECEIVER_HISTORY: "Receiver history is incomplete",
+ },
+ contextAtcLink: "Explore airspace and ATC sectors",
+ contextDisclaimer: "Context combines separate evidence streams. It does not prove ATC instructions, runway assignments or operational causes.",
+ noContext: "No additional reliable operating signals.",
+ approachHeading: "Approach evolution evidence",
+ reapproach: "Re-approach after observed go-around",
+ goAround: "Observed go-around",
+ holding: "Observed holding",
+ finalApproach: "Final with correlated approach evidence",
+ approach: "Observed approach",
+ liveOnly: "Live stage only — no historical confirmation",
+ noApproach: "No active arrivals with sufficient evidence.",
+ approachDisclaimer: "Movements must match ICAO identity and time. Incomplete tracks do not prove landing or stage transitions.",
+ runwayHeading: "Runway change evidence",
+ observedTransition: "Observed traffic transition",
+ predictedDivergence: "Prediction differs from observations",
+ observedStable: "Observed traffic stable",
+ unknown: "Insufficient reliable evidence",
+ runwayDetail: (previous: string | null, current: string | null, samples: number) =>
+ `${previous ?? "?"} → ${current ?? "?"} · current ${samples} flights`,
+ runwayDisclaimer: "This compares observed movements and PUBLIC advisories; it is not a confirmed runway assignment or ATC instruction.",
+ },
+} as const;
+export function airportDText(locale: string) {
+ return locale.startsWith("en") ? airportDExtras.en : airportDExtras.cs;
+}
diff --git a/lib/server/airport-movements.ts b/lib/server/airport-movements.ts
index 9a128767c..b7941e4e2 100644
--- a/lib/server/airport-movements.ts
+++ b/lib/server/airport-movements.ts
@@ -60,6 +60,9 @@ export interface AirportMovementsResponse {
complete: boolean;
truncated: boolean;
movements: AirportMovement[];
+ /** Bounded canonical approach-exception history; distinct from classified movements. */
+ eventEvidence?: AirportMovement[];
+ eventEvidenceTruncated?: boolean;
summary: {
approaches: number;
landings: number;
@@ -390,6 +393,27 @@ export async function getAirportMovements(
current.push(position);
positionsByFlight.set(position.flightId, current);
}
+ // Reuse the already fetched canonical FlightEvent batch. These events are
+ // evidence only and must not change movement counts or runway usage.
+ const eventEvidence: AirportMovement[] = [];
+ let eventEvidenceTruncated = eventRows.length >= POSITION_QUERY_LIMIT;
+ const flightsById = new Map(candidateFlights.map((flight) => [flight.id, flight]));
+ for (const event of eventRows) {
+ const flight = event.flightId === null ? null : flightsById.get(event.flightId);
+ if (!flight || (event.type !== "GO_AROUND" && event.type !== "HOLDING")
+ || (event.airportIcao !== null && event.airportIcao.toUpperCase() !== airport.icaoCode.toUpperCase())) continue;
+ const observedAt = isoTimestamp(event.occurredAt);
+ const observedMs = Date.parse(observedAt);
+ if (!Number.isFinite(observedMs) || observedMs > now.getTime() + 120_000
+ || now.getTime() - observedMs > 20 * 60_000) continue;
+ if (eventEvidence.length >= 250) { eventEvidenceTruncated = true; break; }
+ eventEvidence.push({
+ flightId: flight.id, icaoHex: flight.aircraft.icaoHex,
+ callsign: flight.callsign, registration: flight.registration ?? flight.aircraft.registration,
+ movement: event.type, confidence: "medium", airport: airport.icaoCode,
+ runway: null, observedAt, evidence: ["canonical Flight Intelligence event"],
+ });
+ }
const movements: AirportMovement[] = [];
for (const flight of candidateFlights) {
const flightPositions = positionsByFlight.get(flight.id) ?? [];
@@ -409,7 +433,13 @@ export async function getAirportMovements(
(event.type === "GO_AROUND" || event.type === "HOLDING")
&& (event.airportIcao === null || event.airportIcao.toUpperCase() === airport.icaoCode.toUpperCase()),
);
- if (movement && canonical) {
+ // Never replace a newer observed approach with an older exception event:
+ // doing so would hide a real return to approach after go-around.
+ const canonicalAt = canonical ? Date.parse(isoTimestamp(canonical.occurredAt)) : Number.NaN;
+ const movementAt = movement ? Date.parse(movement.observedAt) : Number.NaN;
+ if (movement && canonical && Number.isFinite(canonicalAt)
+ && Number.isFinite(movementAt) && canonicalAt >= movementAt
+ && canonicalAt <= now.getTime() + 120_000) {
movement = {
...movement,
movement: canonical.type as "GO_AROUND" | "HOLDING",
@@ -425,7 +455,7 @@ export async function getAirportMovements(
lastDiagnostics = { enabled: true, lastQueryDurationMs: queryDurationMs, flightsExamined: candidateFlights.length, positionsExamined: Math.min(positions.length, POSITION_QUERY_LIMIT), truncated };
return {
airport: { icao: airport.icaoCode, name: airport.name }, period, generatedAt: now.toISOString(), complete: !truncated, truncated,
- movements, summary,
+ movements, eventEvidence, eventEvidenceTruncated, summary,
diagnostics: { flightsExamined: candidateFlights.length, positionsExamined: Math.min(positions.length, POSITION_QUERY_LIMIT), queryDurationMs },
};
} catch (error) {
diff --git a/lib/server/airport-operations.ts b/lib/server/airport-operations.ts
index d3e172419..55f2db3c6 100644
--- a/lib/server/airport-operations.ts
+++ b/lib/server/airport-operations.ts
@@ -25,6 +25,8 @@ export interface AirportOperationsResponse {
departures: AirportMovement[];
approaches: AirportMovement[];
recentMovements: AirportMovement[];
+ eventEvidence?: AirportMovement[];
+ eventEvidenceTruncated?: boolean;
runwayUsage: Array<{ designator: string; arrivals: number; departures: number; total: number }>;
wind: AirportOperationsWind[];
goArounds: AirportMovement[];
@@ -76,7 +78,9 @@ export function buildAirportOperations(
airport: movements.airport, generatedAt: movements.generatedAt, window: movements.period,
provenance: "INFERRED", complete: movements.complete, truncated: movements.truncated,
activity: activity(movements.movements.filter((item) => item.movement !== "OVERFLIGHT").length), likelyRunway,
- arrivals, departures, approaches, recentMovements: movements.movements.slice(0, 20), runwayUsage: usage,
+ arrivals, departures, approaches, recentMovements: movements.movements.slice(0, 20),
+ eventEvidence: movements.eventEvidence ?? [], eventEvidenceTruncated: movements.eventEvidenceTruncated ?? false,
+ runwayUsage: usage,
wind, goArounds: movements.movements.filter((item) => item.movement === "GO_AROUND"),
holding: movements.movements.filter((item) => item.movement === "HOLDING"), diagnostics: movements.diagnostics,
};
diff --git a/tests/airport-approach-evidence-d3.test.ts b/tests/airport-approach-evidence-d3.test.ts
new file mode 100644
index 000000000..a15e50a7f
--- /dev/null
+++ b/tests/airport-approach-evidence-d3.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest";
+import { buildAirportApproachEvidenceD3 } from "@/lib/airport-intelligence/approach-evidence-d3";
+import type { AirportCorrelatedTrafficSnapshot } from "@/lib/airport-intelligence/v3";
+import type { AirportOperationsResponse } from "@/lib/server/airport-operations";
+import type { AirportMovement } from "@/lib/server/airport-movements";
+
+const now = new Date("2026-10-08T20:00:00.000Z");
+const move = (kind: AirportMovement["movement"], observedAt: string, flightId = 17): AirportMovement => ({
+ flightId, icaoHex: "ABC123", callsign: "TEST17", registration: null,
+ airport: "LKPR", movement: kind, confidence: "medium", runway: null,
+ observedAt, evidence: [],
+});
+function snapshot(current: AirportMovement | null, stage: string = "APPROACH"): AirportCorrelatedTrafficSnapshot {
+ return { inbound: [{
+ aircraft: {icaoHex: "ABC123", callsign: "TEST17", onGround: false},
+ distanceKm: 5, journey: {stage, routeRelation: "CONFIRMED"},
+ movement: current, movementAgeSeconds: 60,
+ }] as AirportCorrelatedTrafficSnapshot["inbound"], outbound: [] };
+}
+function operations(events: AirportMovement[], complete = true): AirportOperationsResponse {
+ return {recentMovements: events, complete, truncated: false} as AirportOperationsResponse;
+}
+describe("D3 approach transition evidence", () => {
+ it("requires ordered real events on the same flight for reapproach", () => {
+ const prior = move("GO_AROUND", "2026-10-08T19:47:00.000Z");
+ const latest = move("APPROACH", "2026-10-08T19:57:00.000Z");
+ const got = buildAirportApproachEvidenceD3({
+ traffic: snapshot(latest), operations: {...operations([latest]), eventEvidence: [prior]}, now,
+ });
+ expect(got.items[0]).toMatchObject({state: "REAPPROACH_EVIDENCE", flightId: 17});
+ expect(got.counts.reapproach).toBe(1);
+ const mismatched = buildAirportApproachEvidenceD3({
+ traffic: snapshot(latest), operations: {...operations([latest]), eventEvidence: [{...prior, flightId: 18}]}, now,
+ });
+ expect(mismatched.items[0]?.state).toBe("APPROACH_EVIDENCE");
+ const truncated = buildAirportApproachEvidenceD3({
+ traffic: snapshot(latest), operations: {...operations([latest]), eventEvidence: [prior], eventEvidenceTruncated: true}, now,
+ });
+ expect(truncated.complete).toBe(false);
+ });
+ it("cannot mistake a live stage for independently observed holding or go-around", () => {
+ const got = buildAirportApproachEvidenceD3({
+ traffic: snapshot(null, "GO_AROUND"), operations: null, now,
+ });
+ expect(got.items[0]?.state).toBe("LIVE_ONLY");
+ const actual = move("GO_AROUND", "2026-10-08T19:58:00.000Z");
+ expect(buildAirportApproachEvidenceD3({
+ traffic: snapshot(actual, "GO_AROUND"), operations: operations([actual]), now,
+ }).items[0]?.state).toBe("GO_AROUND_EVIDENCE");
+ });
+ it("fails closed for stale, future and identity-conflicting movements", () => {
+ const future = move("APPROACH", "2026-10-08T20:05:00.000Z");
+ const outdated = move("HOLDING", "2026-10-08T19:00:00.000Z");
+ const conflict = {...move("APPROACH", "2026-10-08T19:59:00.000Z"), icaoHex: "FFF000"};
+ for (const m of [future, outdated, conflict]) {
+ expect(buildAirportApproachEvidenceD3({
+ traffic: snapshot(m, m.movement), operations: operations([m]), now,
+ }).items[0]?.state).toBe("LIVE_ONLY");
+ }
+ });
+ it("never infers a landing, caps per-aircraft work and flags incomplete history", () => {
+ const actual = move("APPROACH", "2026-10-08T19:58:00.000Z");
+ const got = buildAirportApproachEvidenceD3({
+ traffic: snapshot(actual, "FINAL"), operations: operations([actual], false), now,
+ });
+ expect(got.items[0]?.state).toBe("FINAL_APPROACH_EVIDENCE");
+ expect(got.complete).toBe(false);
+ });
+});
diff --git a/tests/airport-context-d4.test.ts b/tests/airport-context-d4.test.ts
new file mode 100644
index 000000000..1d6a57f5a
--- /dev/null
+++ b/tests/airport-context-d4.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { buildAirportContextD4 } from "@/lib/airport-intelligence/airport-context-d4";
+import type { AirportRunwayChangeEvidenceD2 } from "@/lib/airport-intelligence/runway-change-evidence-d2";
+import type { AirportArrivalFlowIntelligence } from "@/lib/airport-intelligence/arrival-flow-v8";
+import type { ApproachEvidenceD3 } from "@/lib/airport-intelligence/approach-evidence-d3";
+
+const runway = {
+ state: "OBSERVED_TRANSITION", windAlignment: "DIFFERENT",
+} as AirportRunwayChangeEvidenceD2;
+const arrival = {compression: {state: "HIGH"}} as AirportArrivalFlowIntelligence;
+const approach = {counts: {goAround: 1, holding: 0}, complete: false} as ApproachEvidenceD3;
+
+describe("D4 airport context", () => {
+ it("keeps observations, PUBLIC predictions and wind context separate", () => {
+ expect(buildAirportContextD4({runway, arrival, approach, windAvailable: true})).toMatchObject({
+ signals: [
+ "RUNWAY_TRANSITION_OBSERVED", "WIND_DIFFERS",
+ "HOLDING_OR_GO_AROUND_OBSERVED", "ARRIVAL_COMPRESSION_PREDICTED",
+ "PARTIAL_RECEIVER_HISTORY",
+ ],
+ source: {atc: "NOT_CORRELATED", prediction: "PUBLIC_ONLY"},
+ limitation: "NO_ATC_CLEARANCE_EVIDENCE",
+ });
+ });
+ it("does not claim available wind or correlated ATC when sources are missing", () => {
+ const got = buildAirportContextD4({
+ runway: {...runway, state: "UNKNOWN", windAlignment: "UNKNOWN"},
+ arrival: {...arrival, compression: {...arrival.compression, state: "UNKNOWN"}},
+ approach: {...approach, counts: {...approach.counts, goAround: 0}, complete: true},
+ windAvailable: false,
+ });
+ expect(got.signals).toEqual(["WIND_UNAVAILABLE"]);
+ expect(got.source.atc).toBe("NOT_CORRELATED");
+ expect(got.source.wind).toBe("UNAVAILABLE");
+ });
+});
diff --git a/tests/airport-intelligence-d5-release-boundary.test.ts b/tests/airport-intelligence-d5-release-boundary.test.ts
new file mode 100644
index 000000000..ad0fc2c42
--- /dev/null
+++ b/tests/airport-intelligence-d5-release-boundary.test.ts
@@ -0,0 +1,42 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+const file = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
+const board = file("components/airport-operations-board.tsx");
+const controller = file("components/airport-operations-controller.ts");
+const runway = file("lib/airport-intelligence/runway-change-evidence-d2.ts");
+const approach = file("lib/airport-intelligence/approach-evidence-d3.ts");
+const context = file("lib/airport-intelligence/airport-context-d4.ts");
+const horizon = file("lib/server/airport-terminal-demand-horizon-v9.ts");
+
+describe("D5 airport intelligence release boundaries", () => {
+ it("keeps a single existing airport controller refresh and single aircraft stream", () => {
+ expect(controller.match(/fetch\(/g)).toHaveLength(3);
+ for (const source of [runway, approach, context]) {
+ expect(source).not.toMatch(/fetch\s*\(|setInterval\s*\(|getPrisma\s*\(/);
+ expect(source).not.toContain("getAircraftStateService");
+ expect(source).not.toContain("EventSource");
+ }
+ });
+ it("adds all D2-D4 evidence to existing Live Board without duplicating V8/V9", () => {
+ for (const testId of [
+ "airport-d2-runway-evidence", "airport-d3-approach-evidence",
+ "airport-d4-operational-context",
+ "airport-live-board-v8-arrival-flow", "airport-live-board-v9-terminal-horizon",
+ ]) expect(board).toContain(`data-testid="${testId}"`);
+ expect(board).toContain("buildAirportArrivalFlowIntelligence");
+ expect(board).toContain("buildAirportRunwayFlowIntelligence");
+ });
+ it("preserves public graduation and honest ground truth boundaries", () => {
+ expect(runway).toContain("PREDICTED_DIVERGENCE");
+ expect(runway).toContain("INSUFFICIENT_INDEPENDENT_EVIDENCE");
+ expect(approach).toContain("OBSERVED_CORRELATED_MOVEMENT");
+ expect(approach).toContain("FRESH_LIVE_STAGE_ONLY");
+ expect(context).toContain("NO_ATC_CLEARANCE_EVIDENCE");
+ expect(context).toContain('atc: "NOT_CORRELATED"');
+ expect(horizon).toContain('relation !== "TOWARD"');
+ for (const source of [runway, approach, context]) {
+ expect(source).not.toMatch(/process\.env|\.write\(|promote|PUBLIC_ACTIVE/);
+ }
+ });
+});
diff --git a/tests/airport-runway-change-evidence-d2.test.ts b/tests/airport-runway-change-evidence-d2.test.ts
new file mode 100644
index 000000000..cc78766ee
--- /dev/null
+++ b/tests/airport-runway-change-evidence-d2.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import { buildAirportRunwayChangeEvidenceD2 } from "@/lib/airport-intelligence/runway-change-evidence-d2";
+import type { AirportRunwayFlowIntelligence } from "@/lib/airport-intelligence/v3";
+import type { AirportArrivalFlowIntelligence } from "@/lib/airport-intelligence/arrival-flow-v8";
+
+function inputs(): [AirportRunwayFlowIntelligence, AirportArrivalFlowIntelligence] {
+ const lane = (runway: string) => ({
+ runway, share: .8, samples: 5, reportedSamples: 2, inferredSamples: 3,
+ arrivals: {runway, share: .8, samples: 3},
+ departures: {runway, share: .8, samples: 2},
+ });
+ const observed: AirportRunwayFlowIntelligence = {
+ windowMinutes: 15, state: "TRANSITIONING", previous: lane("11"), current: lane("29"),
+ transition: {from: "11", to: "29"}, windFavoredRunway: "29", windAlignment: "ALIGNED",
+ };
+ const arrival = {
+ runwayAlignment: {
+ state: "ALIGNED", predictedRunway: "29", predictedSamples: 3, predictedShare: .8,
+ observedRunway: "29", observedSamples: 5, observedShare: .8,
+ },
+ } as AirportArrivalFlowIntelligence;
+ return [observed, arrival];
+}
+
+describe("D2 runway evidence", () => {
+ it("accepts only independently supported observed window transitions", () => {
+ const [observed, arrival] = inputs();
+ expect(buildAirportRunwayChangeEvidenceD2(observed, arrival)).toMatchObject({
+ state: "OBSERVED_TRANSITION", reason: "OBSERVED_WINDOW_CHANGE",
+ previousRunway: "11", observedRunway: "29", observedSamples: 5,
+ });
+ });
+ it("does not claim a change for low-count or contradictory windows", () => {
+ const [observed, arrival] = inputs();
+ observed.current.samples = 2;
+ expect(buildAirportRunwayChangeEvidenceD2(observed, arrival).state).toBe("UNKNOWN");
+ observed.current.samples = 5;
+ observed.transition = {from: "11", to: "11"};
+ expect(buildAirportRunwayChangeEvidenceD2(observed, arrival).state).toBe("UNKNOWN");
+ });
+ it("keeps prediction divergence distinct from observed changes", () => {
+ const [observed, arrival] = inputs();
+ observed.state = "STABLE";
+ observed.previous = { ...observed.current };
+ observed.transition = null;
+ arrival.runwayAlignment.predictedRunway = "11";
+ expect(buildAirportRunwayChangeEvidenceD2(observed, arrival).state).toBe("PREDICTED_DIVERGENCE");
+ arrival.runwayAlignment.predictedSamples = 1;
+ expect(buildAirportRunwayChangeEvidenceD2(observed, arrival).state).toBe("OBSERVED_STABLE");
+ });
+ it("never uses wind as transition proof", () => {
+ const [observed, arrival] = inputs();
+ observed.state = "INSUFFICIENT";
+ observed.transition = null;
+ observed.windFavoredRunway = "11";
+ expect(buildAirportRunwayChangeEvidenceD2(observed, arrival).state).toBe("UNKNOWN");
+ });
+});