diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b32ce55..8892711cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps. + - Give application request services, terrain/floor caches and annotation lookup state explicit owners and cancellation; share them across controls, layers and voice. - Construct application layers from explicit sources, with standalone provider selection and catalog-owned aircraft classification; controls and voice queries use those instances. @@ -899,3 +901,4 @@ represent previously published GitHub Releases. ## [0.1.0] — 2026-02-09 - Initial project version. + diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 593c3224c..8364515b5 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -142,6 +142,10 @@ transaction quota. Requires a free `FIRMS_MAP_KEY` (https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it. The former bundled 2026-05-25 snapshot was removed 2026-07-16. +### ECMWF IFS wind + +Wind also offers ECMWF IFS 10 m forecasts from the keyless [ECMWF Open Data](https://www.ecmwf.int/en/forecasts/datasets/open-data) service. The proxy reads its JSON Lines inventory and fetches only 10u/10v GRIB messages, resampling the forecast to the display grid. Data is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Attribution: “Contains modified Copernicus/ECMWF IFS forecast data.” Both model issue time and the selected forecast valid time appear in the layer row. These are model forecasts, not observations. + ### NOAA GFS wind The optional **Wind** layer animates the global 10 m wind field from NOAA's @@ -197,3 +201,4 @@ Douglas-Peucker simplification, 6-decimal rounding). ## In-app attribution The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app. + diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9a27d6cfb..2ecf83446 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3624,3 +3624,5 @@ releases imagery layers/listeners on replacement. Destroy invalidates pending work and releases owned resources, including late factory results. Supplied 3D tilesets remain owned by the caller; tilesets created through the controller's factory are added to its viewer and removed on destruction. + +Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle and forecast step. Per-model caches retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. diff --git a/server/providers/wind.js b/server/providers/wind.js index ae1ef77b3..c305a93d0 100644 --- a/server/providers/wind.js +++ b/server/providers/wind.js @@ -1,193 +1,99 @@ -import { - GFS_BUCKET, - gfsObjectKey, - selectLatestGfsCycle, -} from './wind/catalog.js'; -import { - fetchText, - fetchRange, - parseGfsIdx, - windMessageRanges, -} from './wind/gfs.js'; +import { fetchGfsWind } from './wind/gfs.js'; +import { fetchIfsWind } from './wind/ifs.js'; import { decodeWindGribMessage } from './wind/decode.js'; -import { resampleWindGrid } from './wind/grid.js'; - -/** - * NOAA GFS 10 m wind proxy. - * - * Selects the latest available GFS cycle, reads its `.idx` inventory, byte-range - * fetches only the UGRD/VGRD 10 m GRIB2 messages, decodes them with ecCodes - * (WASM), resamples to a compact grid, and serves a manifest plus a Float32 - * grid (U values then V values). Keyless; cached per cycle for an hour. - * - * Routes (mounted at `/api/wind`): - * GET /api/wind/manifest → manifest JSON - * GET /api/wind/grid/.bin → Float32 U…V payload - * GET /api/wind/status → manifest without `gridUrl` - * - * @param {{fetchImpl?: Function, now?: Function, decodeImpl?: Function, - * targetDx?: number, ttlMs?: number}} [options] - * @returns {import('vite').Plugin} - */ -export function windProxy({ - fetchImpl = fetch, - now = () => Date.now(), - decodeImpl = decodeWindGribMessage, - targetDx = 1, - ttlMs = 3600_000, -} = {}) { - /** @type {?{id: string, idGrid: string, fetchedAt: number, manifest: object, grid: ?object}} */ - let cached = null; - /** @type {?Promise} */ - let loading = null; - let lastAttempt = -Infinity; - let controller = null; - let waiters = 0; +/** Serve bounded, single-flight, per-model forecast snapshots from fixed providers. */ +export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImpl = decodeWindGribMessage, targetDx = 1, ttlMs = 3600_000, timeoutMs = 40_000, models = { gfs: fetchGfsWind, ifs: fetchIfsWind } } = {}) { + const caches = new Map(); + const loadings = new Map(); + const grids = new Map(); + const attempts = new Map(); + const unavailable = (model) => ({ manifest: { model, schemaVersion: 1, unavailable: true, stale: true, reason: 'Wind upstream unavailable' } }); const sendJson = (res, value, status = 200) => { - res.writeHead(status, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - }); + res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(value)); }; - - const cycleRunIso = (cycle) => - `${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z`; - - async function refresh() { - const cycle = selectLatestGfsCycle(now()); - const id = `${cycle.date}-${cycle.hour}`; - if (cached && cached.id === id && now() - cached.fetchedAt < ttlMs) - return cached; - if (loading) return loading; - if (cached && now() - lastAttempt < 60_000) return cached; - lastAttempt = now(); - controller = new AbortController(); - const signal = controller.signal; - const timer = setTimeout(() => controller.abort(), 40_000); - loading = (async () => { + function refresh(model) { + const controller = new AbortController(); + const operation = { controller, waiters: 0, promise: null }; + loadings.set(model, operation); + attempts.set(model, now()); + operation.promise = (async () => { + const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`; - const index = await fetchText({ url: `${base}.idx`, fetchImpl, signal }); - const ranges = windMessageRanges(parseGfsIdx(index.toString())); - const [uBuffer, vBuffer] = await Promise.all([ - fetchRange({ url: base, ...ranges.u, fetchImpl, signal }), - fetchRange({ url: base, ...ranges.v, fetchImpl, signal }), - ]); - const [u, v] = await Promise.all([ - decodeImpl(uBuffer), - decodeImpl(vBuffer), - ]); - const grid = resampleWindGrid({ - u: u.values, - v: v.values, - ni: u.ni, - nj: u.nj, - lo1: u.lo1, - la1: u.la1, - di: u.di, - dj: u.dj, - dx: targetDx, - dy: targetDx, - }); - signal.throwIfAborted(); - if ((!grid.u.every(Number.isFinite) || !grid.v.every(Number.isFinite))) throw new Error('Invalid wind grid'); - const idGrid = `${id}-${targetDx}`; + const value = await models[model]({ fetchImpl, now, decodeImpl, targetDx, signal: controller.signal }); + controller.signal.throwIfAborted(); + const { grid, cycle } = value; + if (!grid.u.every(Number.isFinite) || !grid.v.every(Number.isFinite)) throw new Error('Invalid wind grid'); + const id = `${model}-${cycle.date}-${cycle.hour}-f${cycle.forecastHour || 0}-${targetDx}`; const manifest = { - schemaVersion: 1, - model: 'gfs', - cycle: { ...cycle, forecastHour: 0, runIso: cycleRunIso(cycle), validIso: cycleRunIso(cycle) }, - fetchedAt: now(), - level: '10 m above ground', - units: 'm/s', - grid: { - nx: grid.nx, - ny: grid.ny, - lo1: grid.lo1, - la1: grid.la1, - dx: grid.dx, - dy: grid.dy, - }, - stale: false, - unavailable: false, - reason: null, - gridUrl: `/api/wind/grid/${idGrid}.bin`, + schemaVersion: 1, model, cycle, fetchedAt: now(), level: value.level, units: value.units, + grid: { nx: grid.nx, ny: grid.ny, lo1: grid.lo1, la1: grid.la1, dx: grid.dx, dy: grid.dy }, + stale: false, unavailable: false, reason: null, + gridUrl: `/api/wind/grid/${id}.bin?model=${model}`, }; - cached = { id, idGrid, fetchedAt: manifest.fetchedAt, manifest, grid }; - return cached; - } catch (error) { - if (cached) { - cached = { - ...cached, - manifest: { ...cached.manifest, stale: true, reason: 'Wind upstream unavailable' }, - }; - } else { - cached = { - id: null, - idGrid: null, - fetchedAt: now(), - grid: null, - manifest: { - schemaVersion: 1, - model: 'gfs', - stale: true, - unavailable: true, - reason: 'Wind upstream unavailable', - }, - }; + const state = { id, grid, manifest, fetchedAt: now() }; + caches.set(model, state); + // Retain the previous issued grid as well to cover a manifest/grid rollover. + const history = grids.get(model) || new Map(); + history.set(id, state); + while (history.size > 2) history.delete(history.keys().next().value); + grids.set(model, history); + return state; + } catch { + if (controller.signal.aborted && operation.waiters === 0) { + attempts.delete(model); + return unavailable(model); } - return cached; + const old = caches.get(model); + if (old) { + old.manifest = { ...old.manifest, stale: true, reason: 'Wind upstream unavailable' }; + return old; + } + return unavailable(model); } finally { clearTimeout(timer); - loading = null; - controller = null; + controller.abort(); // Cancel a sibling request if the other component failed. + if (loadings.get(model) === operation) loadings.delete(model); } })(); - return loading; + return operation; } - const handler = async (req, res) => { - const path = new URL(req.url, 'http://localhost').pathname; + const url = new URL(req.url, 'http://localhost'); + const model = url.searchParams.get('model') || 'gfs'; if (req.method !== 'GET') return sendJson(res, { error: 'method_not_allowed' }, 405); - if (!['/', '/manifest', '/status'].includes(path) && !/^\/grid\/[\w.-]+\.bin$/.test(path)) return sendJson(res, { error: 'not_found' }, 404); - // Grid reads never initiate acquisition; an issued manifest owns its cached grid. - if (path.startsWith('/grid/') && (!cached?.grid || path !== `/grid/${cached.idGrid}.bin`)) return sendJson(res, { error: 'unknown_grid' }, 404); - let disconnected = false; - const close = () => { disconnected = true; waiters -= 1; if (waiters === 0) controller?.abort(); }; - waiters += 1; - res.once?.('close', close); - let state; - try { state = path.startsWith('/grid/') ? cached : await refresh(); } - finally { res.removeListener?.('close', close); if (!disconnected) waiters -= 1; } - if (disconnected) return; - if (path === '/status') { - const { gridUrl, ...manifest } = state.manifest; - return sendJson(res, manifest); + if (!['gfs', 'ifs'].includes(model) || !Object.hasOwn(models, model)) return sendJson(res, { error: 'unknown_model' }, 400); + if (url.pathname.startsWith('/grid/')) { + const id = url.pathname.slice(6).replace(/\.bin$/, ''); + const state = url.pathname === `/grid/${id}.bin` && grids.get(model)?.get(id); + if (!state) return sendJson(res, { error: 'unknown_grid' }, 404); + const bytes = Buffer.concat([Buffer.from(state.grid.u.buffer, state.grid.u.byteOffset, state.grid.u.byteLength), Buffer.from(state.grid.v.buffer, state.grid.v.byteOffset, state.grid.v.byteLength)]); + res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'public, max-age=3600, immutable' }); + return res.end(bytes); } - if (path.startsWith('/grid/')) { - if (!state.grid || path !== `/grid/${state.idGrid}.bin`) - return sendJson(res, { error: 'unknown_grid' }, 404); - const payload = Buffer.concat([ - Buffer.from(state.grid.u.buffer, state.grid.u.byteOffset, state.grid.u.byteLength), - Buffer.from(state.grid.v.buffer, state.grid.v.byteOffset, state.grid.v.byteLength), - ]); - res.writeHead(200, { - 'Content-Type': 'application/octet-stream', - 'Cache-Control': 'public, max-age=3600, immutable', - }); - return res.end(payload); + if (!['/', '/manifest', '/status'].includes(url.pathname)) return sendJson(res, { error: 'not_found' }, 404); + let state = caches.get(model); + if (!state || now() - state.fetchedAt >= ttlMs) { + let operation = loadings.get(model); + if (!operation && now() - (attempts.get(model) ?? -Infinity) >= 60_000) operation = refresh(model); + if (operation) { + let disconnected = false; + operation.waiters += 1; + const close = () => { disconnected = true; if (--operation.waiters === 0) operation.controller.abort(); }; + res.once?.('close', close); + try { state = await operation.promise; } + finally { res.removeListener?.('close', close); if (!disconnected) operation.waiters -= 1; } + if (disconnected) return; + } } - return sendJson(res, state.manifest); + const manifest = state?.manifest || unavailable(model).manifest; + if (url.pathname === '/status') { const { gridUrl, ...status } = manifest; return sendJson(res, status); } + return sendJson(res, manifest); }; - return { name: 'wind', - configureServer({ middlewares }) { - middlewares.use('/api/wind', handler); - }, - configurePreviewServer({ middlewares }) { - middlewares.use('/api/wind', handler); - }, + configureServer({ middlewares }) { middlewares.use('/api/wind', handler); }, + configurePreviewServer({ middlewares }) { middlewares.use('/api/wind', handler); }, }; } diff --git a/server/providers/wind/catalog.js b/server/providers/wind/catalog.js index 1e67b8537..f78e9056f 100644 --- a/server/providers/wind/catalog.js +++ b/server/providers/wind/catalog.js @@ -14,3 +14,16 @@ export function selectLatestGfsCycle(nowMs, { availabilityLagMs = 5 * 3600_000 } date.setUTCHours(hour); return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), hour }; } + +/** + * Round a time offset to the nearest published GFS forecast step so the field + * shown is the one valid closest to now, not always the analysis (f000). + * GFS 0.25° is hourly through f120, then every 3 h through f384. + * @param {number} hours - Hours elapsed since the cycle run time. + * @returns {number} A valid forecast step. + */ +export function nearestGfsStep(hours) { + const value = Math.max(0, Number.isFinite(hours) ? hours : 0); + const step = value <= 120 ? Math.round(value) : Math.round(value / 3) * 3; + return Math.max(0, Math.min(384, step)); +} diff --git a/server/providers/wind/gfs.js b/server/providers/wind/gfs.js index d28b2faa7..36c49ce70 100644 --- a/server/providers/wind/gfs.js +++ b/server/providers/wind/gfs.js @@ -88,3 +88,34 @@ export async function fetchRange({ throw new Error('invalid range length'); return buffer; } + +import { GFS_BUCKET, gfsObjectKey, selectLatestGfsCycle, nearestGfsStep } from './catalog.js'; +import { decodeWindGribMessage } from './decode.js'; +import { resampleWindGrid } from './grid.js'; + +/** Fetch and decode the GFS 10 m wind field valid closest to now. */ +export async function fetchGfsWind({ fetchImpl = fetch, now = () => Date.now(), targetDx = 1, decodeImpl = decodeWindGribMessage, signal } = {}) { + const nowMs = now(); + const cycle = selectLatestGfsCycle(nowMs); + const runMs = Date.UTC( + Number(cycle.date.slice(0, 4)), + Number(cycle.date.slice(4, 6)) - 1, + Number(cycle.date.slice(6, 8)), + cycle.hour, + ); + // Pick the forecast step whose valid time is closest to now, so the layer + // shows the freshest field the cycle offers instead of the analysis. + const forecastHour = nearestGfsStep((nowMs - runMs) / 3600_000); + const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey({ ...cycle, forecastHour })}`; + const index = await fetchText({ url: `${base}.idx`, fetchImpl, signal }); + const ranges = windMessageRanges(parseGfsIdx(index.toString())); + const [uBuffer, vBuffer] = await Promise.all([ + fetchRange({ url: base, ...ranges.u, fetchImpl, signal }), + fetchRange({ url: base, ...ranges.v, fetchImpl, signal }), + ]); + const [u, v] = await Promise.all([decodeImpl(uBuffer), decodeImpl(vBuffer)]); + const grid = resampleWindGrid({ u: u.values, v: v.values, ni: u.ni, nj: u.nj, lo1: u.lo1, la1: u.la1, di: u.di, dj: u.dj, dx: targetDx, dy: targetDx }); + const runIso = new Date(runMs).toISOString(); + const validIso = new Date(runMs + forecastHour * 3600_000).toISOString(); + return { cycle: { ...cycle, forecastHour, runIso, validIso }, level: '10 m above ground', units: 'm/s', grid }; +} diff --git a/server/providers/wind/ifs.js b/server/providers/wind/ifs.js new file mode 100644 index 000000000..433591454 --- /dev/null +++ b/server/providers/wind/ifs.js @@ -0,0 +1,146 @@ +import { fetchText, fetchRange } from './gfs.js'; +import { decodeWindGribMessage } from './decode.js'; +import { resampleWindGrid } from './grid.js'; + +/** Keyless ECMWF Open Data root for real-time IFS forecasts. */ +export const IFS_BASE = 'https://data.ecmwf.int/forecasts'; + +/** + * Select the latest IFS cycle that should be available. + * @param {number} nowMs - Reference epoch milliseconds. + * @param {{availabilityLagMs?: number}} [options] + * @returns {{date: string, hour: number}} + */ +export function selectLatestIfsCycle( + nowMs, + { availabilityLagMs = 6 * 3600_000 } = {}, +) { + const shifted = new Date(nowMs - availabilityLagMs); + return { + date: shifted.toISOString().slice(0, 10).replaceAll('-', ''), + hour: Math.floor(shifted.getUTCHours() / 6) * 6, + }; +} + +/** + * Build the IFS GRIB2 and `.index` URLs for one cycle and step. + * @param {{date: string, hour: number, step?: number}} cycle + * @returns {{grib: string, index: string}} + */ +export function ifsObjectUrls({ date, hour, step = 0 }) { + const hh = String(hour).padStart(2, '0'); + const stem = `${IFS_BASE}/${date}/${hh}z/ifs/0p25/oper/${date}${hh}0000-${step}h-oper-fc`; + // The inventory sits beside the GRIB2 as `.index`, NOT `.index`. + return { grib: `${stem}.grib2`, index: `${stem}.index` }; +} + +/** + * Parse the IFS JSON Lines inventory into `{param, offset, length}` records. + * Malformed lines are skipped; a payload with no valid lines is rejected. + * @param {string} text - Raw `.index` body. + * @returns {Array<{param: string, offset: number, length: number}>} + */ +export function parseIfsIndex(text) { + const entries = []; + for (const line of String(text).split(/\r?\n/)) { + if (!line) continue; + let value; + try { + value = JSON.parse(line); + } catch { + continue; // non-JSON line in the index + } + if ( + value?.param && + Number.isFinite(value._offset) && + Number.isFinite(value._length) + ) + entries.push({ + param: value.param, + offset: value._offset, + length: value._length, + }); + } + if (!entries.length) throw new Error('malformed IFS index'); + return entries; +} + +/** + * Byte ranges for the IFS 10 m wind components. + * @param {Array<{param: string, offset: number, length: number}>} entries + * @returns {{u: {start: number, end: number}, v: {start: number, end: number}}} + */ +export function ifsWindRanges(entries) { + const range = (param) => { + const item = entries.find((entry) => entry.param === param); + if (!item) throw new Error(`missing ${param}`); + return { start: item.offset, end: item.offset + item.length - 1 }; + }; + return { u: range('10u'), v: range('10v') }; +} + +/** + * Round a time offset to the nearest published IFS forecast step. IFS 0.25° + * oper is 3-hourly through f144 (00/12z) or f90 (06/18z). + * @param {number} hours - Hours elapsed since the cycle run time. + * @param {number} cycleHour - The cycle hour (0, 6, 12 or 18). + * @returns {number} A valid forecast step. + */ +export function nearestIfsStep(hours, cycleHour) { + const max = cycleHour === 6 || cycleHour === 18 ? 90 : 144; + const value = Math.max(0, Number.isFinite(hours) ? hours : 0); + return Math.max(0, Math.min(max, Math.round(value / 3) * 3)); +} + +/** + * Fetch and decode the IFS 10 m wind field valid closest to now. + * @param {{fetchImpl?: Function, now?: Function, targetDx?: number, + * decodeImpl?: Function}} [options] + * @returns {Promise<{cycle: object, level: string, units: string, grid: object}>} + */ +export async function fetchIfsWind({ + fetchImpl = fetch, + now = () => Date.now(), + targetDx = 1, + decodeImpl = decodeWindGribMessage, + signal, +} = {}) { + const nowMs = now(); + const cycle = selectLatestIfsCycle(nowMs); + const runMs = Date.UTC( + Number(cycle.date.slice(0, 4)), + Number(cycle.date.slice(4, 6)) - 1, + Number(cycle.date.slice(6, 8)), + cycle.hour, + ); + // Show the field valid closest to now rather than always the analysis. + const forecastHour = nearestIfsStep((nowMs - runMs) / 3600_000, cycle.hour); + const urls = ifsObjectUrls({ ...cycle, step: forecastHour }); + const index = await fetchText({ url: urls.index, fetchImpl, signal }); + const ranges = ifsWindRanges(parseIfsIndex(index.toString())); + const [uBuffer, vBuffer] = await Promise.all([ + fetchRange({ url: urls.grib, ...ranges.u, fetchImpl, signal }), + fetchRange({ url: urls.grib, ...ranges.v, fetchImpl, signal }), + ]); + const [u, v] = await Promise.all([decodeImpl(uBuffer), decodeImpl(vBuffer)]); + const grid = resampleWindGrid({ + u: u.values, + v: v.values, + ni: u.ni, + nj: u.nj, + lo1: u.lo1, + la1: u.la1, + di: u.di, + dj: u.dj, + dx: targetDx, + dy: targetDx, + }); + const runIso = new Date(runMs).toISOString(); + const validIso = new Date(runMs + forecastHour * 3600_000).toISOString(); + return { + cycle: { ...cycle, forecastHour, runIso, validIso }, + level: '10 m above ground', + units: 'm/s', + grid, + }; +} diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 53b1382a0..1b3c22cda 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -204,6 +204,13 @@ export const DATA_CREDITS = [ 'NOAA Open Data on AWS ' + '(U.S. public domain; forecast, not observations)', }, + { + key: 'ecmwf-ifs', + html: + 'Contains modified Copernicus/ECMWF IFS forecast data, licensed under CC BY 4.0. ' + + 'Source: https://data.ecmwf.int/forecasts/ ' + + '(ECMWF Open Data)', + }, // ── Bundled snapshots ─────────────────────────────────────────── { key: 'datacenters', diff --git a/src/data/layerState.js b/src/data/layerState.js index a55089879..73f8546f7 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -182,6 +182,9 @@ function integerOption(key, token, defaultValue) { } const OPTION_GROUPS = Object.freeze({ + wind: Object.freeze([ + enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }), + ]), flights: Object.freeze([ // Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in // PROXIMITY mode. Proximity is itself the altitude/count gate — models only @@ -293,7 +296,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }), Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }), Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }), - Object.freeze({ id: 'wind', token: 'k', disposition: 'enabled-only' }), + Object.freeze({ id: 'wind', token: 'k', disposition: 'enabled+options', optionOwner: 'wind' }), ]); export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id)); diff --git a/src/data/windIfs.test.mjs b/src/data/windIfs.test.mjs new file mode 100644 index 000000000..7f98c808d --- /dev/null +++ b/src/data/windIfs.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ifsObjectUrls, parseIfsIndex, ifsWindRanges, nearestIfsStep, selectLatestIfsCycle } from '../../server/providers/wind/ifs.js'; +import { nearestGfsStep } from '../../server/providers/wind/catalog.js'; +test('IFS inventory selects only 10 m components and distinct index suffix', () => { + const urls = ifsObjectUrls({ date: '20260915', hour: 6, step: 9 }); + assert.match(urls.index, /09000|9h-oper-fc.index$/); + assert.equal(urls.index.includes('.grib2.index'), false); + assert.deepEqual(ifsWindRanges(parseIfsIndex('{"param":"10u","_offset":20,"_length":10}\n{"param":"10v","_offset":40,"_length":15}')), { u: {start:20,end:29}, v:{start:40,end:54} }); + assert.deepEqual(selectLatestIfsCycle(Date.UTC(2026,8,15,4)), { date:'20260914', hour:18 }); + assert.equal(nearestIfsStep(8,6),9); assert.equal(nearestGfsStep(8.2),8); +}); diff --git a/src/data/windProxy.test.mjs b/src/data/windProxy.test.mjs index 3055ae52c..711b19bfb 100644 --- a/src/data/windProxy.test.mjs +++ b/src/data/windProxy.test.mjs @@ -53,6 +53,9 @@ const DECODED_U = { }; const DECODED_V = { ...DECODED_U, values: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32] }; +/** Resampled-grid metadata shared by the injected model fakes. */ +const GRID = { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 }; + /** Fetch double: `.idx` text, and range bodies of the requested length. */ function makeFetch({ fail = false, counter } = {}) { return async (url, options = {}) => { @@ -87,7 +90,7 @@ test('wind manifest describes the GFS cycle and resampled grid', async () => { assert.equal(body.cycle.date, '20260914'); assert.equal(body.units, 'm/s'); assert.deepEqual(body.grid, { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 }); - assert.match(body.gridUrl, /^\/api\/wind\/grid\/20260914-6-90\.bin$/); + assert.match(body.gridUrl, /^\/api\/wind\/grid\/gfs-20260914-6-f6-90\.bin\?model=gfs$/); assert.equal(body.stale, false); }); @@ -168,3 +171,65 @@ test('wind returns a JSON 404 for an unknown grid', async () => { assert.equal(res.statusCode, 404); assert.deepEqual(JSON.parse(res.body), { error: 'unknown_grid' }); }); + +test('wind grid ids and URLs are model-scoped', async () => { + const request = install( + proxy({ + models: { + gfs: async () => ({ cycle: { date: '20260914', hour: 6 }, level: 'x', units: 'm/s', grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) } }), + ifs: async () => ({ cycle: { date: '20260914', hour: 0 }, level: 'x', units: 'm/s', grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) } }), + }, + }), + ); + const gfs = JSON.parse((await request('/')).body); + const ifs = JSON.parse((await request('/?model=ifs')).body); + assert.match(gfs.gridUrl, /gfs-20260914-6-f0-90\.bin\?model=gfs$/); + assert.match(ifs.gridUrl, /ifs-20260914-0-f0-90\.bin\?model=ifs$/); + // Each model's grid resolves through its own model parameter. + assert.equal((await request(gfs.gridUrl.replace('/api/wind', ''))).statusCode, 200); + assert.equal((await request(ifs.gridUrl.replace('/api/wind', ''))).statusCode, 200); +}); + +test('wind rejects a non-finite grid instead of caching it', async () => { + const bad = new Float32Array(12); + bad[3] = Number.POSITIVE_INFINITY; + const request = install( + proxy({ + models: { + gfs: async () => ({ cycle: { date: '20260914', hour: 6 }, level: 'x', units: 'm/s', grid: { ...GRID, u: bad, v: new Float32Array(12) } }), + }, + }), + ); + const body = JSON.parse((await request('/')).body); + assert.equal(body.unavailable, true); + assert.equal(body.reason, 'Wind upstream unavailable'); +}); + +test('wind rejects unknown models, paths and methods before acquisition', async () => { + let calls = 0; + const request = install(proxy({ models: { gfs: async () => { calls++; throw new Error(); } } })); + assert.equal((await request('/?model=toString')).statusCode, 400); + assert.equal((await request('/secret')).statusCode, 404); + assert.equal((await request('/manifest', 'POST')).statusCode, 405); + assert.equal((await request('/grid/unknown.bin')).statusCode, 404); + assert.equal(calls, 0); +}); + +test('wind caches forecast steps separately and keeps a prior issued grid readable', async () => { + let clock = Date.UTC(2026, 8, 14, 12); let step = 6; + const request = install(proxy({ now: () => clock, models: { gfs: async () => ({ cycle: { date: '20260914', hour: 6, forecastHour: step }, level:'10 m', units:'m/s', grid: { ...GRID, u: new Float32Array(12).fill(step), v: new Float32Array(12) } }) } })); + const first = JSON.parse((await request('/')).body); + clock += 3600_000; step = 7; + const second = JSON.parse((await request('/')).body); + assert.notEqual(first.gridUrl, second.gridUrl); + assert.equal((await request(first.gridUrl.replace('/api/wind',''))).statusCode, 200); +}); + +test('wind failure backoff prevents unbounded repeated upstream acquisition', async () => { + let calls = 0; + const request = install(proxy({ models: { gfs: async () => { calls++; throw new Error('private upstream detail'); } } })); + const results = await Promise.all([request('/'), request('/')]); + assert.equal(calls, 1); + assert.equal(JSON.parse(results[0].body).reason, 'Wind upstream unavailable'); + await request('/'); assert.equal(calls, 1); +}); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 276c83373..105190467 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -27,10 +27,12 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender let manifest = null; let error = null; let loading = false; + let model = 'gfs'; + let generation = 0; let rowControlsListener = null; const notify = () => rowControlsListener?.(); const layer = { - id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS · FORECAST', updateInterval: 3600_000, + id: 'wind', name: 'Wind', icon: '🌬', source: 'GFS / ECMWF IFS · FORECAST', updateInterval: 3600_000, init(nextViewer) { viewer = nextViewer; rendering = createRendering({ cesium, container: container ?? nextViewer.container, getViewer: () => viewer }); @@ -39,6 +41,7 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender enable() { enabled = true; rendering?.start(); }, disable() { enabled = false; + generation += 1; request?.abort(); request = null; loading = false; rendering?.stop(); rendering?.clear(); }, @@ -49,11 +52,12 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender const abort = () => controller.abort(signal.reason); signal?.addEventListener('abort', abort, { once: true }); request = controller; + generation += 1; loading = true; notify(); try { signal?.throwIfAborted(); - const snapshot = await feed.getSnapshot({ signal: controller.signal }); + const snapshot = await feed.getSnapshot({ signal: controller.signal, model }); if (!enabled || controller.signal.aborted || request !== controller) return false; manifest = snapshot; error = null; @@ -69,14 +73,27 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender if (request === controller) { request = null; loading = false; notify(); } } }, + setParams(params = {}) { + if (!['gfs', 'ifs'].includes(params.model) || params.model === model) return; + model = params.model; + request?.abort(); request = null; + manifest = null; error = null; loading = enabled; + rendering?.stop(); rendering?.clear(); + const current = ++generation; + notify(); + if (enabled) queueMicrotask(() => { + if (enabled && generation === current) void layer.update(viewer); + }); + }, + getParams() { return { model }; }, getRowControls() { const valid = formatWindValidTime(manifest?.cycle?.validIso); const run = formatWindValidTime(manifest?.cycle?.runIso); - return { chips: [], legend: [], info: `GFS forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}` }; + return { chips: ['gfs', 'ifs'].map((value) => ({ id: `model-${value}`, label: value.toUpperCase(), active: model === value, params: { model: value }, title: value === 'ifs' ? 'ECMWF IFS 10 m forecast' : 'NOAA GFS 10 m forecast' })), legend: [{ label: '0', color: '#1e3a8a' }, { label: '10', color: '#22d3ee' }, { label: '20', color: '#fbbf24' }, { label: '30+ m/s', color: '#ef4444' }], info: `${model.toUpperCase()} forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}` }; }, setRowControlsListener(listener) { rowControlsListener = typeof listener === 'function' ? listener : null; }, destroy() { layer.disable(); rendering?.destroy(); rendering = null; viewer = null; rowControlsListener = null; }, - getStats() { return { ...windStats(manifest), loading, error: error || windStats(manifest).error }; }, + getStats() { return { ...windStats(manifest), loading, model: model.toUpperCase(), source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS', validTime: formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable', error: error || windStats(manifest).error }; }, getParticleCount() { return rendering?.getParticleCount() || 0; }, }; return layer; diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs new file mode 100644 index 000000000..4b6ece1c6 --- /dev/null +++ b/src/layers/wind/index.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createWindLayer, formatWindValidTime, windStats } from './index.js'; + +const snapshot = (model) => ({ model, grid: { nx: 2, ny: 2 }, cycle: { runIso: '2026-09-14T12:00:00Z', validIso: '2026-09-14T18:00:00Z' } }); +function harness(feed) { + const calls = []; + const rendering = Object.fromEntries(['attach','start','stop','clear','destroy','setField'].map(name => [name, (...args) => calls.push([name, ...args])])); + const layer = createWindLayer({ feed, createRendering: () => rendering }); + layer.init({ container: {} }); layer.enable(); + return { layer, calls }; +} +test('forecast valid time and source age stay distinct', () => { + assert.equal(formatWindValidTime('invalid'), null); + assert.equal(formatWindValidTime('2026-01-05T06:30:00Z'), '2026-01-05 06:30 UTC'); + assert.equal(windStats(snapshot('gfs')).lastUpdate, Date.parse('2026-09-14T12:00:00Z')); +}); +test('switching models clears old data and ignores an abort-insensitive late source', async () => { + const pending = []; + const { layer, calls } = harness({ getSnapshot: args => new Promise(resolve => pending.push({ ...args, resolve })) }); + const first = layer.update(); + layer.setParams({ model: 'ifs' }); + await Promise.resolve(); + assert.equal(pending.length, 2); + assert.equal(pending[0].signal.aborted, true); + assert.equal(layer.getStats().count, 0); + assert.equal(layer.getStats().model, 'IFS'); + pending[0].resolve(snapshot('gfs')); + await first; + assert.equal(calls.filter(([name]) => name === 'setField').length, 0); + pending[1].resolve(snapshot('ifs')); + await Promise.resolve(); await Promise.resolve(); + assert.equal(calls.find(([name]) => name === 'setField')[1].model, 'ifs'); + assert.match(layer.getRowControls().info, /IFS forecast.*Valid: 2026-09-14 18:00 UTC.*Issued: 2026-09-14 12:00 UTC/); + layer.destroy(); +}); +test('external and owned cancellation both cancel source work; queued switches stop on disable', async () => { + let signal; + const { layer } = harness({ getSnapshot: args => { signal = args.signal; return new Promise(resolve => signal.addEventListener('abort', () => resolve(snapshot('gfs')), { once: true })); } }); + const external = new AbortController(); + const update = layer.update(null, { signal: external.signal }); + layer.disable(); + assert.equal(signal.aborted, true); assert.equal(external.signal.aborted, false); + assert.equal(await update, false); + layer.enable(); + const other = layer.update(null, { signal: external.signal }); + external.abort(); assert.equal(signal.aborted, true); await other; + layer.setParams({ model: 'ifs' }); layer.disable(); + await Promise.resolve(); assert.equal(layer.getStats().loading, false); + layer.destroy(); +}); diff --git a/src/layers/wind/model.js b/src/layers/wind/model.js index b7e5c8667..6f489e101 100644 --- a/src/layers/wind/model.js +++ b/src/layers/wind/model.js @@ -36,9 +36,9 @@ export function windSpeed(u, v) { return Math.hypot(u, v); } -/** Map wind magnitude to a fixed CSS colour ramp (bright from calm upward). */ +/** Map wind magnitude to a fixed CSS colour ramp (calm blue → strong red). */ export function windColor(speed, { max = 30 } = {}) { - const stops = ['#38bdf8', '#22d3ee', '#34d399', '#fbbf24', '#f87171']; + const stops = ['#1e3a8a', '#2563eb', '#22d3ee', '#34d399', '#fbbf24', '#f97316', '#ef4444']; const t = Math.max(0, Math.min(1, speed / max)); const position = t * (stops.length - 1); const index = Math.min(stops.length - 2, Math.floor(position)); diff --git a/src/layers/wind/source.js b/src/layers/wind/source.js index 847536b4c..2ec49812c 100644 --- a/src/layers/wind/source.js +++ b/src/layers/wind/source.js @@ -3,7 +3,8 @@ import { readWindBody } from '../../sources/windBody.js'; /** Create a bounded, cancellable source for the same-origin wind provider. */ export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args), timeoutMs = 45_000 } = {}) { return { - async getSnapshot({ signal } = {}) { + async getSnapshot({ signal, model = 'gfs' } = {}) { + if (!['gfs', 'ifs'].includes(model)) throw new Error('Unknown wind model'); const controller = new AbortController(); const abort = () => controller.abort(signal.reason); signal?.addEventListener('abort', abort, { once: true }); @@ -11,12 +12,12 @@ export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(... const active = controller.signal; try { signal?.throwIfAborted(); - const response = await fetchImpl('/api/wind/manifest', { signal: active, cache: 'no-store', redirect: 'error' }); + const response = await fetchImpl(`/api/wind/manifest?model=${model}`, { signal: active, cache: 'no-store', redirect: 'error' }); if (!response.ok) throw new Error(`Wind HTTP ${response.status}`); const manifest = JSON.parse(new TextDecoder().decode(await readWindBody(response, 16_384, active))); if (manifest?.unavailable) return manifest; const grid = manifest?.grid; - if (manifest?.model !== 'gfs' || !grid || !Number.isInteger(grid.nx) || !Number.isInteger(grid.ny) || grid.nx < 1 || grid.ny < 1 || grid.nx * grid.ny > 1_000_000 || ![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) || grid.dx <= 0 || grid.dy <= 0 || Math.abs(grid.nx * grid.dx - 360) > 0.01 || !/^\/api\/wind\/grid\/[\w.-]+\.bin$/.test(manifest.gridUrl)) + if (manifest?.model !== model || !grid || !Number.isInteger(grid.nx) || !Number.isInteger(grid.ny) || grid.nx < 1 || grid.ny < 1 || grid.nx * grid.ny > 1_000_000 || ![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) || grid.dx <= 0 || grid.dy <= 0 || Math.abs(grid.nx * grid.dx - 360) > 0.01 || !new RegExp(`^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?model=${model}$`).test(manifest.gridUrl)) throw new Error('Malformed wind manifest'); const count = grid.nx * grid.ny; const gridResponse = await fetchImpl(manifest.gridUrl, { signal: active, redirect: 'error' }); diff --git a/src/layers/wind/source.test.mjs b/src/layers/wind/source.test.mjs index 8d6188167..05e69fd69 100644 --- a/src/layers/wind/source.test.mjs +++ b/src/layers/wind/source.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createWindSource } from './source.js'; -const manifest = { model: 'gfs', grid: { nx: 2, ny: 1, lo1: 0, la1: 90, dx: 180, dy: 180 }, gridUrl: '/api/wind/grid/test.bin' }; +const manifest = { model: 'gfs', grid: { nx: 2, ny: 1, lo1: 0, la1: 90, dx: 180, dy: 180 }, gridUrl: '/api/wind/grid/gfs-test.bin?model=gfs' }; const response = (value) => new Response(typeof value === 'object' && !(value instanceof ArrayBuffer) ? JSON.stringify(value) : value); test('wind source splits a bounded field and preserves unavailable responses', async () => { const source = createWindSource({ fetchImpl: async (url) => response(url.includes('manifest') ? manifest : Float32Array.from([1,2,3,4]).buffer) });