import type { PathPoint } from "../api"; export type AlertKind = "hijack" | "goaround" | "holding" | "altitude" | "route" | "anomaly"; export interface AlertClassification { kind: AlertKind; label: string; short: string; reason: string; critical: boolean; } const LEMD_LAT = 40.4936; const LEMD_LON = -3.5668; function distanceNm(lat: number, lon: number): number { const R = 6371; const dLat = ((lat - LEMD_LAT) * Math.PI) / 180; const dLon = ((lon - LEMD_LON) * Math.PI) / 180; const a = (LEMD_LAT * Math.PI) / 180; const b = (lat * Math.PI) / 180; const h = Math.sin(dLat / 2) ** 2 + Math.cos(a) * Math.cos(b) * Math.sin(dLon / 2) ** 2; return (2 * R * Math.asin(Math.sqrt(h))) / 1.852; } function headingDeg(a: PathPoint, b: PathPoint): number { const lat1 = (a.lat * Math.PI) / 180; const lat2 = (b.lat * Math.PI) / 180; const dLon = ((b.lon - a.lon) * Math.PI) / 180; const y = Math.sin(dLon) * Math.cos(lat2); const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon); return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360; } function angularDiff(a: number, b: number): number { let d = Math.abs(a - b) % 360; if (d > 180) d = 360 - d; return d; } export function classify(path: PathPoint[], currentStep: number): AlertClassification { const window = path.slice(Math.max(0, currentStep - 5), currentStep + 1); if (window.length < 2) { return { kind: "anomaly", label: "UNCLASSIFIED ANOMALY", short: "ANOM", reason: "insufficient history", critical: false, }; } const latVar = variance(window.map((p) => p.lat)); const lonVar = variance(window.map((p) => p.lon)); const altVar = variance(window.map((p) => p.alt)); const frozenSteps = window.length; if (latVar < 1e-9 && lonVar < 1e-9 && altVar < 1e-2 && frozenSteps >= 3) { return { kind: "hijack", label: "POSSIBLE HIJACK SIGNATURE", short: "HIJK?", reason: `transponder frozen ${frozenSteps} steps`, critical: true, }; } const altsM = window.map((p) => p.alt); const minAlt = Math.min(...altsM); const lastAlt = altsM[altsM.length - 1]; const altRise = lastAlt - minAlt; if (minAlt < 1500 && altRise > 250 && altsM[altsM.length - 1] > altsM[altsM.length - 2]) { return { kind: "goaround", label: "GO-AROUND PATTERN", short: "GA", reason: `climb after low pass (${Math.round(minAlt)} m)`, critical: false, }; } const headings: number[] = []; for (let i = 1; i < window.length; i++) headings.push(headingDeg(window[i - 1], window[i])); let totalTurn = 0; for (let i = 1; i < headings.length; i++) totalTurn += angularDiff(headings[i], headings[i - 1]); if (totalTurn > 180) { return { kind: "holding", label: "HOLDING PATTERN", short: "HOLD", reason: `cumulative turn ${Math.round(totalTurn)}°`, critical: false, }; } const current = path[currentStep]; const dist = distanceNm(current.lat, current.lon); if (dist < 5 && current.alt > 3000) { return { kind: "altitude", label: "ALTITUDE ANOMALY", short: "ALT!", reason: `${Math.round(current.alt)} m at ${dist.toFixed(1)} NM`, critical: false, }; } if (dist > 20 && current.alt < 500) { return { kind: "altitude", label: "ALTITUDE ANOMALY", short: "ALT!", reason: `${Math.round(current.alt)} m at ${dist.toFixed(1)} NM`, critical: false, }; } if (headings.length >= 2) { const meanHeading = circularMean(headings); const lastHeading = headings[headings.length - 1]; if (angularDiff(meanHeading, lastHeading) > 30) { return { kind: "route", label: "ROUTE DEVIATION", short: "ROUTE", reason: `heading drift ${Math.round(angularDiff(meanHeading, lastHeading))}°`, critical: false, }; } } return { kind: "anomaly", label: "UNCLASSIFIED ANOMALY", short: "ANOM", reason: "score above threshold, no clear signature", critical: false, }; } function variance(values: number[]): number { if (values.length === 0) return 0; const mean = values.reduce((a, b) => a + b, 0) / values.length; return values.reduce((acc, v) => acc + (v - mean) ** 2, 0) / values.length; } function circularMean(degrees: number[]): number { let sx = 0; let sy = 0; for (const d of degrees) { sx += Math.cos((d * Math.PI) / 180); sy += Math.sin((d * Math.PI) / 180); } return ((Math.atan2(sy / degrees.length, sx / degrees.length) * 180) / Math.PI + 360) % 360; }