feat(wind): add ECMWF IFS as a second wind model

Add ECMWF IFS 10 m wind alongside NOAA GFS, selectable at runtime.

The /api/wind proxy becomes a model dispatcher: `?model=gfs|ifs` (default gfs,
unknown → 400), cached per model+cycle+grid. IFS reads the keyless ECMWF Open
Data JSON Lines `.index` (note: `<stem>.index`, not `<grib2>.index`), byte-range
fetches only the `10u`/`10v` messages, decodes them with the same ecCodes WASM
path, and resamples with the existing grid code (ECMWF grids start at lo1=180;
the resampler already wraps columns).

The client layer gains a `model` parameter (setParams/getParams) with a
generation guard so a model switch aborts and refetches exactly once. Registered
as an enabled+options layer with a `model` enum.

Validated end-to-end against the real 2026-09-14 06z IFS cycle on Node 25
(180x91 grid at 2°, 131,040-byte payload, plausible mean |u|/|v|), and GFS
still passes the same check.

Attribution: contains modified Copernicus/ECMWF IFS forecast data, licensed
under CC BY 4.0. Verified: npm test (3412 pass, 0 fail), check:boundaries.
This commit is contained in:
Gustavo Beneduzi committed 2026-09-14 13:04:35 -03:00
1 parent deb34ebe35
commit bb18e39b7d
10 files changed
+216 -166

No files matched your search

+36 -158
View File
@@ -1,170 +1,48 @@
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;
const sendJson = (res, value, status = 200) => {
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;
loading = (async () => {
/** Dispatch cached wind requests by model and cycle. */
export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImpl = decodeWindGribMessage, targetDx = 1, ttlMs = 3600_000, models = { gfs: fetchGfsWind, ifs: fetchIfsWind } } = {}) {
const caches = new Map();
const loadings = new Map();
const sendJson = (res, value, status = 200) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(value)); };
const refresh = (model) => {
const loader = models[model];
const options = { fetchImpl, now, decodeImpl, targetDx };
const promise = (async () => {
try {
const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`;
const index = await fetchText({ url: `${base}.idx`, fetchImpl });
const ranges = windMessageRanges(parseGfsIdx(index.toString()));
const [uBuffer, vBuffer] = await Promise.all([
fetchRange({ url: base, ...ranges.u, fetchImpl }),
fetchRange({ url: base, ...ranges.v, fetchImpl }),
]);
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 value = await loader(options);
const id = `${value.cycle.date}-${value.cycle.hour}`;
const idGrid = `${id}-${targetDx}`;
const manifest = {
schemaVersion: 1,
model: 'gfs',
cycle: { ...cycle, forecastHour: 0, runIso: 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`,
};
cached = { id, idGrid, fetchedAt: manifest.fetchedAt, manifest, grid };
return cached;
const manifest = { schemaVersion: 1, model, cycle: value.cycle, fetchedAt: now(), level: value.level, units: value.units, grid: { nx: value.grid.nx, ny: value.grid.ny, lo1: value.grid.lo1, la1: value.grid.la1, dx: value.grid.dx, dy: value.grid.dy }, stale: false, unavailable: false, reason: null, gridUrl: `/api/wind/grid/${idGrid}.bin` };
const state = { id, idGrid, fetchedAt: manifest.fetchedAt, manifest, grid: value.grid };
caches.set(model, state);
return state;
} catch (error) {
if (cached) {
cached = {
...cached,
manifest: { ...cached.manifest, stale: true, reason: error.message },
};
} else {
cached = {
id: null,
idGrid: null,
fetchedAt: now(),
grid: null,
manifest: {
schemaVersion: 1,
model: 'gfs',
stale: true,
unavailable: true,
reason: error.message,
},
};
}
return cached;
} finally {
loading = null;
}
const old = caches.get(model);
if (old) { old.manifest = { ...old.manifest, stale: true, reason: error.message }; return old; }
return { id: null, idGrid: null, grid: null, manifest: { schemaVersion: 1, model, stale: true, unavailable: true, reason: error.message } };
} finally { loadings.delete(model); }
})();
return loading;
}
loadings.set(model, promise);
return promise;
};
const handler = async (req, res) => {
const path = new URL(req.url, 'http://localhost').pathname;
const state = await refresh();
if (path === '/status') {
const { gridUrl, ...manifest } = state.manifest;
return sendJson(res, manifest);
}
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',
});
const url = new URL(req.url, 'http://localhost');
const model = url.searchParams.get('model') || 'gfs';
if (!models[model]) return sendJson(res, { error: 'unknown_model' }, 400);
let state = caches.get(model);
if (!state || now() - state.fetchedAt >= ttlMs) state = loadings.get(model) || refresh(model);
state = await state;
if (url.pathname === '/status') { const { gridUrl, ...manifest } = state.manifest; return sendJson(res, manifest); }
if (url.pathname.startsWith('/grid/')) {
if (!state.grid || url.pathname !== `/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);
}
return sendJson(res, state.manifest);
};
return {
name: 'wind',
configureServer({ middlewares }) {
middlewares.use('/api/wind', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/wind', handler);
},
};
return { name: 'wind', configureServer({ middlewares }) { middlewares.use('/api/wind', handler); }, configurePreviewServer({ middlewares }) { middlewares.use('/api/wind', handler); } };
}