Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 70 additions & 0 deletions scripts/generate-airports.ts
Original file line number Diff line number Diff line change
@@ -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<string, { name: string; lat: number; lon: number }> = {};
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);
3 changes: 3 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
28 changes: 28 additions & 0 deletions src/situation/analysis/history.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await this.redis.zadd(`${KEY_PREFIX}${icao}`, epochMs, JSON.stringify(frame));
}

async getFrames(icao: string, hours: number, now?: number): Promise<HistoryFrame[]> {
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<void> {
const cutoff = Date.now() - retentionHours * 3600_000;
await this.redis.zremrangebyscore(`${KEY_PREFIX}${icao}`, '-inf', cutoff);
}
}
51 changes: 51 additions & 0 deletions src/situation/analysis/severity.ts
Original file line number Diff line number Diff line change
@@ -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';
}
98 changes: 98 additions & 0 deletions src/situation/analysis/summary.ts
Original file line number Diff line number Diff line change
@@ -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<RegionSummary> {
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<string>();

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<number> {
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);
}
}
41 changes: 41 additions & 0 deletions src/situation/config/airports.ts
Original file line number Diff line number Diff line change
@@ -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<string, Airport> = new Map();

export function loadAirports(overridePath?: string): void {
const bundledPath = join(__dirname, '..', '..', '..', 'data', 'airports.json');
const bundled: Record<string, { name: string; lat: number; lon: number }> =
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<string, { name: string; lat: number; lon: number }> =
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()];
}
Loading
Loading