File size: 14,472 Bytes
ee888e1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 | // @ts-check
// Mobility v1 adapter. Replaces the Phase 0 empty stub with a real
// MobilityState built from existing Redis inputs:
//
// aviation:delays:faa:v1 β US airport delays (FAA ASWS)
// aviation:delays:intl:v3 β ~51 non-US airports (AviationStack)
// aviation:notam:closures:v2 β global ICAO NOTAM closures
// intelligence:gpsjam:v2 β global GPS jamming hexes β airspace
// military:flights:v1 β global military ADSB β reroute proxy
//
// Output (per RegionalSnapshot.mobility):
//
// airspace[] β one aggregated entry per region from GPS-jam
// flight_corridors[] β empty in v1 (no direct corridor stress feed)
// airports[] β MAJOR/SEVERE airport alerts scoped to region
// reroute_intensity β clip(militaryCount/50, 0, 1) region-scoped
// notam_closures[] β NOTAM reason strings for airports in region
//
// All functions are PURE and export-tested β no Redis calls, no side effects.
// The seed writer passes already-fetched source objects in.
//
// Scope boundaries (explicit non-goals for v1):
// - flight_corridors[] stays empty β no direct rerouted-per-corridor feed
// - reroute_intensity uses military count as a crude proxy; future versions
// could use GPS-jam hex density or OpenSky track analysis
// - NOTAM classifier is text-based (closure vs restriction) β no structured parse
// ββ Region classification helpers ββββββββββββββββββββββββββββββββββββββββββββ
/**
* Split AviationStack/FAA AirportRegion enum by country into snapshot regions.
* The airport registry uses `americas / europe / apac / mena / africa`; the
* snapshot uses 7 finer regions. Americas splits by country (USA/CA/MX β
* north-america, rest β latam) and APAC splits by country (IN/PK/BD/LK/AF β
* south-asia, rest β east-asia). Proto enum strings and lowercase labels
* are both accepted.
*
* @param {{ region?: string, country?: string }} alert
* @returns {string | null} snapshot region id, or null if unmappable
*/
export function airportToSnapshotRegion(alert) {
if (!alert) return null;
const region = String(alert.region ?? '').toUpperCase();
const country = String(alert.country ?? '');
if (region.includes('AMERICAS')) {
if (NORTH_AMERICA_COUNTRIES.has(country)) return 'north-america';
return 'latam';
}
if (region.includes('APAC')) {
if (SOUTH_ASIA_COUNTRIES.has(country)) return 'south-asia';
return 'east-asia';
}
if (region.includes('EUROPE')) return 'europe';
if (region.includes('MENA')) return 'mena';
if (region.includes('AFRICA')) return 'sub-saharan-africa';
return null;
}
const NORTH_AMERICA_COUNTRIES = new Set([
'USA', 'United States', 'United States of America',
'Canada',
'Mexico',
]);
const SOUTH_ASIA_COUNTRIES = new Set([
'India', 'Pakistan', 'Bangladesh', 'Sri Lanka', 'Afghanistan', 'Nepal', 'Bhutan', 'Maldives',
]);
/**
* Map fetch-gpsjam.mjs classifyRegion() labels to snapshot region ids.
* Falls back to null for 'other' and unknown labels.
*
* @param {string | undefined} gpsjamRegion
* @returns {string | null}
*/
export function gpsjamRegionToSnapshotRegion(gpsjamRegion) {
switch (gpsjamRegion) {
case 'iran-iraq':
case 'levant':
case 'israel-sinai':
case 'yemen-horn':
case 'turkey-caucasus':
return 'mena';
case 'ukraine-russia':
case 'russia-north':
case 'northern-europe':
case 'western-europe':
return 'europe';
case 'sudan-sahel':
case 'east-africa':
return 'sub-saharan-africa';
case 'afghanistan-pakistan':
return 'south-asia';
case 'southeast-asia':
case 'east-asia':
return 'east-asia';
case 'north-america':
return 'north-america';
default:
return null;
}
}
/**
* Lat/lon β snapshot region bbox classifier for military flights. Coarse
* coverage matching the fetch-gpsjam.mjs region bboxes. Returns null for
* oceans and unmapped airspace.
*
* North America's southern edge is set at lat 16.0Β°N β that captures
* every major Mexican city and state capital (southernmost is Tuxtla
* GutiΓ©rrez at 16.75Β°N) while still routing Guatemala City (14.6Β°N),
* Belize City (17.5Β°N is on the line but Belize is routed via its
* country name in the airport mapper), and El Salvador to latam.
* Before this fix, NA started at lat 20 which left Mexico City (19.4Β°N)
* and most of Mexican airspace in latam, disagreeing with
* airportToSnapshotRegion()'s country-based MXβNA routing and
* understating NA's reroute_intensity from military tracks.
*
* @param {number} lat
* @param {number} lon
* @returns {string | null}
*/
export function latLonToSnapshotRegion(lat, lon) {
if (typeof lat !== 'number' || typeof lon !== 'number') return null;
// MENA (check before Europe so Turkey/Caucasus land MENA per our override)
if (lat >= 12 && lat <= 42 && lon >= 20 && lon <= 63) return 'mena';
// Europe + Russia
if (lat >= 35 && lat <= 72 && lon >= -10 && lon <= 60) return 'europe';
// Sub-Saharan Africa
if (lat >= -35 && lat <= 20 && lon >= -18 && lon <= 52) return 'sub-saharan-africa';
// South Asia
if (lat >= 5 && lat <= 38 && lon >= 60 && lon <= 97) return 'south-asia';
// East Asia / Southeast Asia / Oceania
if (lat >= -45 && lat <= 55 && lon >= 90 && lon <= 180) return 'east-asia';
// North America β includes all major Mexican cities/states. Checked
// before latam so the bbox overlap resolves to NA.
if (lat >= 16 && lat <= 75 && lon >= -170 && lon <= -50) return 'north-america';
// Latin America β capped at 16Β°N so Guatemala/Belize/El Salvador and
// southward fall here, while mainland Mexico goes to NA above.
if (lat >= -56 && lat < 16 && lon >= -120 && lon <= -34) return 'latam';
return null;
}
// ββ Airports block βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/** Severity tier at which an airport alert is considered mobility-relevant. */
const AIRPORT_MIN_SEVERITY_RANK = 3; // 0=normal 1=minor 2=moderate 3=major 4=severe
const SEVERITY_RANK = {
FLIGHT_DELAY_SEVERITY_NORMAL: 0,
FLIGHT_DELAY_SEVERITY_MINOR: 1,
FLIGHT_DELAY_SEVERITY_MODERATE: 2,
FLIGHT_DELAY_SEVERITY_MAJOR: 3,
FLIGHT_DELAY_SEVERITY_SEVERE: 4,
// Also accept the lowercase seeder-internal labels just in case
normal: 0, minor: 1, moderate: 2, major: 3, severe: 4,
};
/**
* @param {string | undefined} severity
* @returns {number}
*/
function severityRank(severity) {
return /** @type {any} */ (SEVERITY_RANK)[String(severity ?? '')] ?? 0;
}
/**
* Build airports[] for one region: filter alerts from both FAA and intl
* seeds down to severity >= MAJOR and map each to the snapshot's
* AirportNodeStatus shape.
*
* @param {string} regionId
* @param {Record<string, any>} sources
* @returns {import('../../shared/regions.types.js').AirportNodeStatus[]}
*/
export function buildAirports(regionId, sources) {
const faaAlerts = sources?.['aviation:delays:faa:v1']?.alerts;
const intlAlerts = sources?.['aviation:delays:intl:v3']?.alerts;
const allAlerts = [
...(Array.isArray(faaAlerts) ? faaAlerts : []),
...(Array.isArray(intlAlerts) ? intlAlerts : []),
];
/** @type {import('../../shared/regions.types.js').AirportNodeStatus[]} */
const out = [];
for (const a of allAlerts) {
if (airportToSnapshotRegion(a) !== regionId) continue;
const rank = severityRank(a?.severity);
if (rank < AIRPORT_MIN_SEVERITY_RANK) continue;
/** @type {'closed' | 'disrupted'} */
const status = rank >= 4 ? 'closed' : 'disrupted';
out.push({
icao: String(a?.icao ?? ''),
name: String(a?.name ?? a?.iata ?? ''),
status,
disruption_reason: String(a?.reason ?? ''),
});
}
return out;
}
// ββ NOTAM closures block βββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Emit NOTAM reason strings for any airport that the `airports[]` block
* would surface in this region. v1: derives the ICAO set from the airport
* alerts (so NOTAMs track the same airport scope) and pulls reason text
* from aviation:notam:closures:v2.reasons[icao].
*
* @param {string} regionId
* @param {Record<string, any>} sources
* @returns {string[]}
*/
export function buildNotamClosures(regionId, sources) {
const notam = sources?.['aviation:notam:closures:v2'];
const reasons = notam?.reasons && typeof notam.reasons === 'object' ? notam.reasons : {};
const closedIcaos = Array.isArray(notam?.closedIcaos) ? notam.closedIcaos : [];
const restrictedIcaos = Array.isArray(notam?.restrictedIcaos) ? notam.restrictedIcaos : [];
const candidates = new Set([...closedIcaos, ...restrictedIcaos]);
if (candidates.size === 0) return [];
// Determine which ICAOs belong to this region by cross-referencing the
// existing airport alert stream (both FAA + intl carry country/region).
const faaAlerts = sources?.['aviation:delays:faa:v1']?.alerts;
const intlAlerts = sources?.['aviation:delays:intl:v3']?.alerts;
/** @type {Record<string, string>} */
const icaoToRegion = {};
for (const a of Array.isArray(faaAlerts) ? faaAlerts : []) {
const r = airportToSnapshotRegion(a);
if (a?.icao && r) icaoToRegion[String(a.icao)] = r;
}
for (const a of Array.isArray(intlAlerts) ? intlAlerts : []) {
const r = airportToSnapshotRegion(a);
if (a?.icao && r) icaoToRegion[String(a.icao)] = r;
}
const out = [];
for (const icao of candidates) {
if (icaoToRegion[icao] !== regionId) continue;
const reason = String(reasons[icao] ?? '').slice(0, 200);
if (reason.length === 0) continue;
out.push(`${icao}: ${reason}`);
}
return out;
}
// ββ Airspace block (from GPS jamming) ββββββββββββββββββββββββββββββββββββββββ
const JAM_LEVEL_RANK = { low: 1, medium: 2, high: 3 };
/**
* Build airspace[] for one region. v1 aggregates GPS-jam hexes mapped to
* this region into ONE AirspaceStatus entry β emitting one per hex would
* flood the UI.
*
* Status resolution:
* - any 'high' level hex present β 'restricted'
* - only 'medium'/'low' hexes β 'restricted' (GPS jam still affects RNAV)
* - no hexes in region β block omits the region
*
* @param {string} regionId
* @param {Record<string, any>} sources
* @returns {import('../../shared/regions.types.js').AirspaceStatus[]}
*/
export function buildAirspace(regionId, sources) {
const hexes = sources?.['intelligence:gpsjam:v2']?.hexes;
if (!Array.isArray(hexes) || hexes.length === 0) return [];
let highCount = 0;
let mediumCount = 0;
let lowCount = 0;
/** @type {Set<string>} */
const subRegions = new Set();
for (const hex of hexes) {
const jamSnapshotRegion = gpsjamRegionToSnapshotRegion(hex?.region);
if (jamSnapshotRegion !== regionId) continue;
const level = String(hex?.level ?? 'low').toLowerCase();
if (level === 'high') highCount += 1;
else if (level === 'medium') mediumCount += 1;
else lowCount += 1;
if (hex?.region) subRegions.add(String(hex.region));
}
const total = highCount + mediumCount + lowCount;
if (total === 0) return [];
const subRegionList = [...subRegions].sort().join(', ');
const summary = `GPS jamming active over ${subRegionList || regionId}: ${highCount} high / ${mediumCount} medium / ${lowCount} low hexes`;
/** @type {import('../../shared/regions.types.js').AirspaceStatus[]} */
const out = [{
airspace_id: `gpsjam:${regionId}`,
status: 'restricted',
reason: summary,
}];
return out;
}
// ββ Reroute intensity ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const REROUTE_FLIGHTS_FULL_SCALE = 50; // military flight count at which reroute_intensity saturates to 1.0
/**
* Crude reroute_intensity proxy: count military flights whose lat/lon lands
* in this region and clip against a full-scale constant. A sustained
* military presence correlates with civil rerouting pressure, even if it's
* not a direct 1:1 measure.
*
* v2 could replace this with:
* - direct OpenSky ADSB civil-flight track diversion counts per corridor
* - GPS-jam hex density as a rerouting proxy (more rigorous)
* - operational NOTAM parse of ATS route closures
*
* @param {string} regionId
* @param {Record<string, any>} sources
* @returns {number} value in [0, 1]
*/
export function buildRerouteIntensity(regionId, sources) {
const flights = sources?.['military:flights:v1']?.flights;
if (!Array.isArray(flights) || flights.length === 0) return 0;
let count = 0;
for (const f of flights) {
const r = latLonToSnapshotRegion(Number(f?.lat), Number(f?.lon));
if (r === regionId) count += 1;
}
return Math.max(0, Math.min(1, count / REROUTE_FLIGHTS_FULL_SCALE));
}
// ββ Top-level composer ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Build the full MobilityState for one region from already-fetched sources.
* Pure, never throws, always returns a shape that matches the proto.
*
* @param {string} regionId
* @param {Record<string, any>} sources
* @returns {import('../../shared/regions.types.js').MobilityState}
*/
export function buildMobilityState(regionId, sources) {
try {
return {
airspace: buildAirspace(regionId, sources),
flight_corridors: [],
airports: buildAirports(regionId, sources),
reroute_intensity: buildRerouteIntensity(regionId, sources),
notam_closures: buildNotamClosures(regionId, sources),
};
} catch (err) {
// Defensive: any unexpected shape bug must not break snapshot persist.
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[mobility] ${regionId}: builder threw, returning empty: ${msg}`);
return {
airspace: [],
flight_corridors: [],
airports: [],
reroute_intensity: 0,
notam_closures: [],
};
}
}
|