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) =>
  1. + + {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} + +
  2. )} +
:

{airportDCopy.noApproach}

} +

{airportDCopy.approachDisclaimer}

+
+ +
+
+
AIRPORT INTELLIGENCE / D4

{airportDCopy.contextHeading}

+
+ {airportContextD4.signals.length ? :

{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"); + }); +});