UNPKG

@fmidev/smartmet-alert-client

Version:

Web application for viewing weather and flood alerts

535 lines (495 loc) 21.3 kB
/** * Offline pipeline that downloads FMI CAP Atom snapshots from * https://alerts.fmi.fi/cap/feed/ and the current SYKE flood CAP feed * from https://wwwi2.ymparisto.fi/i2/CAP/SYKE_CAP_current.atom, * converts them to WFS-shaped WarningsData (see ./capToWfs.ts and * ./sykeToWfs.ts), and writes each result into * tests/fixtures/scenarios/scenario-<id>.json — the same directory the * legacy WFS captures live in, so the loader in * tests/fixtures/mapScenarios.ts does not need to distinguish sources. * * The produced JSON files are committed so test runs stay offline; the * cached raw XMLs are written to tests/fixtures/cap-raw/ (gitignored). * * Each CAP scenario is downloaded in Finnish only. info_sv / info_en * remain empty on the derived warnings; the language × theme matrix * spec keeps using the original 21 WFS scenarios (which carry all * three translations). * * Run with: * npx vite-node scripts/build-cap-scenarios.ts */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import { convertCapAtomToWarningsData } from './capToWfs' import { convertSykeCapToWarningsData } from './sykeToWfs' import type { WarningsData } from '../src/types' const REPO_ROOT = process.cwd() const RAW_DIR = resolve(REPO_ROOT, 'tests', 'fixtures', 'cap-raw') const SCENARIOS_DIR = resolve(REPO_ROOT, 'tests', 'fixtures', 'scenarios') /** * Each scenario is a single CAP Atom snapshot at a specific date+time, * chosen so the fixture set covers different seasons and warning mixes. * The `id` continues the numbering from the original 21 WFS scenarios, * so scenario 22 is the first CAP-derived one. */ interface CapScenarioDef { id: number label: string year: string dayDir: string // MM-DD part of the URL path /** * Exact snapshot timestamp like "13-00-55Z". When omitted, the build * script detects one automatically by listing the day's directory and * preferring a snapshot taken in the Finnish afternoon (10Z-15Z). */ stampPrefix?: string description: string } /** * Pinned scenarios — kept byte-for-byte stable so snapshot regressions * stay comparable across runs even if the feed directory layout drifts. * * These seven (ids 20..26) sit right after the 19 committed WFS * captures (ids 1..19). Id 27 is reserved for the SYKE flood scenario * built separately below; the CAP "additional" scenarios take ids * 28..83 (see ADDITIONAL_DATES). * * Dates that previously rendered with zero warnings on day 0 have been * dropped from this list — see the trimmed ADDITIONAL_DATES below — * since the regression suite keeps a single representative empty-map * scenario (WFS capture #12). */ const PINNED_SCENARIOS: CapScenarioDef[] = [ { id: 20, label: '2024-01-08 midday — cold snap, wind and snowfall', year: '2024', dayDir: '01-08', stampPrefix: '13-00-55Z', description: 'Winter cold spell with traffic and cold-weather warnings', }, { id: 21, label: '2024-03-18 midday — spring storm systems', year: '2024', dayDir: '03-18', stampPrefix: '12-44-10Z', description: 'Late winter / early spring precipitation and wind', }, { id: 22, label: '2024-06-22 afternoon — midsummer thunderstorms', year: '2024', dayDir: '06-22', stampPrefix: '14-48-10Z', description: 'Scattered thunderstorms around Midsummer', }, { id: 23, label: '2024-07-15 afternoon — heatwave + thunderstorms', year: '2024', dayDir: '07-15', stampPrefix: '14-36-49Z', description: 'Rich mix of thunder, rain and forest-fire warnings', }, { id: 24, label: '2024-10-15 afternoon — autumn storm', year: '2024', dayDir: '10-15', stampPrefix: '14-34-59Z', description: 'Windy autumn with coastal wave warnings', }, { id: 25, label: '2024-12-18 midday — pre-Christmas snowfall', year: '2024', dayDir: '12-18', stampPrefix: '12-57-10Z', description: 'Heavy snow and traffic warnings', }, { id: 26, label: '2025-01-10 midday — mid-winter cold', year: '2025', dayDir: '01-10', stampPrefix: '12-36-08Z', description: 'Deep cold and snow across northern Finland', }, ] /** * Additional dates spread across 2020..2026 with the snapshot timestamp * auto-detected at download time (midday preferred). * * Scenario ids start at 28 so that 27 stays reserved for SYKE current * floods. Rows that previously produced empty-map scenarios (either * the feed did not yet exist at the time, or day 0 carried no active * warnings) have been pruned; the regression suite keeps exactly one * representative empty-map scenario (WFS capture #12) and does not * need additional ones. * * Target: a wide set of unusual weather situations across the seasons: * - deep-winter cold spells (Jan/Feb) * - spring snowmelt and late snow (Mar/Apr) * - summer thunderstorms, forest-fire risk, heat (May–Aug) * - autumn storms and early winter (Sep–Dec) */ const ADDITIONAL_DATES: Array<{ year: string dayDir: string label: string description: string }> = [ // ------- 2020 (feed started mid-year) ------- { year: '2020', dayDir: '08-15', label: '2020-08-15 — August thunderstorm complex', description: 'Mature summer convective systems' }, { year: '2020', dayDir: '10-10', label: '2020-10-10 — autumn gales', description: 'Autumn storm season begins' }, { year: '2020', dayDir: '11-10', label: '2020-11-10 — November wind', description: 'Late-autumn coastal systems' }, { year: '2020', dayDir: '12-15', label: '2020-12-15 — December snowfall', description: 'Pre-Christmas winter weather' }, // ------- 2021 ------- { year: '2021', dayDir: '01-10', label: '2021-01-10 — deep winter', description: 'Cold snap in early January' }, { year: '2021', dayDir: '02-10', label: '2021-02-10 — cold-wave peak', description: 'Deep Feb cold spell' }, { year: '2021', dayDir: '03-15', label: '2021-03-15 — March thaw / wind', description: 'Spring transition' }, { year: '2021', dayDir: '04-15', label: '2021-04-15 — spring flood season', description: 'Snowmelt and flood warnings' }, { year: '2021', dayDir: '05-15', label: '2021-05-15 — mid-May', description: 'Late-spring weather' }, { year: '2021', dayDir: '06-15', label: '2021-06-15 — Midsummer warmth', description: 'Summer heat begins' }, { year: '2021', dayDir: '07-10', label: '2021-07-10 — July heatwave', description: 'Extended hot weather warnings' }, { year: '2021', dayDir: '08-15', label: '2021-08-15 — August thunderstorms', description: 'Late-summer convection' }, { year: '2021', dayDir: '10-15', label: '2021-10-15 — October storm', description: 'Mid-autumn storm system' }, { year: '2021', dayDir: '11-15', label: '2021-11-15 — November sleet', description: 'Autumn-to-winter transition' }, { year: '2021', dayDir: '12-20', label: '2021-12-20 — Christmas-week winter', description: 'Deep winter near year-end' }, // ------- 2022 ------- { year: '2022', dayDir: '01-05', label: '2022-01-05 — new-year cold', description: 'Start-of-year cold spell' }, { year: '2022', dayDir: '02-15', label: '2022-02-15 — mid-winter storm', description: 'Feb wind + snow combination' }, { year: '2022', dayDir: '04-05', label: '2022-04-05 — early April spring', description: 'Flood + lingering winter' }, { year: '2022', dayDir: '05-10', label: '2022-05-10 — early May warmup', description: 'Spring thunder possibility' }, { year: '2022', dayDir: '06-10', label: '2022-06-10 — early summer thunder', description: 'First summer thunderstorms' }, { year: '2022', dayDir: '07-20', label: '2022-07-20 — late July heat', description: 'High summer with hot-weather warnings' }, { year: '2022', dayDir: '08-25', label: '2022-08-25 — late-August storms', description: 'End-of-summer storm pattern' }, { year: '2022', dayDir: '09-05', label: '2022-09-05 — early September', description: 'Transition weather' }, { year: '2022', dayDir: '10-25', label: '2022-10-25 — late-autumn gale', description: 'Late-October strong winds' }, { year: '2022', dayDir: '11-20', label: '2022-11-20 — pre-winter storm', description: 'Snowfall + wind' }, { year: '2022', dayDir: '12-23', label: '2022-12-23 — Christmas winter', description: 'Holiday-period cold' }, // ------- 2023 ------- { year: '2023', dayDir: '01-05', label: '2023-01-05 — winter mild-spell', description: 'Variable early-January weather' }, { year: '2023', dayDir: '03-10', label: '2023-03-10 — March transition', description: 'Early-spring weather' }, { year: '2023', dayDir: '05-10', label: '2023-05-10 — early May', description: 'Late-spring frost potential' }, { year: '2023', dayDir: '06-10', label: '2023-06-10 — early summer', description: 'Early convective weather' }, { year: '2023', dayDir: '07-20', label: '2023-07-20 — July heat + thunder', description: 'Summer heat and thunderstorms' }, { year: '2023', dayDir: '08-25', label: '2023-08-25 — late summer', description: 'Fading summer, autumn hints' }, { year: '2023', dayDir: '09-10', label: '2023-09-10 — early autumn wind', description: 'Early-autumn weather' }, { year: '2023', dayDir: '10-20', label: '2023-10-20 — October gale', description: 'Mature autumn storms' }, { year: '2023', dayDir: '11-05', label: '2023-11-05 — November sleet', description: 'Onset of winter conditions' }, { year: '2023', dayDir: '12-24', label: '2023-12-24 — Christmas Eve', description: 'Holiday winter weather' }, // ------- 2024 additional ------- { year: '2024', dayDir: '02-05', label: '2024-02-05 — February cold', description: 'Mid-winter cold' }, { year: '2024', dayDir: '04-10', label: '2024-04-10 — spring flood season', description: 'Snowmelt floods' }, { year: '2024', dayDir: '05-20', label: '2024-05-20 — late spring', description: 'Early forest-fire risk' }, { year: '2024', dayDir: '08-15', label: '2024-08-15 — August thunderstorms', description: 'Peak convective season' }, { year: '2024', dayDir: '09-20', label: '2024-09-20 — equinox storm', description: 'Early-autumn gale' }, { year: '2024', dayDir: '11-22', label: '2024-11-22 — late November', description: 'Early snow / freezing rain' }, // ------- 2025 additional ------- { year: '2025', dayDir: '02-15', label: '2025-02-15 — mid-February winter', description: 'Deep-winter conditions' }, { year: '2025', dayDir: '03-20', label: '2025-03-20 — spring equinox', description: 'Transition weather' }, { year: '2025', dayDir: '05-10', label: '2025-05-10 — late spring', description: 'Early summer setups' }, { year: '2025', dayDir: '06-15', label: '2025-06-15 — Midsummer period', description: 'Midsummer convection' }, { year: '2025', dayDir: '07-20', label: '2025-07-20 — late July', description: 'High summer' }, { year: '2025', dayDir: '09-20', label: '2025-09-20 — autumn onset', description: 'Autumn begins' }, { year: '2025', dayDir: '10-15', label: '2025-10-15 — October gale', description: 'Mid-autumn storm' }, { year: '2025', dayDir: '12-10', label: '2025-12-10 — early December', description: 'Pre-Christmas winter' }, // ------- 2026 (so far) ------- { year: '2026', dayDir: '01-10', label: '2026-01-10 — January cold', description: 'Deep-winter cold' }, // ------- late additions (non-chronological order kept from the // original build for snapshot stability) ------- { year: '2022', dayDir: '11-05', label: '2022-11-05 — early-November', description: 'Early-autumn winter mix' }, { year: '2021', dayDir: '11-30', label: '2021-11-30 — late-November', description: 'Deep-autumn storm' }, { year: '2021', dayDir: '04-05', label: '2021-04-05 — early-April', description: 'Late-winter lingering' }, { year: '2023', dayDir: '11-20', label: '2023-11-20 — late-November', description: 'Pre-winter wind + snow' }, // Today's live snapshot: whatever FMI was publishing on the day the // scenario set was last rebuilt. Auto-detect picks the most recent // afternoon timestamp. Rerun `npx vite-node scripts/build-cap-scenarios.ts` // to refresh, then update snapshots with `npx vitest -u`. { year: '2026', dayDir: '04-22', label: '2026-04-22 — live capture', description: 'Most recent FMI snapshot at build time' }, ] const NEXT_AUTO_ID = 28 const AUTO_SCENARIOS: CapScenarioDef[] = ADDITIONAL_DATES.map((def, idx) => ({ id: NEXT_AUTO_ID + idx, label: def.label, description: def.description, year: def.year, dayDir: def.dayDir, // stampPrefix intentionally left undefined → autoDetectStamp() picks one })) const SCENARIOS: CapScenarioDef[] = [...PINNED_SCENARIOS, ...AUTO_SCENARIOS] const LANGUAGES = ['fi-FI'] as const type LangCode = (typeof LANGUAGES)[number] function rawPath(def: CapScenarioDef, lang: LangCode): string { return resolve( RAW_DIR, `${def.year}-${def.dayDir}-${def.stampPrefix}-${lang}.xml` ) } function urlFor(def: CapScenarioDef, lang: LangCode): string { return `https://alerts.fmi.fi/cap/feed/${def.year}/${def.dayDir}/${def.stampPrefix}-atom_${lang}.xml` } /** * Fetch the directory listing for a given day and pick the snapshot * timestamp that best matches Finnish afternoon (10Z..15Z, or the * middle file as a fallback). Returns null when the day has no * snapshots at all. Cached to disk under cap-raw/index/ so repeat * builds don't hammer the feed server. */ async function autoDetectStamp( year: string, dayDir: string ): Promise<string | null> { const indexCache = resolve(RAW_DIR, 'index', `${year}-${dayDir}.html`) let html: string if (existsSync(indexCache)) { html = readFileSync(indexCache, 'utf8') } else { const url = `https://alerts.fmi.fi/cap/feed/${year}/${dayDir}/` const res = await fetch(url) if (!res.ok) return null html = await res.text() mkdirSync(resolve(RAW_DIR, 'index'), { recursive: true }) writeFileSync(indexCache, html, 'utf8') } const pattern = /href="(\d{2}-\d{2}-\d{2}Z)-atom_fi-FI\.xml"/g const stamps: string[] = [] for (const match of html.matchAll(pattern)) { if (match[1]) stamps.push(match[1]) } if (stamps.length === 0) return null // Prefer snapshots taken between 10:00Z and 15:00Z (13:00..18:00 EET) const afternoon = stamps.find((s) => { const hour = Number(s.slice(0, 2)) return hour >= 10 && hour <= 15 }) return afternoon ?? stamps[Math.floor(stamps.length / 2)] ?? stamps[0] ?? null } async function fetchXml( def: CapScenarioDef, lang: LangCode, forceRefresh = false ): Promise<string> { const cache = rawPath(def, lang) if (!forceRefresh && existsSync(cache)) { return readFileSync(cache, 'utf8') } const url = urlFor(def, lang) const res = await fetch(url) if (!res.ok) { throw new Error( `Failed to download ${url}: HTTP ${res.status} ${res.statusText}` ) } const text = await res.text() mkdirSync(RAW_DIR, { recursive: true }) writeFileSync(cache, text, 'utf8') return text } interface BuildResult { id: number label: string description: string source: 'cap' | 'syke' sourceUrl: string updateTimeIso: string warnings: number unmatchedAreaNames: string[] } function writeEnvelope(args: { id: number label: string source: 'cap' | 'syke' description: string sourceUrl: string updateTimeIso: string data: WarningsData }): void { mkdirSync(SCENARIOS_DIR, { recursive: true }) const outPath = resolve(SCENARIOS_DIR, `scenario-${args.id}.json`) writeFileSync( outPath, JSON.stringify( { meta: { id: args.id, label: args.label, source: args.source, description: args.description, sourceUrl: args.sourceUrl, updateTimeIso: args.updateTimeIso, }, data: args.data, }, null, 2 ), 'utf8' ) } async function buildOne(def: CapScenarioDef): Promise<BuildResult | null> { // Resolve stampPrefix on demand for auto-detected scenarios. const resolved: CapScenarioDef = def.stampPrefix ? def : await resolveStamp(def) if (!resolved.stampPrefix) { return null } const xml = await fetchXml(resolved, 'fi-FI') const converted = convertCapAtomToWarningsData(xml) const sourceUrl = urlFor(resolved, 'fi-FI') const warningCount = ( converted.warningsData.weather_finland_active_all?.features ?? [] ).length if (warningCount === 0) { // An empty feed isn't useful for regression; skip to keep the fixture // set focused on non-trivial situations. return null } writeEnvelope({ id: resolved.id, label: resolved.label, source: 'cap', description: resolved.description, sourceUrl, updateTimeIso: converted.updateTimeIso, data: converted.warningsData, }) return { id: resolved.id, label: resolved.label, description: resolved.description, source: 'cap', sourceUrl, updateTimeIso: converted.updateTimeIso, warnings: warningCount, unmatchedAreaNames: converted.stats.unmatchedAreaNames, } } async function resolveStamp(def: CapScenarioDef): Promise<CapScenarioDef> { const stamp = await autoDetectStamp(def.year, def.dayDir) if (!stamp) { process.stderr.write( `scenario ${def.id}: no snapshot XML for ${def.year}/${def.dayDir}; skipping\n` ) return def } return { ...def, stampPrefix: stamp } } async function main(): Promise<void> { const results: BuildResult[] = [] const skipped: number[] = [] for (const def of SCENARIOS) { try { const r = await buildOne(def) if (!r) { skipped.push(def.id) continue } results.push(r) process.stdout.write( `scenario ${r.id}: ${r.warnings} warnings — ${r.label}\n` ) if (r.unmatchedAreaNames.length > 0) { process.stdout.write( ` unmatched areas (${r.unmatchedAreaNames.length}): ${r.unmatchedAreaNames.slice(0, 6).join(', ')}${r.unmatchedAreaNames.length > 6 ? ', …' : ''}\n` ) } } catch (err) { process.stderr.write( `scenario ${def.id} failed: ${(err as Error).message}\n` ) skipped.push(def.id) } } if (skipped.length > 0) { process.stdout.write( `\nSkipped ${skipped.length} scenarios (empty feed or download error): ${skipped.join(', ')}\n` ) } // SYKE current floods — one extra scenario (id=29) sourced from the // national flood CAP feed. Since the feed is "current only", the cached // XML in tests/fixtures/cap-raw is the snapshot of record — committing // the derived envelope is what keeps it reproducible. try { const sykeResult = await buildSykeFloodScenario() if (sykeResult) { results.push(sykeResult) process.stdout.write( `scenario ${sykeResult.id}: ${sykeResult.warnings} flood features — ${sykeResult.label}\n` ) if (sykeResult.unmatchedAreaNames.length > 0) { process.stdout.write( ` unmatched areas: ${sykeResult.unmatchedAreaNames.slice(0, 6).join(', ')}${sykeResult.unmatchedAreaNames.length > 6 ? ', …' : ''}\n` ) } } } catch (err) { process.stderr.write( `SYKE flood scenario failed: ${(err as Error).message}\n` ) } process.stdout.write( `\nWrote ${results.length} scenarios to ${SCENARIOS_DIR}\n` ) } const SYKE_FEED_URL = 'https://wwwi2.ymparisto.fi/i2/CAP/SYKE_CAP_current.atom' const SYKE_CACHE = () => resolve(RAW_DIR, 'syke-current.atom.xml') async function fetchSykeXml(forceRefresh = false): Promise<string> { const cache = SYKE_CACHE() if (!forceRefresh && existsSync(cache)) { return readFileSync(cache, 'utf8') } const res = await fetch(SYKE_FEED_URL) if (!res.ok) { throw new Error( `Failed to download ${SYKE_FEED_URL}: HTTP ${res.status} ${res.statusText}` ) } const text = await res.text() mkdirSync(RAW_DIR, { recursive: true }) writeFileSync(cache, text, 'utf8') return text } /** * SYKE current-flood scenario id. Sits between the pinned CAP scenarios * (20..26) and the auto-numbered additional ones (28..82). */ const SYKE_SCENARIO_ID = 27 async function buildSykeFloodScenario(): Promise<BuildResult | null> { const xml = await fetchSykeXml() const converted = convertSykeCapToWarningsData(xml) const flood = converted.warningsData.flood_finland_active_all if (!flood || flood.features.length === 0) { process.stdout.write( `scenario ${SYKE_SCENARIO_ID}: SYKE feed has no active floods; skipping\n` ) return null } const label = `SYKE current floods — snapshot ${converted.updateTimeIso}` const description = 'Active flood warnings from SYKE at snapshot time' writeEnvelope({ id: SYKE_SCENARIO_ID, label, source: 'syke', description, sourceUrl: SYKE_FEED_URL, updateTimeIso: converted.updateTimeIso, data: converted.warningsData, }) return { id: SYKE_SCENARIO_ID, label, description, source: 'syke', sourceUrl: SYKE_FEED_URL, updateTimeIso: converted.updateTimeIso, warnings: flood.features.length, unmatchedAreaNames: converted.stats.unmatchedAreaNames, } } await main() export {}