sadar / frontend /src /components /classify.ts
Ruperth's picture
feat: polish mobile responsive layout across all dashboard screens
96bba34
Raw
History Blame Contribute Delete
4.63 kB
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;
}