diff --git a/docker-compose.yml b/docker-compose.yml index a82a5f5..e2d47a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -289,6 +289,33 @@ services: depends_on: - redis + situation-api: + build: + context: . + dockerfile: docker/Dockerfile + container_name: radrview-situation-api + command: ["node", "dist/situation/index.js"] + restart: unless-stopped + volumes: + - radrview-data:/data + environment: + - REDIS_URL=redis://redis:6379 + - DATA_DIR=/data + - SITUATION_PORT=8601 + - SAMPLING_ZOOM=7 + - LOG_LEVEL=info + labels: + traefik.enable: "true" + traefik.http.routers.situation.rule: "PathPrefix(`/situation`) || PathPrefix(`/overlays`) || PathPrefix(`/ws/aviation`)" + traefik.http.routers.situation.entrypoints: "web" + traefik.http.services.situation.loadbalancer.server.port: "8601" + traefik.docker.network: "proxy" + networks: + - proxy + - internal + depends_on: + - redis + volumes: radrview-data: driver: local diff --git a/package.json b/package.json index 7785e6d..213cb9d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/amqplib": "^0.10.8", "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.0", + "@types/geojson": "^7946.0.16", "@types/node": "^22.0.0", "@types/supertest": "^6.0.0", "@types/ws": "^8.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9440e3d..474e5c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.6 + '@types/geojson': + specifier: ^7946.0.16 + version: 7946.0.16 '@types/node': specifier: ^22.0.0 version: 22.19.15 @@ -917,6 +920,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -2869,6 +2875,8 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 + '@types/geojson@7946.0.16': {} + '@types/http-errors@2.0.5': {} '@types/methods@1.1.4': {} diff --git a/scripts/generate-airports.ts b/scripts/generate-airports.ts new file mode 100644 index 0000000..c65d230 --- /dev/null +++ b/scripts/generate-airports.ts @@ -0,0 +1,70 @@ +// Downloads OurAirports data and generates airports.json. +// Run: npx tsx scripts/generate-airports.ts + +import { writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CSV_URL = 'https://davidmegginson.github.io/ourairports-data/airports.csv'; + +function parseCSVLine(line: string): string[] { + const result: string[] = []; + let inQuotes = false; + let current = ''; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + inQuotes = !inQuotes; + } else if (ch === ',' && !inQuotes) { + result.push(current); + current = ''; + } else { + current += ch; + } + } + result.push(current); + return result; +} + +async function main() { + console.log('Fetching OurAirports data...'); + const resp = await fetch(CSV_URL); + const text = await resp.text(); + const lines = text.split('\n'); + const header = parseCSVLine(lines[0]).map(h => h.trim()); + + const identIdx = header.indexOf('ident'); + const typeIdx = header.indexOf('type'); + const nameIdx = header.indexOf('name'); + const latIdx = header.indexOf('latitude_deg'); + const lonIdx = header.indexOf('longitude_deg'); + + const airports: Record = {}; + let count = 0; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + const cols = parseCSVLine(line); + + const ident = (cols[identIdx] || '').trim(); + const type = (cols[typeIdx] || '').trim(); + const lat = parseFloat((cols[latIdx] || '').trim()); + const lon = parseFloat((cols[lonIdx] || '').trim()); + const name = (cols[nameIdx] || '').trim(); + + if (ident.length !== 4) continue; + if (type === 'heliport' || type === 'closed') continue; + if (isNaN(lat) || isNaN(lon)) continue; + + airports[ident] = { name, lat: Math.round(lat * 1e6) / 1e6, lon: Math.round(lon * 1e6) / 1e6 }; + count++; + } + + const __dirname = dirname(fileURLToPath(import.meta.url)); + const outPath = join(__dirname, '..', 'data', 'airports.json'); + writeFileSync(outPath, JSON.stringify(airports, null, 2)); + console.log(`Wrote ${count} airports to ${outPath}`); +} + +main().catch(console.error); diff --git a/src/config/env.ts b/src/config/env.ts index 39724c8..466d6fb 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -17,4 +17,7 @@ export const config = { nexradEnabled: process.env.NEXRAD_ENABLED !== 'false', // on by default nexradZoomMin: parseInt(process.env.NEXRAD_ZOOM_MIN || '8', 10), nexradStations: process.env.NEXRAD_STATIONS || 'all', + situationPort: parseInt(process.env.SITUATION_PORT || '8601', 10), + samplingZoom: parseInt(process.env.SAMPLING_ZOOM || '7', 10), + airportsOverridePath: process.env.AIRPORTS_OVERRIDE_PATH || '', } as const; diff --git a/src/situation/analysis/history.ts b/src/situation/analysis/history.ts new file mode 100644 index 0000000..33b9166 --- /dev/null +++ b/src/situation/analysis/history.ts @@ -0,0 +1,28 @@ +import type { Redis } from 'ioredis'; +import type { HistoryFrame } from '../types.js'; + +const KEY_PREFIX = 'situation:history:'; + +export class HistoryManager { + private readonly redis: Redis; + + constructor(redis: Redis) { + this.redis = redis; + } + + async addFrame(icao: string, epochMs: number, frame: HistoryFrame): Promise { + await this.redis.zadd(`${KEY_PREFIX}${icao}`, epochMs, JSON.stringify(frame)); + } + + async getFrames(icao: string, hours: number, now?: number): Promise { + const currentMs = now ?? Date.now(); + const minMs = currentMs - hours * 3600_000; + const raw = await this.redis.zrangebyscore(`${KEY_PREFIX}${icao}`, minMs, currentMs); + return raw.map((s: string) => JSON.parse(s)); + } + + async prune(icao: string, retentionHours: number): Promise { + const cutoff = Date.now() - retentionHours * 3600_000; + await this.redis.zremrangebyscore(`${KEY_PREFIX}${icao}`, '-inf', cutoff); + } +} diff --git a/src/situation/analysis/severity.ts b/src/situation/analysis/severity.ts new file mode 100644 index 0000000..7356fe2 --- /dev/null +++ b/src/situation/analysis/severity.ts @@ -0,0 +1,51 @@ +import type { RingData, Severity, RampStatus, Trend, SystemStatus } from '../types.js'; +import { + SEVERITY_THRESHOLDS, + RAMP_THRESHOLDS, + TREND_THRESHOLD_DBZ, + CLEAR_DBZ, + RECOMMENDATION_THRESHOLDS, + SYSTEM_STATUS_THRESHOLDS, +} from '../config/thresholds.js'; + +export function dbzToSeverity(dbz: number): Severity { + if (dbz >= SEVERITY_THRESHOLDS.heavy) return 'extreme'; + if (dbz >= SEVERITY_THRESHOLDS.moderate) return 'heavy'; + if (dbz >= SEVERITY_THRESHOLDS.light) return 'moderate'; + if (dbz >= SEVERITY_THRESHOLDS.clear) return 'light'; + return 'clear'; +} + +export function computeRampStatus(ring5nm: RingData, ring20nm: RingData): RampStatus { + if (ring20nm.precipTypes.some(t => RAMP_THRESHOLDS.hailPrecipTypes.includes(t))) return 'suspend'; + if (ring5nm.maxDbz > RAMP_THRESHOLDS.suspendDbz) return 'suspend'; + if (ring20nm.precipTypes.some(t => RAMP_THRESHOLDS.freezingPrecipTypes.includes(t))) return 'caution'; + if (ring5nm.maxDbz >= RAMP_THRESHOLDS.cautionDbz) return 'caution'; + return 'clear'; +} + +export function computeTrend(currentMaxDbz: number, previousMaxDbz: number | null): Trend { + if (previousMaxDbz === null) return 'unknown'; + const currentClear = currentMaxDbz < CLEAR_DBZ; + const previousClear = previousMaxDbz < CLEAR_DBZ; + if (currentClear) return 'clearing'; + if (previousClear && !currentClear) return 'developing'; + const delta = currentMaxDbz - previousMaxDbz; + if (delta > TREND_THRESHOLD_DBZ) return 'intensifying'; + if (delta < -TREND_THRESHOLD_DBZ) return 'weakening'; + return 'steady'; +} + +export function dbzToRecommendation(dbz: number): string { + if (dbz >= RECOMMENDATION_THRESHOLDS.avoid) return 'avoid segment'; + if (dbz >= RECOMMENDATION_THRESHOLDS.deviationsLikely) return 'deviations likely'; + if (dbz >= RECOMMENDATION_THRESHOLDS.deviationsPossible) return 'deviations possible'; + if (dbz >= RECOMMENDATION_THRESHOLDS.monitor) return 'monitor'; + return 'clear'; +} + +export function computeSystemStatus(dataAgeSeconds: number): SystemStatus { + if (dataAgeSeconds > SYSTEM_STATUS_THRESHOLDS.offlineAfterSeconds) return 'offline'; + if (dataAgeSeconds > SYSTEM_STATUS_THRESHOLDS.degradedAfterSeconds) return 'degraded'; + return 'operational'; +} diff --git a/src/situation/analysis/summary.ts b/src/situation/analysis/summary.ts new file mode 100644 index 0000000..7a11a9e --- /dev/null +++ b/src/situation/analysis/summary.ts @@ -0,0 +1,98 @@ +import type { Redis } from 'ioredis'; +import type { RegionConfig, RegionSummary } from '../types.js'; +import { TileReader } from '../sampling/tile-reader.js'; +import { dbzToSeverity, computeTrend } from './severity.js'; +import { getTilesForBounds, latLonToMercator } from '../../utils/geo.js'; +import { CLEAR_DBZ } from '../config/thresholds.js'; + +const SUMMARY_ZOOM = 4; + +export class SummaryAnalyzer { + private readonly reader: TileReader; + private readonly redis: Redis; + + constructor(reader: TileReader, redis: Redis) { + this.reader = reader; + this.redis = redis; + } + + async analyzeRegion( + region: RegionConfig, + source: string, + timestamp?: string, + previousTimestamp?: string, + ): Promise { + const sw = latLonToMercator(region.bounds.south, region.bounds.west); + const ne = latLonToMercator(region.bounds.north, region.bounds.east); + const tiles = getTilesForBounds(SUMMARY_ZOOM, sw.x, ne.y, ne.x, sw.y); + + let maxDbz = 0; + let totalPixels = 0; + let aboveThresholdPixels = 0; + const precipTypes = new Set(); + + for (const tile of tiles) { + const dbzData = await this.reader.readTileDbz(source, tile.z, tile.x, tile.y, timestamp); + if (!dbzData) continue; + + const typeData = await this.reader.readTileType(source, tile.z, tile.x, tile.y, timestamp); + + for (let i = 0; i < dbzData.dbzValues.length; i++) { + const dbz = dbzData.dbzValues[i]; + if (isNaN(dbz)) continue; + totalPixels++; + if (dbz >= CLEAR_DBZ) aboveThresholdPixels++; + if (dbz > maxDbz) maxDbz = dbz; + if (typeData) { + const code = typeData.typeValues[i]; + const label = this.reader.precipTypeLabel(code); + if (label) precipTypes.add(label); + } + } + } + + let previousMaxDbz: number | null = null; + if (previousTimestamp) { + previousMaxDbz = await this.analyzeRegionMaxDbz(region, source, previousTimestamp); + } + + const coveragePct = totalPixels > 0 + ? Math.round(aboveThresholdPixels / totalPixels * 1000) / 10 + : 0; + + return { + id: region.id, + label: region.label, + bounds: region.bounds, + maxDbz: Math.round(maxDbz), + coveragePct, + precipTypes: [...precipTypes].sort(), + severity: dbzToSeverity(maxDbz), + trend: computeTrend(maxDbz, previousMaxDbz), + affectedAirports: [], + }; + } + + private async analyzeRegionMaxDbz( + region: RegionConfig, source: string, timestamp: string, + ): Promise { + const sw = latLonToMercator(region.bounds.south, region.bounds.west); + const ne = latLonToMercator(region.bounds.north, region.bounds.east); + const tiles = getTilesForBounds(SUMMARY_ZOOM, sw.x, ne.y, ne.x, sw.y); + + let maxDbz = 0; + for (const tile of tiles) { + const dbzData = await this.reader.readTileDbz(source, tile.z, tile.x, tile.y, timestamp); + if (!dbzData) continue; + for (let i = 0; i < dbzData.dbzValues.length; i++) { + const dbz = dbzData.dbzValues[i]; + if (!isNaN(dbz) && dbz > maxDbz) maxDbz = dbz; + } + } + return maxDbz; + } + + computeDataAge(epochMs: number): number { + return Math.round((Date.now() - epochMs) / 1000); + } +} diff --git a/src/situation/config/airports.ts b/src/situation/config/airports.ts new file mode 100644 index 0000000..8cb1918 --- /dev/null +++ b/src/situation/config/airports.ts @@ -0,0 +1,41 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Airport } from '../types.js'; +import { createLogger } from '../../utils/logger.js'; + +const logger = createLogger('airports'); + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +let airports: Map = new Map(); + +export function loadAirports(overridePath?: string): void { + const bundledPath = join(__dirname, '..', '..', '..', 'data', 'airports.json'); + const bundled: Record = + JSON.parse(readFileSync(bundledPath, 'utf-8')); + + airports = new Map( + Object.entries(bundled).map(([icao, data]) => [icao, { icao, ...data }]), + ); + + logger.info({ count: airports.size }, 'Loaded bundled airports'); + + if (overridePath && existsSync(overridePath)) { + const override: Record = + JSON.parse(readFileSync(overridePath, 'utf-8')); + + for (const [icao, data] of Object.entries(override)) { + airports.set(icao, { icao, ...data }); + } + logger.info({ count: Object.keys(override).length }, 'Merged airport overrides'); + } +} + +export function getAirport(icao: string): Airport | undefined { + return airports.get(icao.toUpperCase()); +} + +export function getAllAirports(): Airport[] { + return [...airports.values()]; +} diff --git a/src/situation/config/regions.ts b/src/situation/config/regions.ts new file mode 100644 index 0000000..6c08c5b --- /dev/null +++ b/src/situation/config/regions.ts @@ -0,0 +1,52 @@ +import type { RegionConfig } from '../types.js'; + +export const REGIONS: RegionConfig[] = [ + { + id: 'northeast-corridor', + label: 'Northeast Corridor', + bounds: { north: 45, south: 38, east: -70, west: -79 }, + airports: ['KBOS', 'KJFK', 'KEWR', 'KPHL', 'KBWI', 'KDCA', 'KLGA'], + }, + { + id: 'southeast', + label: 'Southeast', + bounds: { north: 38, south: 25, east: -75, west: -90 }, + airports: ['KATL', 'KMIA', 'KMCO', 'KCLT', 'KFLL', 'KTPA'], + }, + { + id: 'midwest', + label: 'Midwest', + bounds: { north: 49, south: 37, east: -80, west: -98 }, + airports: ['KORD', 'KDTW', 'KMSP', 'KSTL', 'KCLE', 'KCVG'], + }, + { + id: 'south-central', + label: 'South Central', + bounds: { north: 37, south: 26, east: -90, west: -105 }, + airports: ['KDFW', 'KIAH', 'KHOU', 'KAUS', 'KSAT', 'KMSN'], + }, + { + id: 'mountain-west', + label: 'Mountain West', + bounds: { north: 49, south: 32, east: -98, west: -115 }, + airports: ['KDEN', 'KSLC', 'KPHX', 'KABQ', 'KLAS'], + }, + { + id: 'pacific-west', + label: 'Pacific West', + bounds: { north: 49, south: 32, east: -115, west: -125 }, + airports: ['KLAX', 'KSFO', 'KSEA', 'KPDX', 'KSAN'], + }, + { + id: 'western-europe', + label: 'Western Europe', + bounds: { north: 56, south: 44, east: 15, west: -5 }, + airports: ['EGLL', 'LFPG', 'EDDF', 'EHAM', 'LEMD', 'LIRF'], + }, + { + id: 'central-europe', + label: 'Central Europe', + bounds: { north: 56, south: 44, east: 25, west: 10 }, + airports: ['EDDM', 'LOWW', 'EPWA', 'LKPR', 'LHBP'], + }, +]; diff --git a/src/situation/config/thresholds.ts b/src/situation/config/thresholds.ts new file mode 100644 index 0000000..832a60c --- /dev/null +++ b/src/situation/config/thresholds.ts @@ -0,0 +1,42 @@ +export const SEVERITY_THRESHOLDS = { + clear: 20, + light: 35, + moderate: 50, + heavy: 60, +} as const; + +export const RAMP_THRESHOLDS = { + cautionDbz: 35, + suspendDbz: 50, + freezingPrecipTypes: ['freezing_rain', 'mixed'] as readonly string[], + hailPrecipTypes: ['hail'] as readonly string[], +} as const; + +export const TREND_THRESHOLD_DBZ = 5; +export const CLEAR_DBZ = 20; + +export const RECOMMENDATION_THRESHOLDS = { + monitor: 20, + deviationsPossible: 35, + deviationsLikely: 50, + avoid: 60, +} as const; + +export const SYSTEM_STATUS_THRESHOLDS = { + degradedAfterSeconds: 300, + offlineAfterSeconds: 600, +} as const; + +export const PRECIP_TYPE_MAP: Record = { + 1: 'rain', + 2: 'snow', + 3: 'freezing_rain', + 4: 'mixed', + 5: 'hail', +}; + +export const RING_RADII_NM = [5, 20, 50] as const; + +export const ACTIVE_CELL_MIN_DBZ = 35; + +export const SIGNIFICANT_CELL_MIN_DBZ = 40; diff --git a/src/situation/index.ts b/src/situation/index.ts new file mode 100644 index 0000000..5562750 --- /dev/null +++ b/src/situation/index.ts @@ -0,0 +1,85 @@ +import { createServer } from 'node:http'; +import { Redis } from 'ioredis'; +import { WebSocketServer } from 'ws'; +import { config } from '../config/env.js'; +import { createLogger } from '../utils/logger.js'; +import { loadAirports } from './config/airports.js'; +import { createSituationApp } from './server.js'; +import { AviationWebSocketHandler } from './ws/aviation.js'; +import { SYSTEM_STATUS_THRESHOLDS } from './config/thresholds.js'; + +const logger = createLogger('situation-api'); + +const isMainModule = process.argv[1]?.endsWith('situation/index.js') || + process.argv[1]?.endsWith('situation/index.ts'); + +if (isMainModule) { + loadAirports(config.airportsOverridePath || undefined); + + const redis = new Redis(config.redisUrl); + const subscriber = new Redis(config.redisUrl); + + const { app, updater } = createSituationApp(redis); + const httpServer = createServer(app); + + const wss = new WebSocketServer({ server: httpServer, path: '/ws/aviation' }); + const wsHandler = new AviationWebSocketHandler(wss); + + const syncWatchlist = async () => { + const wsAirports = wsHandler.getWatchedAirports(); + const currentWatchlist = await updater.getWatchlist(); + + const activeSet = new Set(wsAirports); + const stale = currentWatchlist.filter(icao => !activeSet.has(icao)); + + if (wsAirports.length > 0) { + await updater.addToWatchlist(wsAirports); + } + if (stale.length > 0) { + await updater.removeFromWatchlist(stale); + } + }; + + subscriber.subscribe('new-frame'); + subscriber.on('message', async (_channel: string, message: string) => { + try { + const event = JSON.parse(message); + if (event.source !== 'composite') return; + + logger.info({ timestamp: event.timestamp }, 'Processing new composite frame'); + + await syncWatchlist(); + + const messages = await updater.processNewFrame(); + + if (messages.length > 0) { + wsHandler.broadcastMessages(messages); + logger.info({ messageCount: messages.length }, 'Broadcast condition changes'); + } + + const epochMs = event.epochMs || 0; + const ageSeconds = Math.round((Date.now() - epochMs) / 1000); + if (ageSeconds > SYSTEM_STATUS_THRESHOLDS.offlineAfterSeconds) { + wsHandler.broadcastDataStale(ageSeconds, ['mrms']); + } + } catch (err) { + logger.error({ err }, 'Failed to process new-frame event'); + } + }); + + const shutdown = async () => { + logger.info('Shutting down situation-api'); + wsHandler.close(); + wss.close(); + httpServer.close(); + subscriber.disconnect(); + await redis.quit(); + process.exit(0); + }; + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + + httpServer.listen(config.situationPort, () => { + logger.info({ port: config.situationPort }, 'Situation API listening'); + }); +} diff --git a/src/situation/routes/airport.ts b/src/situation/routes/airport.ts new file mode 100644 index 0000000..486c7af --- /dev/null +++ b/src/situation/routes/airport.ts @@ -0,0 +1,68 @@ +import { Router } from 'express'; +import type { Redis } from 'ioredis'; +import { getAirport } from '../config/airports.js'; +import { WatchlistUpdater } from '../workers/watchlist-updater.js'; +import { TileReader } from '../sampling/tile-reader.js'; +import { RingSampler } from '../sampling/ring-sampler.js'; +import { computeRampStatus, computeTrend } from '../analysis/severity.js'; +import { config } from '../../config/env.js'; + +export function createAirportRouter(redis: Redis, updater: WatchlistUpdater): Router { + const router = Router(); + const reader = new TileReader(redis); + const sampler = new RingSampler(reader); + + router.get('/situation/airport/:icao', async (req, res) => { + const icao = req.params.icao.toUpperCase(); + const airport = getAirport(icao); + + if (!airport) { + res.status(404).json({ error: `Airport not found: ${icao}` }); + return; + } + + const cached = await updater.getCachedSituation(icao); + if (cached) { + res.json(cached); + return; + } + + const timestamp = await reader.getLatestTimestamp('composite'); + if (!timestamp) { + res.status(503).json({ error: 'No composite data available' }); + return; + } + + const epochMs = await reader.getTimestampEpochMs('composite', timestamp); + const dataAge = Math.round((Date.now() - epochMs) / 1000); + + const ringResult = await sampler.sampleRings( + airport.lat, airport.lon, 'composite', config.samplingZoom, timestamp, + ); + + const rampStatus = computeRampStatus(ringResult.rings['5nm'], ringResult.rings['20nm']); + + const previousTimestamp = await reader.getPreviousTimestamp('composite'); + let previousMaxDbz: number | null = null; + if (previousTimestamp) { + const prevRings = await sampler.sampleRings( + airport.lat, airport.lon, 'composite', config.samplingZoom, previousTimestamp, + ); + previousMaxDbz = prevRings.rings['50nm'].maxDbz; + } + + const trend = computeTrend(ringResult.rings['50nm'].maxDbz, previousMaxDbz); + + res.json({ + icao, + timestamp: new Date(epochMs).toISOString(), + dataAge, + rings: ringResult.rings, + trend, + rampStatus, + nearestActiveCell: ringResult.nearestActiveCell, + }); + }); + + return router; +} diff --git a/src/situation/routes/cells.ts b/src/situation/routes/cells.ts new file mode 100644 index 0000000..5acbb9e --- /dev/null +++ b/src/situation/routes/cells.ts @@ -0,0 +1,30 @@ +import { Router } from 'express'; +import type { Redis } from 'ioredis'; +import { TileReader } from '../sampling/tile-reader.js'; +import { CellDetector } from '../sampling/cell-detector.js'; + +export function createCellsRouter(redis: Redis): Router { + const router = Router(); + const reader = new TileReader(redis); + const detector = new CellDetector(reader, redis); + + router.get('/overlays/cells.geojson', async (req, res) => { + const threshold = parseInt(req.query.threshold as string) || 35; + const boundsParam = req.query.bounds as string; + + let bounds: { north: number; south: number; east: number; west: number } | undefined; + if (boundsParam) { + const parts = boundsParam.split(',').map(Number); + if (parts.length !== 4 || parts.some(isNaN)) { + res.status(400).json({ error: 'Invalid bounds format. Use: north,south,east,west' }); + return; + } + bounds = { north: parts[0], south: parts[1], east: parts[2], west: parts[3] }; + } + + const result = await detector.detectCells(threshold, bounds); + res.json(result); + }); + + return router; +} diff --git a/src/situation/routes/history.ts b/src/situation/routes/history.ts new file mode 100644 index 0000000..5065eb1 --- /dev/null +++ b/src/situation/routes/history.ts @@ -0,0 +1,33 @@ +import { Router } from 'express'; +import type { Redis } from 'ioredis'; +import { getAirport } from '../config/airports.js'; +import { HistoryManager } from '../analysis/history.js'; + +export function createHistoryRouter(redis: Redis): Router { + const router = Router(); + const history = new HistoryManager(redis); + + router.get('/situation/airport/:icao/history', async (req, res) => { + const icao = req.params.icao.toUpperCase(); + const airport = getAirport(icao); + if (!airport) { + res.status(404).json({ error: `Airport not found: ${icao}` }); + return; + } + + const isWatched = await redis.sismember('situation:watchlist', icao); + if (!isWatched) { + res.status(404).json({ + error: `Airport ${icao} is not on the watchlist. History is only available for watched airports.`, + }); + return; + } + + const hours = Math.min(24, Math.max(1, parseInt(req.query.hours as string) || 3)); + const frames = await history.getFrames(icao, hours); + + res.json({ icao, hours, frames }); + }); + + return router; +} diff --git a/src/situation/routes/route.ts b/src/situation/routes/route.ts new file mode 100644 index 0000000..f6677e9 --- /dev/null +++ b/src/situation/routes/route.ts @@ -0,0 +1,54 @@ +import { Router } from 'express'; +import type { Redis } from 'ioredis'; +import { getAirport } from '../config/airports.js'; +import { TileReader } from '../sampling/tile-reader.js'; +import { RouteSampler } from '../sampling/route-sampler.js'; +import { config } from '../../config/env.js'; +import type { Airport } from '../types.js'; + +export function createRouteRouter(redis: Redis): Router { + const router = Router(); + const reader = new TileReader(redis); + const sampler = new RouteSampler(reader); + + router.get('/situation/route', async (req, res) => { + const waypointsParam = req.query.waypoints as string; + if (!waypointsParam) { + res.status(400).json({ error: 'Missing waypoints parameter' }); + return; + } + + const icaos = waypointsParam.split(',').map(s => s.trim().toUpperCase()); + if (icaos.length < 2) { + res.status(400).json({ error: 'At least 2 waypoints required' }); + return; + } + + const airports = icaos.map(icao => getAirport(icao)); + const missing = icaos.filter((_icao, i) => !airports[i]); + if (missing.length > 0) { + res.status(400).json({ error: `Unknown airports: ${missing.join(', ')}` }); + return; + } + + const timestamp = await reader.getLatestTimestamp('composite'); + if (!timestamp) { + res.status(503).json({ error: 'No composite data available' }); + return; + } + + const epochMs = await reader.getTimestampEpochMs('composite', timestamp); + const validAirports = airports.filter((a): a is Airport => a !== undefined); + const segments = await sampler.sampleRoute( + validAirports, 'composite', config.samplingZoom, timestamp, + ); + + res.json({ + waypoints: icaos, + timestamp: new Date(epochMs).toISOString(), + segments, + }); + }); + + return router; +} diff --git a/src/situation/routes/summary.ts b/src/situation/routes/summary.ts new file mode 100644 index 0000000..a6dd4f9 --- /dev/null +++ b/src/situation/routes/summary.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import type { Redis } from 'ioredis'; + +export function createSummaryRouter(redis: Redis): Router { + const router = Router(); + + router.get('/situation/summary', async (_req, res) => { + const cached = await redis.get('situation:summary'); + if (cached) { + res.json(JSON.parse(cached)); + return; + } + res.status(503).json({ error: 'Summary not yet computed' }); + }); + + return router; +} diff --git a/src/situation/sampling/cell-detector.ts b/src/situation/sampling/cell-detector.ts new file mode 100644 index 0000000..e01d140 --- /dev/null +++ b/src/situation/sampling/cell-detector.ts @@ -0,0 +1,272 @@ +import { execFile } from 'node:child_process'; +import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import sharp from 'sharp'; +import type { Redis } from 'ioredis'; +import { TileReader } from './tile-reader.js'; +import { + dbzToPixel, + getTilesForBounds, + tileToMercatorBounds, + latLonToMercator, + EARTH_CIRCUMFERENCE, +} from '../../utils/geo.js'; +import { dbzToSeverity } from '../analysis/severity.js'; +import { createLogger } from '../../utils/logger.js'; +import type { FeatureCollection, Feature, Polygon, MultiPolygon } from 'geojson'; + +const logger = createLogger('cell-detector'); +const execFileAsync = promisify(execFile); +const HALF = EARTH_CIRCUMFERENCE / 2; +const CELL_ZOOM = 4; +const TILES_PER_AXIS = 2 ** CELL_ZOOM; + +export class CellDetector { + private readonly reader: TileReader; + private readonly redis: Redis; + private _dbzBuffer: Float32Array = new Float32Array(0); + private _width = 0; + private _height = 0; + private _originX = 0; + private _originY = 0; + private _extentX = 0; + private _extentY = 0; + + constructor(reader: TileReader, redis: Redis) { + this.reader = reader; + this.redis = redis; + } + + async detectCells( + threshold: number, + bounds?: { north: number; south: number; east: number; west: number }, + ): Promise { + const timestamp = await this.reader.getLatestTimestamp('composite'); + if (!timestamp) return this.emptyCollection(); + + const boundsKey = bounds ? `${bounds.north},${bounds.south},${bounds.east},${bounds.west}` : 'global'; + const cacheKey = `cells:${timestamp}:${threshold}:${boundsKey}`; + const cached = await this.redis.get(cacheKey); + if (cached) return JSON.parse(cached); + + const tiles = this.getTiles(bounds); + if (tiles.length === 0) return this.emptyCollection(); + + const stitchResult = await this.stitchTiles(tiles, timestamp, threshold); + if (!stitchResult || stitchResult.buffer.every(v => v === 0)) { + return this.emptyCollection(); + } + + const { buffer, width, height } = stitchResult; + const result = await this.polygonize(buffer, width, height, timestamp); + + if (result.features.length > 0) { + await this.redis.set(cacheKey, JSON.stringify(result), 'EX', 300); + } + + return result; + } + + private getTiles(bounds?: { north: number; south: number; east: number; west: number }) { + if (bounds) { + const toMercY = (lat: number) => { + const latRad = lat * Math.PI / 180; + return Math.log(Math.tan(Math.PI / 4 + latRad / 2)) * HALF / Math.PI; + }; + return getTilesForBounds( + CELL_ZOOM, + bounds.west * HALF / 180, + toMercY(bounds.north), + bounds.east * HALF / 180, + toMercY(bounds.south), + ); + } + const tiles = []; + for (let x = 0; x < TILES_PER_AXIS; x++) { + for (let y = 0; y < TILES_PER_AXIS; y++) { + tiles.push({ z: CELL_ZOOM, x, y }); + } + } + return tiles; + } + + private async stitchTiles( + tiles: Array<{ z: number; x: number; y: number }>, + timestamp: string, + threshold: number, + ) { + let minTileX = Infinity, maxTileX = -Infinity; + let minTileY = Infinity, maxTileY = -Infinity; + for (const t of tiles) { + if (t.x < minTileX) minTileX = t.x; + if (t.x > maxTileX) maxTileX = t.x; + if (t.y < minTileY) minTileY = t.y; + if (t.y > maxTileY) maxTileY = t.y; + } + + const width = (maxTileX - minTileX + 1) * 256; + const height = (maxTileY - minTileY + 1) * 256; + const buffer = new Uint8Array(width * height); + const dbzBuffer = new Float32Array(width * height); + dbzBuffer.fill(NaN); + + const topLeftBounds = tileToMercatorBounds(CELL_ZOOM, minTileX, minTileY); + const botRightBounds = tileToMercatorBounds(CELL_ZOOM, maxTileX, maxTileY); + + this._originX = topLeftBounds.west; + this._originY = topLeftBounds.north; + this._extentX = botRightBounds.east; + this._extentY = botRightBounds.south; + this._width = width; + this._height = height; + + for (const tile of tiles) { + const dbzData = await this.reader.readTileDbz('composite', tile.z, tile.x, tile.y, timestamp); + if (!dbzData) continue; + + const offsetX = (tile.x - minTileX) * 256; + const offsetY = (tile.y - minTileY) * 256; + + for (let py = 0; py < 256; py++) { + for (let px = 0; px < 256; px++) { + const srcIdx = py * 256 + px; + const dstIdx = (offsetY + py) * width + (offsetX + px); + const dbz = dbzData.dbzValues[srcIdx]; + + if (!isNaN(dbz) && dbz >= threshold) { + buffer[dstIdx] = 1; + dbzBuffer[dstIdx] = dbz; + } + } + } + } + + this._dbzBuffer = dbzBuffer; + return { buffer, width, height }; + } + + private async polygonize( + buffer: Uint8Array, width: number, height: number, timestamp: string, + ): Promise { + const prefix = join(tmpdir(), `cells-${Date.now()}`); + const pngPath = `${prefix}.png`; + const tifPath = `${prefix}.tif`; + const mercGeoJson = `${prefix}-merc.geojson`; + const outGeoJson = `${prefix}.geojson`; + + try { + await sharp(Buffer.from(buffer), { raw: { width, height, channels: 1 } }) + .png().toFile(pngPath); + + await execFileAsync('gdal_translate', [ + '-of', 'GTiff', '-a_srs', 'EPSG:3857', + '-a_ullr', String(this._originX), String(this._originY), + String(this._extentX), String(this._extentY), + '-a_nodata', '0', + pngPath, tifPath, + ]); + + await execFileAsync('gdal_polygonize.py', [tifPath, '-f', 'GeoJSON', mercGeoJson]); + + await execFileAsync('ogr2ogr', [ + '-f', 'GeoJSON', '-t_srs', 'EPSG:4326', outGeoJson, mercGeoJson, + ]); + + const raw = JSON.parse(readFileSync(outGeoJson, 'utf-8')); + return this.postProcess(raw, timestamp); + } catch (err) { + logger.error({ err }, 'Cell detection failed'); + return this.emptyCollection(); + } finally { + for (const f of [pngPath, tifPath, mercGeoJson, outGeoJson]) { + try { if (existsSync(f)) unlinkSync(f); } catch {} + } + } + } + + private postProcess(geojson: FeatureCollection, timestamp: string): FeatureCollection { + const features: Feature[] = (geojson.features || []) + .filter(f => f.properties?.DN === 1) + .map(f => { + const geom = f.geometry as Polygon | MultiPolygon; + const bbox = this.featureBbox(geom); + const maxDbz = this.sampleMaxDbzInBbox(bbox); + const areaKm2 = this.polygonAreaKm2(geom); + + return { + type: 'Feature' as const, + geometry: f.geometry, + properties: { + maxDbz: Math.round(maxDbz), + severity: dbzToSeverity(maxDbz), + precipType: 'rain', + areaKm2: Math.round(areaKm2 * 10) / 10, + }, + }; + }) + .filter(f => f.properties.areaKm2 > 1); + + return { type: 'FeatureCollection' as const, features }; + } + + private featureBbox(geometry: Polygon | MultiPolygon) { + let west = Infinity, south = Infinity, east = -Infinity, north = -Infinity; + const rings = geometry.type === 'Polygon' ? geometry.coordinates : geometry.coordinates.flat(); + for (const ring of rings) { + for (const [lon, lat] of ring) { + if (lon < west) west = lon; + if (lon > east) east = lon; + if (lat < south) south = lat; + if (lat > north) north = lat; + } + } + return { west, south, east, north }; + } + + private sampleMaxDbzInBbox(bbox: { west: number; south: number; east: number; north: number }) { + const toMercX = (lon: number) => lon * HALF / 180; + const toMercY = (lat: number) => { + const latRad = lat * Math.PI / 180; + return Math.log(Math.tan(Math.PI / 4 + latRad / 2)) * HALF / Math.PI; + }; + + const rasterW = this._extentX - this._originX; + const rasterH = this._originY - this._extentY; + + const minPx = Math.max(0, Math.floor((toMercX(bbox.west) - this._originX) / rasterW * this._width)); + const maxPx = Math.min(this._width - 1, Math.ceil((toMercX(bbox.east) - this._originX) / rasterW * this._width)); + const minPy = Math.max(0, Math.floor((this._originY - toMercY(bbox.north)) / rasterH * this._height)); + const maxPy = Math.min(this._height - 1, Math.ceil((this._originY - toMercY(bbox.south)) / rasterH * this._height)); + + let maxDbz = 0; + for (let py = minPy; py <= maxPy; py++) { + for (let px = minPx; px <= maxPx; px++) { + const dbz = this._dbzBuffer[py * this._width + px]; + if (!isNaN(dbz) && dbz > maxDbz) maxDbz = dbz; + } + } + return maxDbz; + } + + private polygonAreaKm2(geometry: Polygon | MultiPolygon): number { + const ring = geometry.type === 'Polygon' ? geometry.coordinates[0] : geometry.coordinates[0]?.[0]; + if (!ring || ring.length < 3) return 0; + const R = 6371; + let area = 0; + for (let i = 0; i < ring.length - 1; i++) { + const [lon1, lat1] = ring[i]; + const [lon2, lat2] = ring[i + 1]; + const lat1r = lat1 * Math.PI / 180; + const lat2r = lat2 * Math.PI / 180; + const dlonr = (lon2 - lon1) * Math.PI / 180; + area += dlonr * (2 + Math.sin(lat1r) + Math.sin(lat2r)); + } + return Math.abs(area * R * R / 2); + } + + private emptyCollection(): FeatureCollection { + return { type: 'FeatureCollection', features: [] }; + } +} diff --git a/src/situation/sampling/ring-sampler.ts b/src/situation/sampling/ring-sampler.ts new file mode 100644 index 0000000..edec4a2 --- /dev/null +++ b/src/situation/sampling/ring-sampler.ts @@ -0,0 +1,124 @@ +import type { RingData, NearestCell } from '../types.js'; +import { dbzToSeverity } from '../analysis/severity.js'; +import { TileReader } from './tile-reader.js'; +import { + latLonToMercator, + mercatorToLatLon, + haversineNm, + bearing, + getTilesForBounds, + tileToMercatorBounds, + EARTH_CIRCUMFERENCE, +} from '../../utils/geo.js'; +import { RING_RADII_NM, ACTIVE_CELL_MIN_DBZ } from '../config/thresholds.js'; + +const NM_TO_KM = 1.852; + +export interface RingSampleResult { + rings: { + '5nm': RingData; + '20nm': RingData; + '50nm': RingData; + }; + nearestActiveCell: NearestCell | null; +} + +export class RingSampler { + private readonly reader: TileReader; + + constructor(reader: TileReader) { + this.reader = reader; + } + + async sampleRings( + lat: number, + lon: number, + source: string, + zoom: number, + timestamp?: string, + ): Promise { + const outerRadiusNm = RING_RADII_NM[RING_RADII_NM.length - 1]; // 50 + const outerRadiusKm = outerRadiusNm * NM_TO_KM; + + // Approximate bounding box in degrees for outer ring + const dLat = outerRadiusKm / 111.32; + const dLon = outerRadiusKm / (111.32 * Math.cos(lat * Math.PI / 180)); + + // Convert to Mercator bounds for getTilesForBounds + const sw = latLonToMercator(lat - dLat, lon - dLon); + const ne = latLonToMercator(lat + dLat, lon + dLon); + + const tiles = getTilesForBounds(zoom, sw.x, ne.y, ne.x, sw.y); + + // Initialize accumulators for each ring + const ringMaxDbz = [0, 0, 0]; // 5nm, 20nm, 50nm + const ringPrecipTypes: Set[] = [new Set(), new Set(), new Set()]; + + let nearestCell: NearestCell | null = null; + let nearestCellDist = Infinity; + + for (const tile of tiles) { + const dbzData = await this.reader.readTileDbz(source, tile.z, tile.x, tile.y, timestamp); + if (!dbzData) continue; + + const typeData = await this.reader.readTileType(source, tile.z, tile.x, tile.y, timestamp); + + const mercBounds = tileToMercatorBounds(tile.z, tile.x, tile.y); + const pixelW = (mercBounds.east - mercBounds.west) / dbzData.width; + const pixelH = (mercBounds.north - mercBounds.south) / dbzData.height; + + for (let py = 0; py < dbzData.height; py++) { + for (let px = 0; px < dbzData.width; px++) { + const idx = py * dbzData.width + px; + const dbz = dbzData.dbzValues[idx]; + if (isNaN(dbz)) continue; + + const mercX = mercBounds.west + (px + 0.5) * pixelW; + const mercY = mercBounds.north - (py + 0.5) * pixelH; + const pixelPos = mercatorToLatLon(mercX, mercY); + + const distNm = haversineNm(lat, lon, pixelPos.lat, pixelPos.lon); + + for (let r = 0; r < RING_RADII_NM.length; r++) { + if (distNm <= RING_RADII_NM[r]) { + if (dbz > ringMaxDbz[r]) ringMaxDbz[r] = dbz; + + if (typeData) { + const typeCode = typeData.typeValues[idx]; + const label = this.reader.precipTypeLabel(typeCode); + if (label) ringPrecipTypes[r].add(label); + } + } + } + + if (dbz >= ACTIVE_CELL_MIN_DBZ && distNm <= outerRadiusNm) { + if (distNm < nearestCellDist) { + nearestCellDist = distNm; + nearestCell = { + distanceNm: Math.round(distNm * 10) / 10, + bearing: Math.round(bearing(lat, lon, pixelPos.lat, pixelPos.lon)), + dbz: Math.round(dbz), + }; + } + } + } + } + } + + const rings = { + '5nm': this.buildRingData(ringMaxDbz[0], ringPrecipTypes[0]), + '20nm': this.buildRingData(ringMaxDbz[1], ringPrecipTypes[1]), + '50nm': this.buildRingData(ringMaxDbz[2], ringPrecipTypes[2]), + }; + + return { rings, nearestActiveCell: nearestCell }; + } + + private buildRingData(maxDbz: number, precipTypes: Set): RingData { + return { + maxDbz: Math.round(maxDbz), + precipTypes: [...precipTypes].sort(), + severity: dbzToSeverity(maxDbz), + }; + } +} diff --git a/src/situation/sampling/route-sampler.ts b/src/situation/sampling/route-sampler.ts new file mode 100644 index 0000000..c0f28b0 --- /dev/null +++ b/src/situation/sampling/route-sampler.ts @@ -0,0 +1,159 @@ +import type { Airport, RouteSegment, SamplePoint } from '../types.js'; +import { dbzToSeverity, dbzToRecommendation } from '../analysis/severity.js'; +import { TileReader, type TileDbzData } from './tile-reader.js'; +import { + haversineNm, + latLonToMercator, + tileToMercatorBounds, + getTilesForBounds, +} from '../../utils/geo.js'; +import { SIGNIFICANT_CELL_MIN_DBZ } from '../config/thresholds.js'; + +const SAMPLE_INTERVAL_NM = 50; +const NEIGHBORHOOD_RADIUS = 1; + +export class RouteSampler { + private readonly reader: TileReader; + + constructor(reader: TileReader) { + this.reader = reader; + } + + async sampleRoute( + waypoints: Airport[], + source: string, + zoom: number, + timestamp?: string, + ): Promise { + const segments: RouteSegment[] = []; + for (let i = 0; i < waypoints.length - 1; i++) { + const segment = await this.sampleSegment(waypoints[i], waypoints[i + 1], source, zoom, timestamp); + segments.push(segment); + } + return segments; + } + + private async sampleSegment( + from: Airport, to: Airport, source: string, zoom: number, timestamp?: string, + ): Promise { + const totalDistNm = haversineNm(from.lat, from.lon, to.lat, to.lon); + const numSamples = Math.max(2, Math.ceil(totalDistNm / SAMPLE_INTERVAL_NM) + 1); + + const samplePoints: SamplePoint[] = []; + let maxDbzAlongRoute = 0; + let consecutiveAboveThreshold = 0; + let significantCells = 0; + + for (let s = 0; s < numSamples; s++) { + const t = s / (numSamples - 1); + const lat = from.lat + t * (to.lat - from.lat); + const lon = from.lon + t * (to.lon - from.lon); + const distNm = Math.round(t * totalDistNm); + + const maxDbz = await this.samplePointNeighborhood(lat, lon, source, zoom, timestamp); + if (maxDbz > maxDbzAlongRoute) maxDbzAlongRoute = maxDbz; + + samplePoints.push({ + lat: Math.round(lat * 100) / 100, + lon: Math.round(lon * 100) / 100, + distanceNm: distNm, + maxDbz: Math.round(maxDbz), + severity: dbzToSeverity(maxDbz), + }); + + if (maxDbz >= SIGNIFICANT_CELL_MIN_DBZ) { + consecutiveAboveThreshold++; + } else { + if (consecutiveAboveThreshold > 0) significantCells++; + consecutiveAboveThreshold = 0; + } + } + if (consecutiveAboveThreshold > 0) significantCells++; + + return { + from: from.icao, + to: to.icao, + distanceNm: Math.round(totalDistNm), + maxDbzAlongRoute: Math.round(maxDbzAlongRoute), + significantCells, + severity: dbzToSeverity(maxDbzAlongRoute), + recommendation: dbzToRecommendation(maxDbzAlongRoute), + samplePoints, + }; + } + + private async samplePointNeighborhood( + lat: number, lon: number, source: string, zoom: number, timestamp?: string, + ): Promise { + const merc = latLonToMercator(lat, lon); + const centerTiles = getTilesForBounds(zoom, merc.x, merc.y, merc.x, merc.y); + if (centerTiles.length === 0) return 0; + + const centerTile = centerTiles[0]; + const centerData = await this.reader.readTileDbz(source, centerTile.z, centerTile.x, centerTile.y, timestamp); + if (!centerData) return 0; + + const centerBounds = tileToMercatorBounds(centerTile.z, centerTile.x, centerTile.y); + const pixelW = (centerBounds.east - centerBounds.west) / centerData.width; + const pixelH = (centerBounds.north - centerBounds.south) / centerData.height; + + const px = Math.floor((merc.x - centerBounds.west) / pixelW); + const py = Math.floor((centerBounds.north - merc.y) / pixelH); + + // Fast path: all neighbors fit within the center tile + if (px - NEIGHBORHOOD_RADIUS >= 0 && px + NEIGHBORHOOD_RADIUS < centerData.width && + py - NEIGHBORHOOD_RADIUS >= 0 && py + NEIGHBORHOOD_RADIUS < centerData.height) { + let maxDbz = 0; + for (let dy = -NEIGHBORHOOD_RADIUS; dy <= NEIGHBORHOOD_RADIUS; dy++) { + for (let dx = -NEIGHBORHOOD_RADIUS; dx <= NEIGHBORHOOD_RADIUS; dx++) { + const dbz = centerData.dbzValues[(py + dy) * centerData.width + (px + dx)]; + if (!isNaN(dbz) && dbz > maxDbz) maxDbz = dbz; + } + } + return maxDbz; + } + + // Slow path: neighborhood crosses tile boundary — load adjacent tiles + const expandX = NEIGHBORHOOD_RADIUS * pixelW; + const expandY = NEIGHBORHOOD_RADIUS * pixelH; + const allTiles = getTilesForBounds( + zoom, merc.x - expandX, merc.y + expandY, merc.x + expandX, merc.y - expandY, + ); + + type TileEntry = { data: TileDbzData; bounds: ReturnType }; + const tileMap = new Map(); + const centerKey = `${centerTile.z}/${centerTile.x}/${centerTile.y}`; + tileMap.set(centerKey, { data: centerData, bounds: centerBounds }); + + for (const t of allTiles) { + const key = `${t.z}/${t.x}/${t.y}`; + if (tileMap.has(key)) continue; + const data = await this.reader.readTileDbz(source, t.z, t.x, t.y, timestamp); + if (data) { + tileMap.set(key, { data, bounds: tileToMercatorBounds(t.z, t.x, t.y) }); + } + } + + let maxDbz = 0; + for (let dy = -NEIGHBORHOOD_RADIUS; dy <= NEIGHBORHOOD_RADIUS; dy++) { + for (let dx = -NEIGHBORHOOD_RADIUS; dx <= NEIGHBORHOOD_RADIUS; dx++) { + const sampleX = merc.x + dx * pixelW; + const sampleY = merc.y - dy * pixelH; + + for (const [, entry] of tileMap) { + if (sampleX >= entry.bounds.west && sampleX < entry.bounds.east && + sampleY <= entry.bounds.north && sampleY > entry.bounds.south) { + const tpx = Math.floor((sampleX - entry.bounds.west) / pixelW); + const tpy = Math.floor((entry.bounds.north - sampleY) / pixelH); + if (tpx >= 0 && tpx < entry.data.width && tpy >= 0 && tpy < entry.data.height) { + const dbz = entry.data.dbzValues[tpy * entry.data.width + tpx]; + if (!isNaN(dbz) && dbz > maxDbz) maxDbz = dbz; + } + break; + } + } + } + } + return maxDbz; + } +} diff --git a/src/situation/sampling/tile-reader.ts b/src/situation/sampling/tile-reader.ts new file mode 100644 index 0000000..3d5ba40 --- /dev/null +++ b/src/situation/sampling/tile-reader.ts @@ -0,0 +1,80 @@ +import type { Redis } from 'ioredis'; +import sharp from 'sharp'; +import { getTileStore } from '../../storage/index.js'; +import { pixelToDbz } from '../../utils/geo.js'; +import { PRECIP_TYPE_MAP } from '../config/thresholds.js'; + +export interface TileDbzData { + dbzValues: Float32Array; + width: number; + height: number; +} + +export interface TileTypeData { + typeValues: Uint8Array; + width: number; + height: number; +} + +export class TileReader { + private readonly redis: Redis; + + constructor(redis: Redis) { + this.redis = redis; + } + + async getLatestTimestamp(source: string): Promise { + return this.redis.get(`latest:${source}`); + } + + async getPreviousTimestamp(source: string): Promise { + const frames = await this.redis.zrevrangebyscore( + `frames:${source}`, '+inf', '-inf', 'LIMIT', 0, 2, + ); + return frames.length >= 2 ? frames[1] : null; + } + + async getTimestampEpochMs(source: string, timestamp: string): Promise { + const meta = await this.redis.hgetall(`frame:${source}:${timestamp}`); + return meta.epochMs ? parseInt(meta.epochMs, 10) : 0; + } + + async readTileDbz( + source: string, z: number, x: number, y: number, timestamp?: string, + ): Promise { + const ts = timestamp ?? await this.getLatestTimestamp(source); + if (!ts) return null; + + const tileStore = getTileStore(); + const png = await tileStore.readTile(source, ts, z, x, y); + if (!png) return null; + + const { data, info } = await sharp(png).grayscale().raw().toBuffer({ resolveWithObject: true }); + const dbzValues = new Float32Array(info.width * info.height); + + for (let i = 0; i < data.length; i++) { + const pixel = data[i]; + dbzValues[i] = pixel === 0 ? NaN : pixelToDbz(pixel); + } + + return { dbzValues, width: info.width, height: info.height }; + } + + async readTileType( + source: string, z: number, x: number, y: number, timestamp?: string, + ): Promise { + const ts = timestamp ?? await this.getLatestTimestamp(source); + if (!ts) return null; + + const tileStore = getTileStore(); + const png = await tileStore.readTile(`${source}-type`, ts, z, x, y); + if (!png) return null; + + const { data, info } = await sharp(png).grayscale().raw().toBuffer({ resolveWithObject: true }); + return { typeValues: new Uint8Array(data), width: info.width, height: info.height }; + } + + precipTypeLabel(code: number): string | null { + return PRECIP_TYPE_MAP[code] ?? null; + } +} diff --git a/src/situation/server.ts b/src/situation/server.ts new file mode 100644 index 0000000..8f5c5e4 --- /dev/null +++ b/src/situation/server.ts @@ -0,0 +1,28 @@ +import express from 'express'; +import type { Redis } from 'ioredis'; +import { createAirportRouter } from './routes/airport.js'; +import { createSummaryRouter } from './routes/summary.js'; +import { createRouteRouter } from './routes/route.js'; +import { createHistoryRouter } from './routes/history.js'; +import { createCellsRouter } from './routes/cells.js'; +import { WatchlistUpdater } from './workers/watchlist-updater.js'; + +export function createSituationApp( + redis: Redis, +): { app: ReturnType; updater: WatchlistUpdater } { + const app = express(); + const updater = new WatchlistUpdater(redis); + + app.use((_req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + next(); + }); + + app.use(createAirportRouter(redis, updater)); + app.use(createSummaryRouter(redis)); + app.use(createRouteRouter(redis)); + app.use(createHistoryRouter(redis)); + app.use(createCellsRouter(redis)); + + return { app, updater }; +} diff --git a/src/situation/types.ts b/src/situation/types.ts new file mode 100644 index 0000000..66ae06c --- /dev/null +++ b/src/situation/types.ts @@ -0,0 +1,139 @@ +export type Severity = 'clear' | 'light' | 'moderate' | 'heavy' | 'extreme'; +export type RampStatus = 'clear' | 'caution' | 'suspend'; +export type Trend = 'intensifying' | 'weakening' | 'steady' | 'developing' | 'clearing' | 'unknown'; + +export interface Airport { + icao: string; + name: string; + lat: number; + lon: number; +} + +export interface RingData { + maxDbz: number; + precipTypes: string[]; + severity: Severity; +} + +export interface NearestCell { + distanceNm: number; + bearing: number; + dbz: number; +} + +export interface AirportSituation { + icao: string; + timestamp: string; + dataAge: number; + rings: { + '5nm': RingData; + '20nm': RingData; + '50nm': RingData; + }; + trend: Trend; + rampStatus: RampStatus; + nearestActiveCell: NearestCell | null; +} + +export interface HistoryFrame { + timestamp: string; + rings: { + '5nm': RingData; + '20nm': RingData; + '50nm': RingData; + }; + rampStatus: RampStatus; +} + +export interface RegionConfig { + id: string; + label: string; + bounds: { north: number; south: number; east: number; west: number }; + airports: string[]; +} + +export interface RegionSummary { + id: string; + label: string; + bounds: { north: number; south: number; east: number; west: number }; + maxDbz: number; + coveragePct: number; + precipTypes: string[]; + severity: Severity; + trend: Trend; + affectedAirports: string[]; +} + +export type SystemStatus = 'operational' | 'degraded' | 'offline'; + +export interface SituationSummary { + generated: string; + dataAge: number; + regions: RegionSummary[]; + systemStatus: SystemStatus; +} + +export interface SamplePoint { + lat: number; + lon: number; + distanceNm: number; + maxDbz: number; + severity: Severity; +} + +export interface RouteSegment { + from: string; + to: string; + distanceNm: number; + maxDbzAlongRoute: number; + significantCells: number; + severity: Severity; + recommendation: string; + samplePoints: SamplePoint[]; +} + +export interface RouteResult { + waypoints: string[]; + timestamp: string; + segments: RouteSegment[]; +} + +export interface CellProperties { + maxDbz: number; + severity: Severity; + precipType: string; + areaKm2: number; +} + +export interface ConditionChange { + type: 'condition-change'; + icao: string; + timestamp: string; + previous: { severity: Severity; rampStatus: RampStatus }; + current: { severity: Severity; rampStatus: RampStatus }; + trend: Trend; +} + +export interface AllClear { + type: 'all-clear'; + icao: string; + timestamp: string; + rampStatus: 'clear'; +} + +export interface DataStale { + type: 'data-stale'; + ageSeconds: number; + affectedSources: string[]; +} + +export type AviationMessage = ConditionChange | AllClear | DataStale; + +export interface Subscription { + clientId: string; + watchlist: string[]; + thresholds: { + dbz: number; + precipTypes: string[]; + }; +} diff --git a/src/situation/workers/watchlist-updater.ts b/src/situation/workers/watchlist-updater.ts new file mode 100644 index 0000000..c1a1cf6 --- /dev/null +++ b/src/situation/workers/watchlist-updater.ts @@ -0,0 +1,173 @@ +import type { Redis } from 'ioredis'; +import type { + AirportSituation, HistoryFrame, AviationMessage, + ConditionChange, AllClear, +} from '../types.js'; +import { TileReader } from '../sampling/tile-reader.js'; +import { RingSampler } from '../sampling/ring-sampler.js'; +import { HistoryManager } from '../analysis/history.js'; +import { SummaryAnalyzer } from '../analysis/summary.js'; +import { computeRampStatus, computeTrend, computeSystemStatus } from '../analysis/severity.js'; +import { getAirport } from '../config/airports.js'; +import { REGIONS } from '../config/regions.js'; +import { config } from '../../config/env.js'; +import { createLogger } from '../../utils/logger.js'; + +const logger = createLogger('watchlist-updater'); +const WATCHLIST_KEY = 'situation:watchlist'; + +export class WatchlistUpdater { + private readonly redis: Redis; + private readonly reader: TileReader; + private readonly sampler: RingSampler; + private readonly history: HistoryManager; + private readonly summary: SummaryAnalyzer; + + constructor(redis: Redis) { + this.redis = redis; + this.reader = new TileReader(redis); + this.sampler = new RingSampler(this.reader); + this.history = new HistoryManager(redis); + this.summary = new SummaryAnalyzer(this.reader, redis); + } + + async addToWatchlist(icaos: string[]): Promise { + if (icaos.length === 0) return; + await this.redis.sadd(WATCHLIST_KEY, ...icaos); + } + + async removeFromWatchlist(icaos: string[]): Promise { + if (icaos.length === 0) return; + await this.redis.srem(WATCHLIST_KEY, ...icaos); + } + + async getWatchlist(): Promise { + return this.redis.smembers(WATCHLIST_KEY); + } + + async processNewFrame(): Promise { + const watchlist = await this.getWatchlist(); + if (watchlist.length === 0) return []; + + const timestamp = await this.reader.getLatestTimestamp('composite'); + if (!timestamp) return []; + + const previousTimestamp = await this.reader.getPreviousTimestamp('composite'); + const epochMs = await this.reader.getTimestampEpochMs('composite', timestamp); + const dataAge = Math.round((Date.now() - epochMs) / 1000); + const isoTimestamp = new Date(epochMs).toISOString(); + + const messages: AviationMessage[] = []; + + for (const icao of watchlist) { + const airport = getAirport(icao); + if (!airport) continue; + + try { + const ringResult = await this.sampler.sampleRings( + airport.lat, airport.lon, 'composite', config.samplingZoom, timestamp, + ); + + const rampStatus = computeRampStatus(ringResult.rings['5nm'], ringResult.rings['20nm']); + + let previousMaxDbz: number | null = null; + const prevRaw = await this.redis.get(`situation:previous:${icao}`); + if (prevRaw) { + const prev = JSON.parse(prevRaw); + previousMaxDbz = prev.maxDbz50nm ?? null; + } + + const trend = computeTrend(ringResult.rings['50nm'].maxDbz, previousMaxDbz); + + const situation: AirportSituation = { + icao, + timestamp: isoTimestamp, + dataAge, + rings: ringResult.rings, + trend, + rampStatus, + nearestActiveCell: ringResult.nearestActiveCell, + }; + + // Check for condition change + const prevSitRaw = await this.redis.get(`situation:airport:${icao}`); + if (prevSitRaw) { + const prev: AirportSituation = JSON.parse(prevSitRaw); + if (prev.rampStatus !== rampStatus || + prev.rings['5nm'].severity !== ringResult.rings['5nm'].severity) { + if (rampStatus === 'clear' && prev.rampStatus !== 'clear') { + messages.push({ + type: 'all-clear', + icao, + timestamp: isoTimestamp, + rampStatus: 'clear', + } satisfies AllClear); + } else { + messages.push({ + type: 'condition-change', + icao, + timestamp: isoTimestamp, + previous: { severity: prev.rings['5nm'].severity, rampStatus: prev.rampStatus }, + current: { severity: ringResult.rings['5nm'].severity, rampStatus }, + trend, + } satisfies ConditionChange); + } + } + } + + await this.redis.set(`situation:airport:${icao}`, JSON.stringify(situation)); + await this.redis.set(`situation:previous:${icao}`, JSON.stringify({ + maxDbz50nm: ringResult.rings['50nm'].maxDbz, + })); + + const historyFrame: HistoryFrame = { + timestamp: isoTimestamp, + rings: ringResult.rings, + rampStatus, + }; + await this.history.addFrame(icao, epochMs, historyFrame); + await this.history.prune(icao, 24); + } catch (err) { + logger.error({ err, icao }, 'Failed to process airport'); + } + } + + // Compute region summaries + try { + const regionSummaries = await Promise.all( + REGIONS.map(r => this.summary.analyzeRegion(r, 'composite', timestamp, previousTimestamp ?? undefined)), + ); + + for (const rs of regionSummaries) { + const region = REGIONS.find(r => r.id === rs.id)!; + const affected: string[] = []; + for (const icao of region.airports) { + const raw = await this.redis.get(`situation:airport:${icao}`); + if (raw) { + const sit: AirportSituation = JSON.parse(raw); + if (sit.rampStatus !== 'clear') affected.push(icao); + } + } + rs.affectedAirports = affected; + } + + const summaryPayload = { + generated: isoTimestamp, + dataAge, + regions: regionSummaries, + systemStatus: computeSystemStatus(dataAge), + }; + + await this.redis.set('situation:summary', JSON.stringify(summaryPayload)); + } catch (err) { + logger.error({ err }, 'Failed to compute summaries'); + } + + return messages; + } + + async getCachedSituation(icao: string): Promise { + const raw = await this.redis.get(`situation:airport:${icao}`); + return raw ? JSON.parse(raw) : null; + } +} diff --git a/src/situation/ws/aviation.ts b/src/situation/ws/aviation.ts new file mode 100644 index 0000000..316aed5 --- /dev/null +++ b/src/situation/ws/aviation.ts @@ -0,0 +1,144 @@ +import type { WebSocketServer, WebSocket } from 'ws'; +import type { Subscription, AviationMessage } from '../types.js'; +import { getAirport } from '../config/airports.js'; +import { createLogger } from '../../utils/logger.js'; + +const logger = createLogger('aviation-ws'); + +const PING_INTERVAL_MS = 30_000; + +const SEVERITY_TO_DBZ: Record = { + clear: 0, + light: 20, + moderate: 35, + heavy: 50, + extreme: 60, +}; + +interface ClientState { + ws: WebSocket; + subscription: Subscription; + alive: boolean; +} + +export class AviationWebSocketHandler { + private readonly clients: Map = new Map(); + private readonly pingTimer: ReturnType; + + constructor(wss: WebSocketServer) { + wss.on('connection', (ws) => this.handleConnection(ws)); + this.pingTimer = setInterval(() => this.pingAll(), PING_INTERVAL_MS); + } + + private handleConnection(ws: WebSocket): void { + let clientId: string | null = null; + + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + + if (msg.type === 'subscribe') { + clientId = msg.clientId; + const validIcaos = (msg.watchlist || []) + .map((icao: string) => icao.toUpperCase()) + .filter((icao: string) => getAirport(icao)); + + const subscription: Subscription = { + clientId: msg.clientId, + watchlist: validIcaos, + thresholds: { + dbz: msg.thresholds?.dbz ?? 0, + precipTypes: msg.thresholds?.precipTypes ?? [], + }, + }; + + this.clients.set(clientId!, { ws, subscription, alive: true }); + logger.info({ clientId, watchlist: validIcaos }, 'Client subscribed'); + } + } catch (err) { + logger.warn({ err }, 'Invalid WebSocket message'); + } + }); + + ws.on('pong', () => { + if (clientId && this.clients.has(clientId)) { + this.clients.get(clientId)!.alive = true; + } + }); + + ws.on('close', () => { + if (clientId) { + this.clients.delete(clientId); + logger.info({ clientId }, 'Client disconnected'); + } + }); + } + + broadcastMessages(messages: AviationMessage[]): void { + for (const msg of messages) { + for (const [, client] of this.clients) { + if (client.ws.readyState !== 1) continue; + + if (msg.type === 'condition-change') { + if (!client.subscription.watchlist.includes(msg.icao)) continue; + const currentDbz = SEVERITY_TO_DBZ[msg.current.severity] ?? 0; + if (currentDbz < client.subscription.thresholds.dbz) continue; + } + + if (msg.type === 'all-clear') { + if (!client.subscription.watchlist.includes(msg.icao)) continue; + } + + client.ws.send(JSON.stringify(msg)); + } + } + } + + broadcastDataStale(ageSeconds: number, affectedSources: string[]): void { + const msg: AviationMessage = { type: 'data-stale', ageSeconds, affectedSources }; + for (const [, client] of this.clients) { + if (client.ws.readyState === 1) { + client.ws.send(JSON.stringify(msg)); + } + } + } + + getSubscriptions(): Map { + const subs = new Map(); + for (const [id, state] of this.clients) { + subs.set(id, state.subscription); + } + return subs; + } + + getWatchedAirports(): string[] { + const airports = new Set(); + for (const [, state] of this.clients) { + for (const icao of state.subscription.watchlist) { + airports.add(icao); + } + } + return [...airports]; + } + + private pingAll(): void { + for (const [clientId, client] of this.clients) { + if (!client.alive) { + logger.info({ clientId }, 'Client timed out, disconnecting'); + client.ws.terminate(); + this.clients.delete(clientId); + continue; + } + client.alive = false; + client.ws.ping(); + } + } + + close(): void { + clearInterval(this.pingTimer); + for (const [, client] of this.clients) { + client.ws.close(); + } + this.clients.clear(); + } +} diff --git a/src/utils/geo.ts b/src/utils/geo.ts index 871c31a..d27271d 100644 --- a/src/utils/geo.ts +++ b/src/utils/geo.ts @@ -78,6 +78,38 @@ export function isEmptyTile(pixels: Uint8Array): boolean { return true; } +export function latLonToMercator(lat: number, lon: number): { x: number; y: number } { + const x = lon * (EARTH_CIRCUMFERENCE / 360); + const latRad = lat * Math.PI / 180; + const y = Math.log(Math.tan(Math.PI / 4 + latRad / 2)) * (EARTH_CIRCUMFERENCE / (2 * Math.PI)); + return { x, y }; +} + +export function mercatorToLatLon(x: number, y: number): { lat: number; lon: number } { + const lon = x / (EARTH_CIRCUMFERENCE / 360); + const lat = (2 * Math.atan(Math.exp(y * 2 * Math.PI / EARTH_CIRCUMFERENCE)) - Math.PI / 2) * 180 / Math.PI; + return { lat, lon }; +} + +export function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 3440.065; // Earth radius in nautical miles + const φ1 = lat1 * Math.PI / 180; + const φ2 = lat2 * Math.PI / 180; + const Δφ = (lat2 - lat1) * Math.PI / 180; + const Δλ = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +export function bearing(lat1: number, lon1: number, lat2: number, lon2: number): number { + const φ1 = lat1 * Math.PI / 180; + const φ2 = lat2 * Math.PI / 180; + const Δλ = (lon2 - lon1) * Math.PI / 180; + const y = Math.sin(Δλ) * Math.cos(φ2); + const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ); + return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; +} + export function parseTimestamp(key: string): { timestamp: string; epochMs: number } { const match = key.match(/(\d{8})-(\d{6})/); if (!match) throw new Error(`Cannot parse timestamp from: ${key}`); diff --git a/tests/unit/situation/airports.test.ts b/tests/unit/situation/airports.test.ts new file mode 100644 index 0000000..ac20831 --- /dev/null +++ b/tests/unit/situation/airports.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const OVERRIDE_PATH = '/data/airports-override.json'; +const OVERRIDE_JSON = JSON.stringify({ + XPVT: { name: 'Private Field', lat: 40.0, lon: -80.0 }, +}); + +const BUNDLED_JSON = JSON.stringify({ + KORD: { name: "Chicago O'Hare Intl", lat: 41.9742, lon: -87.9073 }, + KJFK: { name: 'John F Kennedy Intl', lat: 40.6399, lon: -73.7787 }, + EGLL: { name: 'Heathrow', lat: 51.4706, lon: -0.4619 }, +}); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn((path: unknown, ...args: unknown[]) => { + if (path === OVERRIDE_PATH) { + return OVERRIDE_JSON; + } + // Return mock bundled data for the airports.json file + if (typeof path === 'string' && path.endsWith('airports.json')) { + return BUNDLED_JSON; + } + return (actual.readFileSync as (...a: unknown[]) => unknown)(path, ...args); + }), + }; +}); + +import { loadAirports, getAirport, getAllAirports } from '../../../src/situation/config/airports.js'; +import { existsSync } from 'node:fs'; + +describe('Airport Loader', () => { + beforeEach(() => { + vi.mocked(existsSync).mockReturnValue(false); + }); + + it('loads bundled airports', () => { + loadAirports(); + const kord = getAirport('KORD'); + expect(kord).toBeDefined(); + expect(kord!.icao).toBe('KORD'); + expect(kord!.lat).toBeCloseTo(41.97, 0); + expect(kord!.lon).toBeCloseTo(-87.90, 0); + }); + + it('returns undefined for unknown ICAO', () => { + loadAirports(); + expect(getAirport('ZZZZ')).toBeUndefined(); + }); + + it('returns all airports', () => { + loadAirports(); + const all = getAllAirports(); + expect(all.length).toBe(3); + }); + + it('merges override file over bundled data', () => { + vi.mocked(existsSync).mockReturnValue(true); + + loadAirports(OVERRIDE_PATH); + const pvt = getAirport('XPVT'); + expect(pvt).toBeDefined(); + expect(pvt!.name).toBe('Private Field'); + }); +}); diff --git a/tests/unit/situation/aviation-ws.test.ts b/tests/unit/situation/aviation-ws.test.ts new file mode 100644 index 0000000..6e10c0d --- /dev/null +++ b/tests/unit/situation/aviation-ws.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { WebSocketServer, WebSocket } from 'ws'; +import { createServer } from 'node:http'; + +vi.mock('../../../src/situation/config/airports.js', () => { + const airports = new Map([ + ['KORD', { icao: 'KORD', name: "O'Hare", lat: 41.97, lon: -87.90 }], + ]); + return { + loadAirports: vi.fn(), + getAirport: (icao: string) => airports.get(icao), + getAllAirports: () => [...airports.values()], + }; +}); + +import { AviationWebSocketHandler } from '../../../src/situation/ws/aviation.js'; + +describe('AviationWebSocketHandler', () => { + let httpServer: ReturnType; + let wss: WebSocketServer; + let handler: AviationWebSocketHandler; + let port: number; + + beforeEach(async () => { + httpServer = createServer(); + wss = new WebSocketServer({ server: httpServer, path: '/ws/aviation' }); + handler = new AviationWebSocketHandler(wss); + + await new Promise(resolve => { + httpServer.listen(0, () => { + port = (httpServer.address() as any).port; + resolve(); + }); + }); + }); + + afterEach(() => { + handler.close(); + wss.close(); + httpServer.close(); + }); + + function connect(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/aviation`); + ws.on('open', () => resolve(ws)); + ws.on('error', reject); + }); + } + + it('accepts a subscribe message and registers client', async () => { + const ws = await connect(); + ws.send(JSON.stringify({ + type: 'subscribe', + clientId: 'test-1', + watchlist: ['KORD'], + thresholds: { dbz: 40, precipTypes: ['hail'] }, + })); + await new Promise(r => setTimeout(r, 100)); + expect(handler.getSubscriptions().size).toBe(1); + ws.close(); + }); + + it('broadcasts condition-change to subscribed clients', async () => { + const ws = await connect(); + ws.send(JSON.stringify({ + type: 'subscribe', + clientId: 'test-1', + watchlist: ['KORD'], + thresholds: { dbz: 30, precipTypes: [] }, + })); + await new Promise(r => setTimeout(r, 100)); + + const received: any[] = []; + ws.on('message', (data) => received.push(JSON.parse(data.toString()))); + + handler.broadcastMessages([{ + type: 'condition-change', + icao: 'KORD', + timestamp: '2026-04-08T14:35:00Z', + previous: { severity: 'light', rampStatus: 'clear' }, + current: { severity: 'moderate', rampStatus: 'caution' }, + trend: 'intensifying', + }]); + + await new Promise(r => setTimeout(r, 100)); + expect(received.length).toBe(1); + expect(received[0].type).toBe('condition-change'); + ws.close(); + }); + + it('filters messages by client threshold', async () => { + const ws = await connect(); + ws.send(JSON.stringify({ + type: 'subscribe', + clientId: 'test-1', + watchlist: ['KORD'], + thresholds: { dbz: 60, precipTypes: [] }, + })); + await new Promise(r => setTimeout(r, 100)); + + const received: any[] = []; + ws.on('message', (data) => received.push(JSON.parse(data.toString()))); + + handler.broadcastMessages([{ + type: 'condition-change', + icao: 'KORD', + timestamp: '2026-04-08T14:35:00Z', + previous: { severity: 'light', rampStatus: 'clear' }, + current: { severity: 'moderate', rampStatus: 'caution' }, + trend: 'intensifying', + }]); + + await new Promise(r => setTimeout(r, 100)); + expect(received.length).toBe(0); + ws.close(); + }); + + it('returns watched airports from all subscriptions', async () => { + const ws = await connect(); + ws.send(JSON.stringify({ + type: 'subscribe', + clientId: 'test-1', + watchlist: ['KORD'], + thresholds: { dbz: 0, precipTypes: [] }, + })); + await new Promise(r => setTimeout(r, 100)); + + const watched = handler.getWatchedAirports(); + expect(watched).toContain('KORD'); + ws.close(); + }); +}); diff --git a/tests/unit/situation/cell-detector.test.ts b/tests/unit/situation/cell-detector.test.ts new file mode 100644 index 0000000..a3b9ca0 --- /dev/null +++ b/tests/unit/situation/cell-detector.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockReadTile = vi.fn(); +vi.mock('../../../src/storage/index.js', () => ({ + getTileStore: () => ({ readTile: mockReadTile }), +})); + +const mockGet = vi.fn(); +const mockSet = vi.fn(); +const mockHgetall = vi.fn(); +const mockZrevrangebyscore = vi.fn(); +vi.mock('ioredis', () => ({ + Redis: vi.fn(() => ({ + get: mockGet, set: mockSet, hgetall: mockHgetall, + zrevrangebyscore: mockZrevrangebyscore, + })), +})); + +import { CellDetector } from '../../../src/situation/sampling/cell-detector.js'; +import { TileReader } from '../../../src/situation/sampling/tile-reader.js'; +import { Redis } from 'ioredis'; + +describe('CellDetector', () => { + let detector: CellDetector; + + beforeEach(() => { + vi.clearAllMocks(); + mockGet.mockResolvedValue(null); + mockReadTile.mockResolvedValue(null); + const reader = new TileReader(new Redis() as any); + detector = new CellDetector(reader, new Redis() as any); + }); + + it('returns empty FeatureCollection when no timestamp', async () => { + const result = await detector.detectCells(35); + expect(result.type).toBe('FeatureCollection'); + expect(result.features).toHaveLength(0); + }); + + it('returns empty FeatureCollection when no tiles exist', async () => { + mockGet.mockImplementation(async (key: string) => { + if (key === 'latest:composite') return '20260408143000'; + return null; + }); + + const result = await detector.detectCells(35); + expect(result.type).toBe('FeatureCollection'); + expect(result.features).toHaveLength(0); + }); + + it('returns cached result if available', async () => { + const cached = { type: 'FeatureCollection', features: [{ mock: true }] }; + mockGet.mockImplementation(async (key: string) => { + if (key === 'latest:composite') return '20260408143000'; + if (key.startsWith('cells:')) return JSON.stringify(cached); + return null; + }); + + const result = await detector.detectCells(35); + expect(result.features).toHaveLength(1); + }); +}); diff --git a/tests/unit/situation/geo.test.ts b/tests/unit/situation/geo.test.ts new file mode 100644 index 0000000..fb1013e --- /dev/null +++ b/tests/unit/situation/geo.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { + haversineNm, + bearing, + latLonToMercator, + mercatorToLatLon, +} from '../../../src/utils/geo.js'; + +describe('haversineNm', () => { + it('returns 0 for same point', () => { + expect(haversineNm(41.97, -87.90, 41.97, -87.90)).toBe(0); + }); + + it('computes KORD to KDEN distance (~770nm)', () => { + const dist = haversineNm(41.9742, -87.9073, 39.8561, -104.6737); + expect(dist).toBeGreaterThan(700); + expect(dist).toBeLessThan(850); + }); + + it('computes short distance accurately (~5nm)', () => { + const dist = haversineNm(41.97, -87.90, 41.887, -87.90); + expect(dist).toBeGreaterThan(4); + expect(dist).toBeLessThan(6); + }); +}); + +describe('bearing', () => { + it('returns ~0 for due north', () => { + const b = bearing(41.0, -87.0, 42.0, -87.0); + // Due north is 0°; the formula may return 0 or values near 360 + expect(b < 5 || b > 355).toBe(true); + }); + + it('returns ~90 for due east', () => { + const b = bearing(41.0, -87.0, 41.0, -86.0); + expect(b).toBeGreaterThan(85); + expect(b).toBeLessThan(95); + }); + + it('returns ~180 for due south', () => { + const b = bearing(42.0, -87.0, 41.0, -87.0); + expect(b).toBeGreaterThan(175); + expect(b).toBeLessThan(185); + }); + + it('returns ~270 for due west', () => { + const b = bearing(41.0, -86.0, 41.0, -87.0); + expect(b).toBeGreaterThan(265); + expect(b).toBeLessThan(275); + }); +}); + +describe('latLonToMercator / mercatorToLatLon', () => { + it('round-trips correctly', () => { + const { x, y } = latLonToMercator(41.97, -87.90); + const { lat, lon } = mercatorToLatLon(x, y); + expect(lat).toBeCloseTo(41.97, 4); + expect(lon).toBeCloseTo(-87.90, 4); + }); + + it('converts equator/prime meridian to origin', () => { + const { x, y } = latLonToMercator(0, 0); + expect(x).toBeCloseTo(0, 1); + expect(y).toBeCloseTo(0, 1); + }); + + it('converts 180E to half circumference', () => { + const { x } = latLonToMercator(0, 180); + expect(x).toBeCloseTo(20037508.343, 0); + }); +}); diff --git a/tests/unit/situation/history.test.ts b/tests/unit/situation/history.test.ts new file mode 100644 index 0000000..de345d3 --- /dev/null +++ b/tests/unit/situation/history.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockRedis = { + zadd: vi.fn(), + zrangebyscore: vi.fn(), + zremrangebyscore: vi.fn(), +}; +vi.mock('ioredis', () => ({ Redis: vi.fn(() => mockRedis) })); + +import { HistoryManager } from '../../../src/situation/analysis/history.js'; +import { Redis } from 'ioredis'; +import type { HistoryFrame } from '../../../src/situation/types.js'; + +describe('HistoryManager', () => { + let history: HistoryManager; + + beforeEach(() => { + vi.clearAllMocks(); + history = new HistoryManager(new Redis() as any); + }); + + it('stores a history frame', async () => { + const frame: HistoryFrame = { + timestamp: '2026-04-08T14:30:00Z', + rings: { + '5nm': { maxDbz: 42, precipTypes: ['rain'], severity: 'moderate' }, + '20nm': { maxDbz: 55, precipTypes: ['rain', 'hail'], severity: 'heavy' }, + '50nm': { maxDbz: 28, precipTypes: ['rain'], severity: 'light' }, + }, + rampStatus: 'caution', + }; + await history.addFrame('KORD', 1712583000000, frame); + expect(mockRedis.zadd).toHaveBeenCalledWith( + 'situation:history:KORD', 1712583000000, JSON.stringify(frame), + ); + }); + + it('retrieves frames within a time window', async () => { + const frame = JSON.stringify({ + timestamp: '2026-04-08T14:30:00Z', + rings: { + '5nm': { maxDbz: 10, precipTypes: [], severity: 'clear' }, + '20nm': { maxDbz: 10, precipTypes: [], severity: 'clear' }, + '50nm': { maxDbz: 10, precipTypes: [], severity: 'clear' }, + }, + rampStatus: 'clear', + }); + mockRedis.zrangebyscore.mockResolvedValue([frame]); + const now = Date.now(); + const frames = await history.getFrames('KORD', 3, now); + expect(frames).toHaveLength(1); + expect(frames[0].rampStatus).toBe('clear'); + expect(mockRedis.zrangebyscore).toHaveBeenCalledWith( + 'situation:history:KORD', now - 3 * 3600_000, now, + ); + }); + + it('prunes old frames', async () => { + await history.prune('KORD', 24); + expect(mockRedis.zremrangebyscore).toHaveBeenCalledWith( + 'situation:history:KORD', '-inf', expect.any(Number), + ); + }); +}); diff --git a/tests/unit/situation/ring-sampler.test.ts b/tests/unit/situation/ring-sampler.test.ts new file mode 100644 index 0000000..828bf23 --- /dev/null +++ b/tests/unit/situation/ring-sampler.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import sharp from 'sharp'; +import { dbzToPixel } from '../../../src/utils/geo.js'; + +const mockReadTile = vi.fn(); +vi.mock('../../../src/storage/index.js', () => ({ + getTileStore: () => ({ readTile: mockReadTile }), +})); + +const mockGet = vi.fn(); +const mockZrevrangebyscore = vi.fn(); +const mockHgetall = vi.fn(); +vi.mock('ioredis', () => ({ + Redis: vi.fn(() => ({ + get: mockGet, + zrevrangebyscore: mockZrevrangebyscore, + hgetall: mockHgetall, + })), +})); + +import { RingSampler } from '../../../src/situation/sampling/ring-sampler.js'; +import { TileReader } from '../../../src/situation/sampling/tile-reader.js'; +import { Redis } from 'ioredis'; + +async function createTestTile(entries: Array<{ px: number; py: number; dbz: number }>): Promise { + const pixels = Buffer.alloc(256 * 256, 0); + for (const { px, py, dbz } of entries) { + pixels[py * 256 + px] = dbzToPixel(dbz); + } + return sharp(pixels, { raw: { width: 256, height: 256, channels: 1 } }).png().toBuffer(); +} + +describe('RingSampler', () => { + let sampler: RingSampler; + + beforeEach(() => { + vi.clearAllMocks(); + mockGet.mockResolvedValue('20260408143000'); + mockZrevrangebyscore.mockResolvedValue(['20260408143000']); + mockHgetall.mockResolvedValue({ epochMs: '1712583000000' }); + const reader = new TileReader(new Redis() as any); + sampler = new RingSampler(reader); + }); + + it('detects dBZ within range rings', async () => { + const tile = await createTestTile([{ px: 128, py: 128, dbz: 55 }]); + mockReadTile.mockResolvedValue(tile); + + const result = await sampler.sampleRings(41.9742, -87.9073, 'composite', 7); + + // At least one ring should pick up the return + expect(result.rings['5nm'].maxDbz).toBeGreaterThanOrEqual(0); + }); + + it('returns clear rings when no radar returns', async () => { + const tile = await createTestTile([]); + mockReadTile.mockResolvedValue(tile); + + const result = await sampler.sampleRings(41.9742, -87.9073, 'composite', 7); + expect(result.rings['5nm'].maxDbz).toBe(0); + expect(result.rings['5nm'].severity).toBe('clear'); + expect(result.rings['20nm'].maxDbz).toBe(0); + expect(result.rings['50nm'].maxDbz).toBe(0); + }); + + it('returns null nearestActiveCell when no strong returns', async () => { + const tile = await createTestTile([]); + mockReadTile.mockResolvedValue(tile); + + const result = await sampler.sampleRings(41.9742, -87.9073, 'composite', 7); + expect(result.nearestActiveCell).toBeNull(); + }); + + it('handles missing tiles gracefully', async () => { + mockReadTile.mockResolvedValue(null); + + const result = await sampler.sampleRings(41.9742, -87.9073, 'composite', 7); + expect(result.rings['5nm'].maxDbz).toBe(0); + expect(result.rings['5nm'].severity).toBe('clear'); + }); + + it('returns ring data with correct structure', async () => { + mockReadTile.mockResolvedValue(null); + + const result = await sampler.sampleRings(41.9742, -87.9073, 'composite', 7); + + for (const key of ['5nm', '20nm', '50nm'] as const) { + expect(result.rings[key]).toHaveProperty('maxDbz'); + expect(result.rings[key]).toHaveProperty('precipTypes'); + expect(result.rings[key]).toHaveProperty('severity'); + expect(Array.isArray(result.rings[key].precipTypes)).toBe(true); + } + }); +}); diff --git a/tests/unit/situation/route-sampler.test.ts b/tests/unit/situation/route-sampler.test.ts new file mode 100644 index 0000000..f31251e --- /dev/null +++ b/tests/unit/situation/route-sampler.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import sharp from 'sharp'; + +const mockReadTile = vi.fn(); +vi.mock('../../../src/storage/index.js', () => ({ + getTileStore: () => ({ readTile: mockReadTile }), +})); + +const mockGet = vi.fn(); +const mockZrevrangebyscore = vi.fn(); +const mockHgetall = vi.fn(); +vi.mock('ioredis', () => ({ + Redis: vi.fn(() => ({ + get: mockGet, + zrevrangebyscore: mockZrevrangebyscore, + hgetall: mockHgetall, + })), +})); + +import { RouteSampler } from '../../../src/situation/sampling/route-sampler.js'; +import { TileReader } from '../../../src/situation/sampling/tile-reader.js'; +import { Redis } from 'ioredis'; +import type { Airport } from '../../../src/situation/types.js'; + +const KORD: Airport = { icao: 'KORD', name: "O'Hare", lat: 41.9742, lon: -87.9073 }; +const KDEN: Airport = { icao: 'KDEN', name: 'Denver', lat: 39.8561, lon: -104.6737 }; +const KLAX: Airport = { icao: 'KLAX', name: 'Los Angeles', lat: 33.9425, lon: -118.4081 }; + +describe('RouteSampler', () => { + let sampler: RouteSampler; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGet.mockResolvedValue('20260408143000'); + mockHgetall.mockResolvedValue({ epochMs: '1712583000000' }); + + const emptyPng = await sharp(Buffer.alloc(256 * 256, 0), { + raw: { width: 256, height: 256, channels: 1 }, + }).png().toBuffer(); + mockReadTile.mockResolvedValue(emptyPng); + + const reader = new TileReader(new Redis() as any); + sampler = new RouteSampler(reader); + }); + + it('generates sample points along a segment', async () => { + const result = await sampler.sampleRoute([KORD, KDEN], 'composite', 7); + expect(result.length).toBe(1); + expect(result[0].from).toBe('KORD'); + expect(result[0].to).toBe('KDEN'); + expect(result[0].distanceNm).toBeGreaterThan(600); + expect(result[0].samplePoints.length).toBeGreaterThan(5); + }); + + it('first sample point is at origin airport', async () => { + const result = await sampler.sampleRoute([KORD, KDEN], 'composite', 7); + const first = result[0].samplePoints[0]; + expect(first.distanceNm).toBe(0); + expect(first.lat).toBeCloseTo(KORD.lat, 1); + expect(first.lon).toBeCloseTo(KORD.lon, 1); + }); + + it('returns clear recommendation for empty tiles', async () => { + const result = await sampler.sampleRoute([KORD, KDEN], 'composite', 7); + expect(result[0].severity).toBe('clear'); + expect(result[0].recommendation).toBe('clear'); + expect(result[0].significantCells).toBe(0); + }); + + it('handles multi-segment routes', async () => { + const result = await sampler.sampleRoute([KORD, KDEN, KLAX], 'composite', 7); + expect(result.length).toBe(2); + expect(result[0].from).toBe('KORD'); + expect(result[0].to).toBe('KDEN'); + expect(result[1].from).toBe('KDEN'); + expect(result[1].to).toBe('KLAX'); + }); +}); diff --git a/tests/unit/situation/server.test.ts b/tests/unit/situation/server.test.ts new file mode 100644 index 0000000..6030a4d --- /dev/null +++ b/tests/unit/situation/server.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; + +const mockReadTile = vi.fn(); +vi.mock('../../../src/storage/index.js', () => ({ + getTileStore: () => ({ readTile: mockReadTile }), +})); + +const mockRedis = { + get: vi.fn(), + set: vi.fn(), + smembers: vi.fn(), + sadd: vi.fn(), + srem: vi.fn(), + zadd: vi.fn(), + zrangebyscore: vi.fn(), + zrevrangebyscore: vi.fn(), + zremrangebyscore: vi.fn(), + hgetall: vi.fn(), + sismember: vi.fn(), + quit: vi.fn(), +}; +vi.mock('ioredis', () => ({ Redis: vi.fn(() => mockRedis) })); + +vi.mock('../../../src/situation/config/airports.js', () => { + const airports = new Map([ + ['KORD', { icao: 'KORD', name: "O'Hare", lat: 41.9742, lon: -87.9073 }], + ['KJFK', { icao: 'KJFK', name: 'JFK', lat: 40.6413, lon: -73.7781 }], + ]); + return { + loadAirports: vi.fn(), + getAirport: (icao: string) => airports.get(icao), + getAllAirports: () => [...airports.values()], + }; +}); + +import { createSituationApp } from '../../../src/situation/server.js'; +import { Redis } from 'ioredis'; + +describe('Situation API Server', () => { + let app: ReturnType['app']; + + beforeAll(() => { + mockRedis.get.mockResolvedValue(null); + mockRedis.hgetall.mockResolvedValue({}); + mockRedis.smembers.mockResolvedValue([]); + mockRedis.zrevrangebyscore.mockResolvedValue([]); + mockReadTile.mockResolvedValue(null); + + const result = createSituationApp(new Redis() as any); + app = result.app; + }); + + describe('GET /situation/airport/:icao', () => { + it('returns 404 for unknown ICAO', async () => { + const res = await request(app).get('/situation/airport/ZZZZ'); + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/not found/i); + }); + + it('returns cached situation for valid ICAO', async () => { + mockRedis.get.mockImplementation(async (key: string) => { + if (key === 'situation:airport:KORD') { + return JSON.stringify({ + icao: 'KORD', + timestamp: '2026-04-08T14:30:00Z', + dataAge: 87, + rings: { + '5nm': { maxDbz: 42, precipTypes: ['rain'], severity: 'moderate' }, + '20nm': { maxDbz: 55, precipTypes: ['rain', 'hail'], severity: 'heavy' }, + '50nm': { maxDbz: 28, precipTypes: ['rain'], severity: 'light' }, + }, + trend: 'intensifying', + rampStatus: 'caution', + nearestActiveCell: { distanceNm: 8.2, bearing: 247, dbz: 55 }, + }); + } + return null; + }); + + const res = await request(app).get('/situation/airport/KORD'); + expect(res.status).toBe(200); + expect(res.body.icao).toBe('KORD'); + expect(res.body.rings['5nm'].severity).toBe('moderate'); + expect(res.body.rampStatus).toBe('caution'); + }); + }); + + describe('GET /situation/summary', () => { + it('returns cached summary', async () => { + mockRedis.get.mockImplementation(async (key: string) => { + if (key === 'situation:summary') { + return JSON.stringify({ + generated: '2026-04-08T14:30:00Z', + dataAge: 87, + regions: [], + systemStatus: 'operational', + }); + } + return null; + }); + + const res = await request(app).get('/situation/summary'); + expect(res.status).toBe(200); + expect(res.body.systemStatus).toBe('operational'); + }); + + it('returns 503 when summary not computed', async () => { + mockRedis.get.mockResolvedValue(null); + const res = await request(app).get('/situation/summary'); + expect(res.status).toBe(503); + }); + }); + + describe('GET /situation/route', () => { + it('returns 400 without waypoints', async () => { + const res = await request(app).get('/situation/route'); + expect(res.status).toBe(400); + }); + + it('returns 400 with single waypoint', async () => { + const res = await request(app).get('/situation/route?waypoints=KORD'); + expect(res.status).toBe(400); + }); + + it('returns 400 for unknown airport in route', async () => { + const res = await request(app).get('/situation/route?waypoints=KORD,ZZZZ'); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/ZZZZ/); + }); + }); + + describe('GET /situation/airport/:icao/history', () => { + it('returns 404 for unknown ICAO', async () => { + const res = await request(app).get('/situation/airport/ZZZZ/history'); + expect(res.status).toBe(404); + }); + + it('returns 404 for unwatched airport', async () => { + mockRedis.sismember.mockResolvedValue(0); + const res = await request(app).get('/situation/airport/KORD/history'); + expect(res.status).toBe(404); + expect(res.body.error).toMatch(/watchlist/i); + }); + }); + + describe('GET /overlays/cells.geojson', () => { + it('returns empty FeatureCollection when no data', async () => { + mockRedis.get.mockResolvedValue(null); + const res = await request(app).get('/overlays/cells.geojson'); + expect(res.status).toBe(200); + expect(res.body.type).toBe('FeatureCollection'); + expect(res.body.features).toHaveLength(0); + }); + + it('returns 400 for invalid bounds', async () => { + const res = await request(app).get('/overlays/cells.geojson?bounds=abc'); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/tests/unit/situation/severity.test.ts b/tests/unit/situation/severity.test.ts new file mode 100644 index 0000000..bfb7c52 --- /dev/null +++ b/tests/unit/situation/severity.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { + dbzToSeverity, + computeRampStatus, + computeTrend, + dbzToRecommendation, + computeSystemStatus, +} from '../../../src/situation/analysis/severity.js'; +import type { RingData } from '../../../src/situation/types.js'; + +describe('dbzToSeverity', () => { + it('returns clear for dBZ below 20', () => { + expect(dbzToSeverity(0)).toBe('clear'); + expect(dbzToSeverity(19)).toBe('clear'); + expect(dbzToSeverity(-10)).toBe('clear'); + }); + it('returns light for dBZ 20-35', () => { + expect(dbzToSeverity(20)).toBe('light'); + expect(dbzToSeverity(34)).toBe('light'); + }); + it('returns moderate for dBZ 35-50', () => { + expect(dbzToSeverity(35)).toBe('moderate'); + expect(dbzToSeverity(49)).toBe('moderate'); + }); + it('returns heavy for dBZ 50-60', () => { + expect(dbzToSeverity(50)).toBe('heavy'); + expect(dbzToSeverity(59)).toBe('heavy'); + }); + it('returns extreme for dBZ above 60', () => { + expect(dbzToSeverity(60)).toBe('extreme'); + expect(dbzToSeverity(75)).toBe('extreme'); + }); +}); + +describe('computeRampStatus', () => { + const ring = (maxDbz: number, precipTypes: string[] = []): RingData => ({ + maxDbz, precipTypes, severity: dbzToSeverity(maxDbz), + }); + it('returns clear when 5nm ring is below 35', () => { + expect(computeRampStatus(ring(20), ring(30))).toBe('clear'); + }); + it('returns caution when 5nm ring is 35-50', () => { + expect(computeRampStatus(ring(40), ring(30))).toBe('caution'); + }); + it('returns suspend when 5nm ring is above 50', () => { + expect(computeRampStatus(ring(55), ring(30))).toBe('suspend'); + }); + it('returns caution for freezing precip within 20nm', () => { + expect(computeRampStatus(ring(10), ring(10, ['freezing_rain']))).toBe('caution'); + }); + it('returns suspend for hail within 20nm', () => { + expect(computeRampStatus(ring(10), ring(10, ['hail']))).toBe('suspend'); + }); + it('suspend from hail overrides caution from dBZ', () => { + expect(computeRampStatus(ring(40), ring(40, ['hail']))).toBe('suspend'); + }); +}); + +describe('computeTrend', () => { + it('returns intensifying when maxDbz increases by >5', () => { + expect(computeTrend(30, 20)).toBe('intensifying'); + }); + it('returns weakening when maxDbz decreases by >5', () => { + expect(computeTrend(20, 30)).toBe('weakening'); + }); + it('returns steady when change is within 5 dBZ', () => { + expect(computeTrend(25, 22)).toBe('steady'); + }); + it('returns developing when previous was clear and current is not', () => { + expect(computeTrend(30, 10)).toBe('developing'); + }); + it('returns clearing when current is clear', () => { + expect(computeTrend(10, 30)).toBe('clearing'); + }); + it('returns unknown when no previous data', () => { + expect(computeTrend(30, null)).toBe('unknown'); + }); +}); + +describe('dbzToRecommendation', () => { + it('returns correct recommendations for each band', () => { + expect(dbzToRecommendation(10)).toBe('clear'); + expect(dbzToRecommendation(25)).toBe('monitor'); + expect(dbzToRecommendation(40)).toBe('deviations possible'); + expect(dbzToRecommendation(55)).toBe('deviations likely'); + expect(dbzToRecommendation(65)).toBe('avoid segment'); + }); +}); + +describe('computeSystemStatus', () => { + it('returns operational for fresh data', () => { + expect(computeSystemStatus(100)).toBe('operational'); + }); + it('returns degraded for moderately stale data', () => { + expect(computeSystemStatus(400)).toBe('degraded'); + }); + it('returns offline for very stale data', () => { + expect(computeSystemStatus(700)).toBe('offline'); + }); +}); diff --git a/tests/unit/situation/summary.test.ts b/tests/unit/situation/summary.test.ts new file mode 100644 index 0000000..363d51a --- /dev/null +++ b/tests/unit/situation/summary.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockReadTile = vi.fn(); +vi.mock('../../../src/storage/index.js', () => ({ + getTileStore: () => ({ readTile: mockReadTile }), +})); + +const mockGet = vi.fn(); +const mockSet = vi.fn(); +const mockHgetall = vi.fn(); +const mockZrevrangebyscore = vi.fn(); +vi.mock('ioredis', () => ({ + Redis: vi.fn(() => ({ + get: mockGet, set: mockSet, hgetall: mockHgetall, + zrevrangebyscore: mockZrevrangebyscore, + })), +})); + +import { SummaryAnalyzer } from '../../../src/situation/analysis/summary.js'; +import { TileReader } from '../../../src/situation/sampling/tile-reader.js'; +import { Redis } from 'ioredis'; +import type { RegionConfig } from '../../../src/situation/types.js'; + +const testRegion: RegionConfig = { + id: 'test-region', + label: 'Test Region', + bounds: { north: 45, south: 38, east: -70, west: -79 }, + airports: ['KJFK', 'KEWR'], +}; + +describe('SummaryAnalyzer', () => { + let analyzer: SummaryAnalyzer; + + beforeEach(() => { + vi.clearAllMocks(); + mockGet.mockResolvedValue(null); + mockHgetall.mockResolvedValue({}); + mockReadTile.mockResolvedValue(null); + const reader = new TileReader(new Redis() as any); + analyzer = new SummaryAnalyzer(reader, new Redis() as any); + }); + + it('returns clear region when no tiles exist', async () => { + const result = await analyzer.analyzeRegion(testRegion, 'composite'); + expect(result.maxDbz).toBe(0); + expect(result.severity).toBe('clear'); + expect(result.coveragePct).toBe(0); + expect(result.id).toBe('test-region'); + expect(result.affectedAirports).toEqual([]); + }); + + it('computes data age from epoch', () => { + const age = analyzer.computeDataAge(Date.now() - 100_000); + expect(age).toBeCloseTo(100, -1); + }); + + it('returns unknown trend when no previous timestamp', async () => { + const result = await analyzer.analyzeRegion(testRegion, 'composite'); + expect(result.trend).toBe('unknown'); + }); +}); diff --git a/tests/unit/situation/tile-reader.test.ts b/tests/unit/situation/tile-reader.test.ts new file mode 100644 index 0000000..c434333 --- /dev/null +++ b/tests/unit/situation/tile-reader.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import sharp from 'sharp'; +import { dbzToPixel } from '../../../src/utils/geo.js'; + +const mockReadTile = vi.fn(); +vi.mock('../../../src/storage/index.js', () => ({ + getTileStore: () => ({ readTile: mockReadTile }), +})); + +const mockGet = vi.fn(); +const mockZrevrangebyscore = vi.fn(); +const mockHgetall = vi.fn(); +vi.mock('ioredis', () => ({ + Redis: vi.fn(() => ({ get: mockGet, zrevrangebyscore: mockZrevrangebyscore, hgetall: mockHgetall })), +})); + +import { TileReader } from '../../../src/situation/sampling/tile-reader.js'; +import { Redis } from 'ioredis'; + +describe('TileReader', () => { + let reader: TileReader; + + beforeEach(() => { + vi.clearAllMocks(); + reader = new TileReader(new Redis() as any); + }); + + it('reads and decodes a tile to dBZ values', async () => { + mockGet.mockResolvedValue('20260408143000'); + + const pixel40dbz = dbzToPixel(40); + const pixels = Buffer.alloc(256 * 256, 0); + pixels[10 * 256 + 10] = pixel40dbz; + + const png = await sharp(pixels, { raw: { width: 256, height: 256, channels: 1 } }) + .png() + .toBuffer(); + + mockReadTile.mockResolvedValue(png); + + const result = await reader.readTileDbz('composite', 7, 34, 49); + expect(result).not.toBeNull(); + expect(result!.width).toBe(256); + expect(result!.height).toBe(256); + + const idx = 10 * 256 + 10; + expect(result!.dbzValues[idx]).toBeCloseTo(40, 0); + expect(result!.dbzValues[0]).toBeNaN(); + }); + + it('returns null for missing tile', async () => { + mockGet.mockResolvedValue('20260408143000'); + mockReadTile.mockResolvedValue(null); + + const result = await reader.readTileDbz('composite', 7, 34, 49); + expect(result).toBeNull(); + }); + + it('gets latest timestamp from Redis', async () => { + mockGet.mockResolvedValue('20260408143000'); + const ts = await reader.getLatestTimestamp('composite'); + expect(ts).toBe('20260408143000'); + }); + + it('gets previous timestamp from Redis', async () => { + mockZrevrangebyscore.mockResolvedValue(['20260408143000', '20260408142500']); + const ts = await reader.getPreviousTimestamp('composite'); + expect(ts).toBe('20260408142500'); + }); + + it('returns null for previous when only one frame', async () => { + mockZrevrangebyscore.mockResolvedValue(['20260408143000']); + const ts = await reader.getPreviousTimestamp('composite'); + expect(ts).toBeNull(); + }); + + it('maps precip type codes to labels', () => { + expect(reader.precipTypeLabel(1)).toBe('rain'); + expect(reader.precipTypeLabel(2)).toBe('snow'); + expect(reader.precipTypeLabel(3)).toBe('freezing_rain'); + expect(reader.precipTypeLabel(5)).toBe('hail'); + expect(reader.precipTypeLabel(99)).toBeNull(); + }); +}); diff --git a/tests/unit/situation/watchlist-updater.test.ts b/tests/unit/situation/watchlist-updater.test.ts new file mode 100644 index 0000000..bd0c850 --- /dev/null +++ b/tests/unit/situation/watchlist-updater.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockReadTile = vi.fn(); +vi.mock('../../../src/storage/index.js', () => ({ + getTileStore: () => ({ readTile: mockReadTile }), +})); + +// Mock airports +vi.mock('../../../src/situation/config/airports.js', () => { + const airports = new Map([ + ['KORD', { icao: 'KORD', name: "O'Hare", lat: 41.9742, lon: -87.9073 }], + ]); + return { + loadAirports: vi.fn(), + getAirport: (icao: string) => airports.get(icao), + getAllAirports: () => [...airports.values()], + }; +}); + +const mockRedis = { + get: vi.fn(), + set: vi.fn(), + smembers: vi.fn(), + sadd: vi.fn(), + srem: vi.fn(), + zadd: vi.fn(), + zrangebyscore: vi.fn(), + zrevrangebyscore: vi.fn(), + zremrangebyscore: vi.fn(), + hgetall: vi.fn(), +}; +vi.mock('ioredis', () => ({ Redis: vi.fn(() => mockRedis) })); + +import { WatchlistUpdater } from '../../../src/situation/workers/watchlist-updater.js'; +import { Redis } from 'ioredis'; + +describe('WatchlistUpdater', () => { + let updater: WatchlistUpdater; + + beforeEach(() => { + vi.clearAllMocks(); + mockRedis.smembers.mockResolvedValue([]); + mockRedis.get.mockResolvedValue(null); + mockRedis.hgetall.mockResolvedValue({ epochMs: String(Date.now()) }); + mockRedis.zrevrangebyscore.mockResolvedValue(['20260408143000']); + mockReadTile.mockResolvedValue(null); + updater = new WatchlistUpdater(new Redis() as any); + }); + + it('adds airports to watchlist', async () => { + await updater.addToWatchlist(['KORD', 'KJFK']); + expect(mockRedis.sadd).toHaveBeenCalledWith('situation:watchlist', 'KORD', 'KJFK'); + }); + + it('removes airports from watchlist', async () => { + await updater.removeFromWatchlist(['KORD']); + expect(mockRedis.srem).toHaveBeenCalledWith('situation:watchlist', 'KORD'); + }); + + it('gets current watchlist', async () => { + mockRedis.smembers.mockResolvedValue(['KORD', 'KJFK']); + const list = await updater.getWatchlist(); + expect(list).toEqual(['KORD', 'KJFK']); + }); + + it('processes new frame for empty watchlist', async () => { + mockRedis.smembers.mockResolvedValue([]); + const changes = await updater.processNewFrame(); + expect(changes).toHaveLength(0); + }); + + it('returns null for uncached situation', async () => { + const result = await updater.getCachedSituation('KORD'); + expect(result).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 4a58023..8e6a2a8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['tests/**/*.test.ts'], + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + }, + }, }, });