From 498b2ef492b8e4523ec67f9078b86844555e3690 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:08:57 +0200 Subject: [PATCH 01/22] feat(airport): add bounded observed-versus-public runway evidence interpretation --- .../runway-change-evidence-d2.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 lib/airport-intelligence/runway-change-evidence-d2.ts 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, + }; +} From 218880181d456bd8659ff3cfdcf876c95d139c97 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:09:00 +0200 Subject: [PATCH 02/22] feat(airport): add bounded observed-versus-public runway evidence interpretation --- lib/i18n/airport-d-extras.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 lib/i18n/airport-d-extras.ts diff --git a/lib/i18n/airport-d-extras.ts b/lib/i18n/airport-d-extras.ts new file mode 100644 index 000000000..5a1e53693 --- /dev/null +++ b/lib/i18n/airport-d-extras.ts @@ -0,0 +1,26 @@ +/** Small dedicated bilingual copy for read-only Airport Intelligence stages D2-D4. */ +export const airportDExtras = { + cs: { + 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: { + 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; +} From 82b4c9cac7523529cfb84d508700309a96b6a5bc Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:09:03 +0200 Subject: [PATCH 03/22] feat(airport): add bounded observed-versus-public runway evidence interpretation --- .../airport-runway-change-evidence-d2.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/airport-runway-change-evidence-d2.test.ts 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"); + }); +}); From 06782029677605f4a50a41588b22ca55623b3dec Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:09:13 +0200 Subject: [PATCH 04/22] feat(airport): show evidence-backed runway transition status in existing live board --- components/airport-operations-board.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/components/airport-operations-board.tsx b/components/airport-operations-board.tsx index 4fc1f29b7..16978d3df 100644 --- a/components/airport-operations-board.tsx +++ b/components/airport-operations-board.tsx @@ -14,6 +14,8 @@ 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 { 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 +343,8 @@ export function AirportOperationsBoard({ runwayFlow, referenceTime: arrivalSequence.generatedAt ?? operations?.generatedAt ?? null, }); + const runwayEvidenceD2 = buildAirportRunwayChangeEvidenceD2(runwayFlow, arrivalFlow); + 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 +657,18 @@ 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}

+
+ {terminalDemandHorizon ?
Date: Thu, 8 Oct 2026 22:10:13 +0200 Subject: [PATCH 05/22] feat(airport): correlate approach, holding and go-around with bounded verified movement histories --- .../approach-evidence-d3.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 lib/airport-intelligence/approach-evidence-d3.ts diff --git a/lib/airport-intelligence/approach-evidence-d3.ts b/lib/airport-intelligence/approach-evidence-d3.ts new file mode 100644 index 000000000..157d4f10e --- /dev/null +++ b/lib/airport-intelligence/approach-evidence-d3.ts @@ -0,0 +1,105 @@ +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?.recentMovements ?? []).slice(0, 500)) { + 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), + }; +} From e60c05a2120eb6a32106247b77ff24e69e7d6e73 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:10:15 +0200 Subject: [PATCH 06/22] feat(airport): correlate approach, holding and go-around with bounded verified movement histories --- tests/airport-approach-evidence-d3.test.ts | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/airport-approach-evidence-d3.test.ts diff --git a/tests/airport-approach-evidence-d3.test.ts b/tests/airport-approach-evidence-d3.test.ts new file mode 100644 index 000000000..1727732d3 --- /dev/null +++ b/tests/airport-approach-evidence-d3.test.ts @@ -0,0 +1,65 @@ +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, 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, {...prior, flightId: 18}]), now, + }); + expect(mismatched.items[0]?.state).toBe("APPROACH_EVIDENCE"); + }); + 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); + }); +}); From 2e9f9a7f304b610e2970d8ad7d22bb4d0f2eaf1e Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:10:39 +0200 Subject: [PATCH 07/22] feat(i18n): localize conservative approach evidence labels --- lib/i18n/airport-d-extras.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/i18n/airport-d-extras.ts b/lib/i18n/airport-d-extras.ts index 5a1e53693..8263c31a3 100644 --- a/lib/i18n/airport-d-extras.ts +++ b/lib/i18n/airport-d-extras.ts @@ -1,6 +1,15 @@ /** Small dedicated bilingual copy for read-only Airport Intelligence stages D2-D4. */ export const airportDExtras = { cs: { + 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í", @@ -11,6 +20,15 @@ export const airportDExtras = { runwayDisclaimer: "Jde o porovnání pozorovaných pohybů a pouze veřejných predikcí, nikoliv potvrzenou volbu dráhy nebo pokyn ATC.", }, en: { + 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", From 57fc5cab9c25134712aae4a44ca9e24463356a2e Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:10:49 +0200 Subject: [PATCH 08/22] feat(airport): display verified active approach evidence in existing board --- components/airport-operations-board.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/components/airport-operations-board.tsx b/components/airport-operations-board.tsx index 16978d3df..a07ec3e8d 100644 --- a/components/airport-operations-board.tsx +++ b/components/airport-operations-board.tsx @@ -15,6 +15,7 @@ import { 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 { airportDText } from "@/lib/i18n/airport-d-extras"; import type { AirportOperationsControllerState } from "@/components/airport-operations-controller"; import type { AirportLiveTrafficControllerState } from "@/components/airport-live-traffic-controller"; @@ -344,6 +345,7 @@ export function AirportOperationsBoard({ referenceTime: arrivalSequence.generatedAt ?? operations?.generatedAt ?? null, }); const runwayEvidenceD2 = buildAirportRunwayChangeEvidenceD2(runwayFlow, arrivalFlow); + const approachEvidenceD3 = buildAirportApproachEvidenceD3({traffic: activeTraffic, operations}); const airportDCopy = airportDText(t.locale); const terminalDemandHorizon = operations?.terminalDemandHorizon ?? null; const timeline = buildAirportOperationsTimeline(operations); @@ -669,6 +671,26 @@ export function AirportOperationsBoard({

{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}

+
+ {terminalDemandHorizon ?
Date: Thu, 8 Oct 2026 22:11:09 +0200 Subject: [PATCH 09/22] feat(airport): synthesize source-labeled operational context without false ATC assertions --- .../airport-context-d4.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 lib/airport-intelligence/airport-context-d4.ts 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", + }; +} From a84022000187a62cb238923d77a85d004aaa372b Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:11:12 +0200 Subject: [PATCH 10/22] feat(airport): synthesize source-labeled operational context without false ATC assertions --- tests/airport-context-d4.test.ts | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/airport-context-d4.test.ts diff --git a/tests/airport-context-d4.test.ts b/tests/airport-context-d4.test.ts new file mode 100644 index 000000000..96b5f2402 --- /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: {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"); + }); +}); From 17dad9817987455bf443693ad81dd3ecec28be65 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:11:25 +0200 Subject: [PATCH 11/22] feat(i18n): bilingual source-labeled airport and ATC context --- lib/i18n/airport-d-extras.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/lib/i18n/airport-d-extras.ts b/lib/i18n/airport-d-extras.ts index 8263c31a3..56e11e11d 100644 --- a/lib/i18n/airport-d-extras.ts +++ b/lib/i18n/airport-d-extras.ts @@ -1,6 +1,21 @@ /** 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", @@ -20,6 +35,21 @@ export const airportDExtras = { 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", From 85f176da62b719060d07d69cd5259e4a1e0eca94 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:11:34 +0200 Subject: [PATCH 12/22] feat(airport): expose operational context and existing airspace explorer without unverified ATC claims --- components/airport-operations-board.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/components/airport-operations-board.tsx b/components/airport-operations-board.tsx index a07ec3e8d..f1ab8c6f8 100644 --- a/components/airport-operations-board.tsx +++ b/components/airport-operations-board.tsx @@ -16,6 +16,7 @@ import { buildAirportArrivalSequence } from "@/lib/airport-intelligence/arrival- 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"; @@ -346,6 +347,7 @@ export function AirportOperationsBoard({ }); 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); @@ -691,6 +693,17 @@ export function AirportOperationsBoard({

{airportDCopy.approachDisclaimer}

+
+
+
AIRPORT INTELLIGENCE / D4

{airportDCopy.contextHeading}

+
+ {airportContextD4.signals.length ?
    + {airportContextD4.signals.map((signal) =>
  • {airportDCopy.contextSignals[signal]}
  • )} +
:

{airportDCopy.noContext}

} +

{airportDCopy.contextAtcLink} ↗

+

{airportDCopy.contextDisclaimer}

+
+ {terminalDemandHorizon ?
Date: Thu, 8 Oct 2026 22:11:49 +0200 Subject: [PATCH 13/22] test(airport): freeze D1-D4 provider, privacy, PUBLIC, and single-stream release boundaries --- ...t-intelligence-d5-release-boundary.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/airport-intelligence-d5-release-boundary.test.ts 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/); + } + }); +}); From 9279581fe6c0966bb71ba64da4fd30ed9077263f Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:12:07 +0200 Subject: [PATCH 14/22] docs(airport): close D2-D5 contracts, audit semantics and release gates --- docs/AIRPORT-OPERATIONS.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/AIRPORT-OPERATIONS.md b/docs/AIRPORT-OPERATIONS.md index 72b64093a..afc99656e 100644 --- a/docs/AIRPORT-OPERATIONS.md +++ b/docs/AIRPORT-OPERATIONS.md @@ -206,3 +206,36 @@ runway and ATC evidence, rather than creating another dashboard or poller. - **D5: Evidence-backed release.** Deterministic scenario tests, realistic browser screenshots and longitudinal comparisons with independent recorded outcomes. No automatic PUBLIC prediction graduation. + +### D2–D5 implementation and verification + +D2 adds a **separate** conservative runway-evidence projection over existing +V7 observed, per-flight deduplicated 15-minute runway windows and V8 +readiness-gated PUBLIC runway predictions. It distinguishes an observed +transition from a prediction disagreement and a stable observed runway. Both +windows require at least three unique-flight samples and >=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 existing correlated +receiver snapshot and at most 500 existing recent airport movements. 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. From cce4a1d077caacfc3f76b9e16e1c9a1fad56b6d3 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:12:10 +0200 Subject: [PATCH 15/22] docs(airport): close D2-D5 contracts, audit semantics and release gates --- docs/cs/AIRPORT-OPERATIONS.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/cs/AIRPORT-OPERATIONS.md b/docs/cs/AIRPORT-OPERATIONS.md index 597a7f715..c73fa81b8 100644 --- a/docs/cs/AIRPORT-OPERATIONS.md +++ b/docs/cs/AIRPORT-OPERATIONS.md @@ -211,3 +211,30 @@ 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 a maximálně 500 nedávných +pohybů. Ž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. From 282eabcff5c601efd89de3ca64998cfd66883d03 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:14:09 +0200 Subject: [PATCH 16/22] feat(airport): expose bounded canonical exception evidence and preserve newer approach observations --- lib/server/airport-movements.ts | 34 +++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) 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) { From 335a672906eac90ef37df4609b204712e90fba32 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:14:16 +0200 Subject: [PATCH 17/22] feat(airport): retain source-identified exception history in existing operations response --- lib/server/airport-operations.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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, }; From abfc739f37f45155c596ccd9058305caa1cb5ef3 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:14:23 +0200 Subject: [PATCH 18/22] feat(airport): corroborate re-approach against already fetched Flight Intelligence exception events --- lib/airport-intelligence/approach-evidence-d3.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/airport-intelligence/approach-evidence-d3.ts b/lib/airport-intelligence/approach-evidence-d3.ts index 157d4f10e..d96d96d3c 100644 --- a/lib/airport-intelligence/approach-evidence-d3.ts +++ b/lib/airport-intelligence/approach-evidence-d3.ts @@ -43,7 +43,10 @@ export function buildAirportApproachEvidenceD3(input: { }): ApproachEvidenceD3 { const now = (input.now ?? new Date()).getTime(); const histories = new Map(); - for (const movement of (input.operations?.recentMovements ?? []).slice(0, 500)) { + 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); @@ -100,6 +103,7 @@ export function buildAirportApproachEvidenceD3(input: { 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), + complete: Boolean(input.operations?.complete && !input.operations?.truncated + && !input.operations?.eventEvidenceTruncated), }; } From bdb36bf5156cb59728f565052e80895c78ac1ccd Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:14:41 +0200 Subject: [PATCH 19/22] test(airport): require real FlightEvent-based history for reapproach evidence --- tests/airport-approach-evidence-d3.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/airport-approach-evidence-d3.test.ts b/tests/airport-approach-evidence-d3.test.ts index 1727732d3..a15e50a7f 100644 --- a/tests/airport-approach-evidence-d3.test.ts +++ b/tests/airport-approach-evidence-d3.test.ts @@ -25,14 +25,18 @@ describe("D3 approach transition evidence", () => { 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, prior]), now, + 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, {...prior, flightId: 18}]), now, + 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({ From 70a893d4a5d49a792fb1c1ff8944252b305e1d09 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:15:01 +0200 Subject: [PATCH 20/22] docs(airport): document canonical FlightEvent corroboration and bounded re-approach truth --- docs/AIRPORT-OPERATIONS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/AIRPORT-OPERATIONS.md b/docs/AIRPORT-OPERATIONS.md index afc99656e..aaea0b076 100644 --- a/docs/AIRPORT-OPERATIONS.md +++ b/docs/AIRPORT-OPERATIONS.md @@ -217,8 +217,10 @@ windows require at least three unique-flight samples and >=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 existing correlated -receiver snapshot and at most 500 existing recent airport movements. A live +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**. From c9535518c19036e1328fbb384f4b16bff71374e0 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:15:04 +0200 Subject: [PATCH 21/22] docs(airport): document canonical FlightEvent corroboration and bounded re-approach truth --- docs/cs/AIRPORT-OPERATIONS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/cs/AIRPORT-OPERATIONS.md b/docs/cs/AIRPORT-OPERATIONS.md index c73fa81b8..e714f6aed 100644 --- a/docs/cs/AIRPORT-OPERATIONS.md +++ b/docs/cs/AIRPORT-OPERATIONS.md @@ -220,8 +220,10 @@ 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 a maximálně 500 nedávných -pohybů. Živá fáze bez odpovídající ICAO identity, instance letu, času a +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. From 3d614c6c2bc70b6d9fce5abab44edd6b86bcc129 Mon Sep 17 00:00:00 2001 From: Martin Pomykal Date: Thu, 8 Oct 2026 22:16:05 +0200 Subject: [PATCH 22/22] test(airport): retain typed compression fields in D4 missing-evidence fixture --- tests/airport-context-d4.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/airport-context-d4.test.ts b/tests/airport-context-d4.test.ts index 96b5f2402..1d6a57f5a 100644 --- a/tests/airport-context-d4.test.ts +++ b/tests/airport-context-d4.test.ts @@ -25,7 +25,7 @@ describe("D4 airport context", () => { 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: {state: "UNKNOWN"}}, + arrival: {...arrival, compression: {...arrival.compression, state: "UNKNOWN"}}, approach: {...approach, counts: {...approach.counts, goAround: 0}, complete: true}, windAvailable: false, });