merge: integrate ECMWF IFS PR #464 with bounded model switching

This commit is contained in:
Bilawal Sidhu committed 2026-09-15 02:25:35 -05:00
commit 26354d5ae1
16 files changed
+444 -182

No files matched your search

+3
View File
@@ -118,6 +118,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased] ## [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. - 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. - 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 ## [0.1.0] — 2026-02-09
- Initial project version. - Initial project version.
+5
View File
@@ -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. (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. 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 ### NOAA GFS wind
The optional **Wind** layer animates the global 10 m wind field from NOAA's 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 ## 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. 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.
+2
View File
@@ -3624,3 +3624,5 @@ releases imagery layers/listeners on replacement. Destroy invalidates pending
work and releases owned resources, including late factory results. Supplied 3D work and releases owned resources, including late factory results. Supplied 3D
tilesets remain owned by the caller; tilesets created through the controller's tilesets remain owned by the caller; tilesets created through the controller's
factory are added to its viewer and removed on destruction. 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.
+76 -170
View File
@@ -1,193 +1,99 @@
import { import { fetchGfsWind } from './wind/gfs.js';
GFS_BUCKET, import { fetchIfsWind } from './wind/ifs.js';
gfsObjectKey,
selectLatestGfsCycle,
} from './wind/catalog.js';
import {
fetchText,
fetchRange,
parseGfsIdx,
windMessageRanges,
} from './wind/gfs.js';
import { decodeWindGribMessage } from './wind/decode.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/<id>.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<object>} */
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) => { const sendJson = (res, value, status = 200) => {
res.writeHead(status, { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify(value)); res.end(JSON.stringify(value));
}; };
function refresh(model) {
const cycleRunIso = (cycle) => const controller = new AbortController();
`${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z`; const operation = { controller, waiters: 0, promise: null };
loadings.set(model, operation);
async function refresh() { attempts.set(model, now());
const cycle = selectLatestGfsCycle(now()); operation.promise = (async () => {
const id = `${cycle.date}-${cycle.hour}`; const timer = setTimeout(() => controller.abort(), timeoutMs);
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 () => {
try { try {
const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`; const value = await models[model]({ fetchImpl, now, decodeImpl, targetDx, signal: controller.signal });
const index = await fetchText({ url: `${base}.idx`, fetchImpl, signal }); controller.signal.throwIfAborted();
const ranges = windMessageRanges(parseGfsIdx(index.toString())); const { grid, cycle } = value;
const [uBuffer, vBuffer] = await Promise.all([ if (!grid.u.every(Number.isFinite) || !grid.v.every(Number.isFinite)) throw new Error('Invalid wind grid');
fetchRange({ url: base, ...ranges.u, fetchImpl, signal }), const id = `${model}-${cycle.date}-${cycle.hour}-f${cycle.forecastHour || 0}-${targetDx}`;
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 manifest = { const manifest = {
schemaVersion: 1, schemaVersion: 1, model, cycle, fetchedAt: now(), level: value.level, units: value.units,
model: 'gfs', grid: { nx: grid.nx, ny: grid.ny, lo1: grid.lo1, la1: grid.la1, dx: grid.dx, dy: grid.dy },
cycle: { ...cycle, forecastHour: 0, runIso: cycleRunIso(cycle), validIso: cycleRunIso(cycle) }, stale: false, unavailable: false, reason: null,
fetchedAt: now(), gridUrl: `/api/wind/grid/${id}.bin?model=${model}`,
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`,
}; };
cached = { id, idGrid, fetchedAt: manifest.fetchedAt, manifest, grid }; const state = { id, grid, manifest, fetchedAt: now() };
return cached; caches.set(model, state);
} catch (error) { // Retain the previous issued grid as well to cover a manifest/grid rollover.
if (cached) { const history = grids.get(model) || new Map();
cached = { history.set(id, state);
...cached, while (history.size > 2) history.delete(history.keys().next().value);
manifest: { ...cached.manifest, stale: true, reason: 'Wind upstream unavailable' }, grids.set(model, history);
}; return state;
} else { } catch {
cached = { if (controller.signal.aborted && operation.waiters === 0) {
id: null, attempts.delete(model);
idGrid: null, return unavailable(model);
fetchedAt: now(),
grid: null,
manifest: {
schemaVersion: 1,
model: 'gfs',
stale: true,
unavailable: true,
reason: 'Wind upstream unavailable',
},
};
} }
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 { } finally {
clearTimeout(timer); clearTimeout(timer);
loading = null; controller.abort(); // Cancel a sibling request if the other component failed.
controller = null; if (loadings.get(model) === operation) loadings.delete(model);
} }
})(); })();
return loading; return operation;
} }
const handler = async (req, res) => { 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 (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); if (!['gfs', 'ifs'].includes(model) || !Object.hasOwn(models, model)) return sendJson(res, { error: 'unknown_model' }, 400);
// Grid reads never initiate acquisition; an issued manifest owns its cached grid. if (url.pathname.startsWith('/grid/')) {
if (path.startsWith('/grid/') && (!cached?.grid || path !== `/grid/${cached.idGrid}.bin`)) return sendJson(res, { error: 'unknown_grid' }, 404); const id = url.pathname.slice(6).replace(/\.bin$/, '');
let disconnected = false; const state = url.pathname === `/grid/${id}.bin` && grids.get(model)?.get(id);
const close = () => { disconnected = true; waiters -= 1; if (waiters === 0) controller?.abort(); }; if (!state) return sendJson(res, { error: 'unknown_grid' }, 404);
waiters += 1; 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.once?.('close', close); res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'public, max-age=3600, immutable' });
let state; return res.end(bytes);
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 (path.startsWith('/grid/')) { if (!['/', '/manifest', '/status'].includes(url.pathname)) return sendJson(res, { error: 'not_found' }, 404);
if (!state.grid || path !== `/grid/${state.idGrid}.bin`) let state = caches.get(model);
return sendJson(res, { error: 'unknown_grid' }, 404); if (!state || now() - state.fetchedAt >= ttlMs) {
const payload = Buffer.concat([ let operation = loadings.get(model);
Buffer.from(state.grid.u.buffer, state.grid.u.byteOffset, state.grid.u.byteLength), if (!operation && now() - (attempts.get(model) ?? -Infinity) >= 60_000) operation = refresh(model);
Buffer.from(state.grid.v.buffer, state.grid.v.byteOffset, state.grid.v.byteLength), if (operation) {
]); let disconnected = false;
res.writeHead(200, { operation.waiters += 1;
'Content-Type': 'application/octet-stream', const close = () => { disconnected = true; if (--operation.waiters === 0) operation.controller.abort(); };
'Cache-Control': 'public, max-age=3600, immutable', res.once?.('close', close);
}); try { state = await operation.promise; }
return res.end(payload); 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 { return {
name: 'wind', name: 'wind',
configureServer({ middlewares }) { configureServer({ middlewares }) { middlewares.use('/api/wind', handler); },
middlewares.use('/api/wind', handler); configurePreviewServer({ middlewares }) { middlewares.use('/api/wind', handler); },
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/wind', handler);
},
}; };
} }
+13
View File
@@ -14,3 +14,16 @@ export function selectLatestGfsCycle(nowMs, { availabilityLagMs = 5 * 3600_000 }
date.setUTCHours(hour); date.setUTCHours(hour);
return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), 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));
}
+31
View File
@@ -88,3 +88,34 @@ export async function fetchRange({
throw new Error('invalid range length'); throw new Error('invalid range length');
return buffer; 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 };
}
+146
View File
@@ -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 `<stem>.index`, NOT `<grib2>.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,
};
}
+7
View File
@@ -204,6 +204,13 @@ export const DATA_CREDITS = [
'<a href="https://registry.opendata.aws/noaa-gfs-bdp-pds/" target="_blank" rel="noopener">NOAA Open Data on AWS</a> ' + '<a href="https://registry.opendata.aws/noaa-gfs-bdp-pds/" target="_blank" rel="noopener">NOAA Open Data on AWS</a> ' +
'(U.S. public domain; forecast, not observations)', '(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/ ' +
'(<a href="https://www.ecmwf.int/en/forecasts/datasets/open-data" target="_blank" rel="noopener">ECMWF Open Data</a>)',
},
// ── Bundled snapshots ─────────────────────────────────────────── // ── Bundled snapshots ───────────────────────────────────────────
{ {
key: 'datacenters', key: 'datacenters',
+4 -1
View File
@@ -182,6 +182,9 @@ function integerOption(key, token, defaultValue) {
} }
const OPTION_GROUPS = Object.freeze({ const OPTION_GROUPS = Object.freeze({
wind: Object.freeze([
enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }),
]),
flights: Object.freeze([ flights: Object.freeze([
// Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in // 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 // 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: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }),
Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }), Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }),
Object.freeze({ id: 'traffic', token: 't', 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)); export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id));
+12
View File
@@ -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);
});
+66 -1
View File
@@ -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] }; 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. */ /** Fetch double: `.idx` text, and range bodies of the requested length. */
function makeFetch({ fail = false, counter } = {}) { function makeFetch({ fail = false, counter } = {}) {
return async (url, options = {}) => { 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.cycle.date, '20260914');
assert.equal(body.units, 'm/s'); assert.equal(body.units, 'm/s');
assert.deepEqual(body.grid, { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 }); 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); 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.equal(res.statusCode, 404);
assert.deepEqual(JSON.parse(res.body), { error: 'unknown_grid' }); 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);
});
+21 -4
View File
@@ -27,10 +27,12 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender
let manifest = null; let manifest = null;
let error = null; let error = null;
let loading = false; let loading = false;
let model = 'gfs';
let generation = 0;
let rowControlsListener = null; let rowControlsListener = null;
const notify = () => rowControlsListener?.(); const notify = () => rowControlsListener?.();
const layer = { 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) { init(nextViewer) {
viewer = nextViewer; viewer = nextViewer;
rendering = createRendering({ cesium, container: container ?? nextViewer.container, getViewer: () => viewer }); 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(); }, enable() { enabled = true; rendering?.start(); },
disable() { disable() {
enabled = false; enabled = false;
generation += 1;
request?.abort(); request = null; loading = false; request?.abort(); request = null; loading = false;
rendering?.stop(); rendering?.clear(); rendering?.stop(); rendering?.clear();
}, },
@@ -49,11 +52,12 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender
const abort = () => controller.abort(signal.reason); const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true }); signal?.addEventListener('abort', abort, { once: true });
request = controller; request = controller;
generation += 1;
loading = true; loading = true;
notify(); notify();
try { try {
signal?.throwIfAborted(); 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; if (!enabled || controller.signal.aborted || request !== controller) return false;
manifest = snapshot; manifest = snapshot;
error = null; error = null;
@@ -69,14 +73,27 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender
if (request === controller) { request = null; loading = false; notify(); } 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() { getRowControls() {
const valid = formatWindValidTime(manifest?.cycle?.validIso); const valid = formatWindValidTime(manifest?.cycle?.validIso);
const run = formatWindValidTime(manifest?.cycle?.runIso); 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; }, setRowControlsListener(listener) { rowControlsListener = typeof listener === 'function' ? listener : null; },
destroy() { layer.disable(); rendering?.destroy(); rendering = null; viewer = null; rowControlsListener = 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; }, getParticleCount() { return rendering?.getParticleCount() || 0; },
}; };
return layer; return layer;
+51
View File
@@ -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();
});
+2 -2
View File
@@ -36,9 +36,9 @@ export function windSpeed(u, v) {
return Math.hypot(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 } = {}) { 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 t = Math.max(0, Math.min(1, speed / max));
const position = t * (stops.length - 1); const position = t * (stops.length - 1);
const index = Math.min(stops.length - 2, Math.floor(position)); const index = Math.min(stops.length - 2, Math.floor(position));
+4 -3
View File
@@ -3,7 +3,8 @@ import { readWindBody } from '../../sources/windBody.js';
/** Create a bounded, cancellable source for the same-origin wind provider. */ /** Create a bounded, cancellable source for the same-origin wind provider. */
export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args), timeoutMs = 45_000 } = {}) { export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args), timeoutMs = 45_000 } = {}) {
return { 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 controller = new AbortController();
const abort = () => controller.abort(signal.reason); const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true }); signal?.addEventListener('abort', abort, { once: true });
@@ -11,12 +12,12 @@ export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...
const active = controller.signal; const active = controller.signal;
try { try {
signal?.throwIfAborted(); 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}`); if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
const manifest = JSON.parse(new TextDecoder().decode(await readWindBody(response, 16_384, active))); const manifest = JSON.parse(new TextDecoder().decode(await readWindBody(response, 16_384, active)));
if (manifest?.unavailable) return manifest; if (manifest?.unavailable) return manifest;
const grid = manifest?.grid; 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'); throw new Error('Malformed wind manifest');
const count = grid.nx * grid.ny; const count = grid.nx * grid.ny;
const gridResponse = await fetchImpl(manifest.gridUrl, { signal: active, redirect: 'error' }); const gridResponse = await fetchImpl(manifest.gridUrl, { signal: active, redirect: 'error' });
+1 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createWindSource } from './source.js'; 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); 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 () => { 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) }); const source = createWindSource({ fetchImpl: async (url) => response(url.includes('manifest') ? manifest : Float32Array.from([1,2,3,4]).buffer) });