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]
- 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.
+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.
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.
+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
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.
+76 -170
View File
@@ -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/<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) => {
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); },
};
}
+13
View File
@@ -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));
}
+31
View File
@@ -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 };
}
+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> ' +
'(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 ───────────────────────────────────────────
{
key: 'datacenters',
+4 -1
View File
@@ -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));
+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] };
/** 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);
});
+21 -4
View File
@@ -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;
+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);
}
/** 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));
+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. */
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' });
+1 -1
View File
@@ -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) });