Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
498b2ef
feat(airport): add bounded observed-versus-public runway evidence int…
Boym323 Oct 8, 2026
2188801
feat(airport): add bounded observed-versus-public runway evidence int…
Boym323 Oct 8, 2026
82b4c9c
feat(airport): add bounded observed-versus-public runway evidence int…
Boym323 Oct 8, 2026
0678202
feat(airport): show evidence-backed runway transition status in exist…
Boym323 Oct 8, 2026
2618a1a
feat(airport): correlate approach, holding and go-around with bounded…
Boym323 Oct 8, 2026
e60c05a
feat(airport): correlate approach, holding and go-around with bounded…
Boym323 Oct 8, 2026
2e9f9a7
feat(i18n): localize conservative approach evidence labels
Boym323 Oct 8, 2026
57fc5ca
feat(airport): display verified active approach evidence in existing …
Boym323 Oct 8, 2026
96c1829
feat(airport): synthesize source-labeled operational context without …
Boym323 Oct 8, 2026
a840220
feat(airport): synthesize source-labeled operational context without …
Boym323 Oct 8, 2026
17dad98
feat(i18n): bilingual source-labeled airport and ATC context
Boym323 Oct 8, 2026
85f176d
feat(airport): expose operational context and existing airspace explo…
Boym323 Oct 8, 2026
848d38c
test(airport): freeze D1-D4 provider, privacy, PUBLIC, and single-str…
Boym323 Oct 8, 2026
9279581
docs(airport): close D2-D5 contracts, audit semantics and release gates
Boym323 Oct 8, 2026
cce4a1d
docs(airport): close D2-D5 contracts, audit semantics and release gates
Boym323 Oct 8, 2026
282eabc
feat(airport): expose bounded canonical exception evidence and preser…
Boym323 Oct 8, 2026
335a672
feat(airport): retain source-identified exception history in existing…
Boym323 Oct 8, 2026
abfc739
feat(airport): corroborate re-approach against already fetched Flight…
Boym323 Oct 8, 2026
bdb36bf
test(airport): require real FlightEvent-based history for reapproach …
Boym323 Oct 8, 2026
70a893d
docs(airport): document canonical FlightEvent corroboration and bound…
Boym323 Oct 8, 2026
c953551
docs(airport): document canonical FlightEvent corroboration and bound…
Boym323 Oct 8, 2026
3d614c6
test(airport): retain typed compression fields in D4 missing-evidence…
Boym323 Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions components/airport-operations-board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)} %`;
Expand Down Expand Up @@ -653,6 +661,49 @@ export function AirportOperationsBoard({
<p className="airport-v3-disclaimer">{t.airport.liveBoardV8Disclaimer}</p>
</section>

<section className="airport-live-flow-pressure" data-testid="airport-d2-runway-evidence" aria-label={airportDCopy.runwayHeading}>
<div className="airport-live-flow-heading">
<div><span className="ui-kicker">AIRPORT INTELLIGENCE / D2</span><h3>{airportDCopy.runwayHeading}</h3></div>
<strong>{runwayEvidenceD2.state === "OBSERVED_TRANSITION" ? airportDCopy.observedTransition
: runwayEvidenceD2.state === "PREDICTED_DIVERGENCE" ? airportDCopy.predictedDivergence
: runwayEvidenceD2.state === "OBSERVED_STABLE" ? airportDCopy.observedStable
: airportDCopy.unknown}</strong>
</div>
<p>{airportDCopy.runwayDetail(runwayEvidenceD2.previousRunway, runwayEvidenceD2.observedRunway, runwayEvidenceD2.observedSamples)}</p>
<p className="airport-v3-disclaimer">{airportDCopy.runwayDisclaimer}</p>
</section>

<section className="airport-live-flow-pressure" data-testid="airport-d3-approach-evidence" aria-label={airportDCopy.approachHeading}>
<div className="airport-live-flow-heading">
<div><span className="ui-kicker">AIRPORT INTELLIGENCE / D3</span><h3>{airportDCopy.approachHeading}</h3></div>
<span>{approachEvidenceD3.items.length}</span>
</div>
{approachEvidenceD3.items.length ? <ol className="airport-live-flight-list">
{approachEvidenceD3.items.map((item) => <li key={item.icaoHex}>
<span className="airport-live-flight-main">
<Link href={`/aircraft/${encodeURIComponent(item.icaoHex)}`}>{item.label}</Link>
<small>{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}</small>
</span>
</li>)}
</ol> : <p className="airport-v3-empty">{airportDCopy.noApproach}</p>}
<p className="airport-v3-disclaimer">{airportDCopy.approachDisclaimer}</p>
</section>

<section className="airport-live-flow-pressure" data-testid="airport-d4-operational-context" aria-label={airportDCopy.contextHeading}>
<div className="airport-live-flow-heading">
<div><span className="ui-kicker">AIRPORT INTELLIGENCE / D4</span><h3>{airportDCopy.contextHeading}</h3></div>
</div>
{airportContextD4.signals.length ? <ul className="airport-live-flight-list">
{airportContextD4.signals.map((signal) => <li key={signal}>{airportDCopy.contextSignals[signal]}</li>)}
</ul> : <p className="airport-v3-empty">{airportDCopy.noContext}</p>}
<p><Link href="/airspace">{airportDCopy.contextAtcLink} ↗</Link></p>
<p className="airport-v3-disclaimer">{airportDCopy.contextDisclaimer}</p>
</section>

{terminalDemandHorizon ? <section
className="airport-live-flow-pressure airport-live-terminal-horizon-v9"
data-testid="airport-live-board-v9-terminal-horizon"
Expand Down
35 changes: 35 additions & 0 deletions docs/AIRPORT-OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,38 @@ 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 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.
29 changes: 29 additions & 0 deletions docs/cs/AIRPORT-OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
48 changes: 48 additions & 0 deletions lib/airport-intelligence/airport-context-d4.ts
Original file line number Diff line number Diff line change
@@ -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",
};
}
109 changes: 109 additions & 0 deletions lib/airport-intelligence/approach-evidence-d3.ts
Original file line number Diff line number Diff line change
@@ -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<AirportMovement["movement"]>(["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<number, AirportMovement[]>();
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<string>();
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),
};
}
Loading
Loading