feat(wind): add keyless NOAA GFS wind layer with animated particles

Add a Wind layer animating the global 10 m wind field from NOAA GFS.

The /api/wind proxy selects the latest available 0.25° cycle, reads its .idx
inventory, byte-range fetches only the UGRD/VGRD 10 m GRIB2 messages (~1 MB
each instead of the whole ~500 MB file), decodes them with ecCodes (WASM),
resamples to a compact grid (1° default), and serves a manifest plus a Float32
U/V payload cached per cycle for an hour (single-flight, last-good on failure).

The client renders nullschool-style particles in a canvas overlay: bilinear
field sampling with longitude wrap, per-frame advection, Cesium camera
projection, and globe-occlusion culling via EllipsoidalOccluder.

Validated end-to-end against the real 2026-09-14 06z cycle on Node 25
(180x91 grid at 2°, 131,040-byte payload, plausible mean |u|/|v|). The WASM
decoder requires Node >=24, which the repo already requires.

Registered as `wind` (token k) with attribution and docs. Verified: npm test
(3412 pass, 0 fail), npm run check:boundaries.
This commit is contained in:
Gustavo Beneduzi committed 2026-09-14 12:59:18 -03:00
1 parent 844c25212c
commit deb34ebe35
28 files changed
+1122 -3

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
export const GFS_BUCKET = 'noaa-gfs-bdp-pds';
/** Build the public GFS object key. */
export function gfsObjectKey({ date, hour, forecastHour = 0 }) {
const hh = String(hour).padStart(2, '0');
return `gfs.${date}/${hh}/atmos/gfs.t${hh}z.pgrb2.0p25.f${String(forecastHour).padStart(3, '0')}`;
}
/** Select the latest GFS cycle that should be available. */
export function selectLatestGfsCycle(nowMs, { availabilityLagMs = 5 * 3600_000 } = {}) {
const d = new Date(nowMs - availabilityLagMs);
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const hour = Math.floor(d.getUTCHours() / 6) * 6;
date.setUTCHours(hour);
return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), hour };
}
+63
View File
@@ -0,0 +1,63 @@
import os from 'node:os';
import path from 'node:path';
import { promises as fs } from 'node:fs';
import { randomUUID } from 'node:crypto';
/**
* Lazily created ecCodes WASM instance. The `@meri-imperiumi/eccodes-wasm`
* module is only imported on first use so unit tests can inject a fake
* decoder without loading the WASM (which requires Node >= 24).
* @type {?Promise<object>}
*/
let eccodesPromise = null;
/** @returns {Promise<object>} The shared ecCodes WASM instance. */
function loadEccodes() {
eccodesPromise ??= (async () => {
const module = await import('@meri-imperiumi/eccodes-wasm');
return module.createEccodes();
})();
return eccodesPromise;
}
/**
* Decode one GRIB2 wind message into its grid metadata and values.
*
* The WASM decoder reads from its own mounted filesystem, so the message is
* written to a temp file under `mountDir` and removed in `finally`.
*
* @param {Buffer} buffer - One GRIB2 message (already byte-range extracted).
* @param {{ eccodesModule?: object, mountDir?: string }} [options]
* @returns {Promise<{ni: number, nj: number, lo1: number, la1: number,
* di: number, dj: number, values: ArrayLike<number>, shortName: string,
* level: number, units: string}>}
*/
export async function decodeWindGribMessage(
buffer,
{ eccodesModule, mountDir = os.tmpdir() } = {},
) {
const eccodes = eccodesModule ?? (await loadEccodes());
const name = `wind-${randomUUID()}.grib2`;
const file = path.join(mountDir, name);
await fs.writeFile(file, buffer);
let handle = null;
try {
eccodes.mountFilesystem(mountDir);
handle = eccodes.openGrib(file);
return {
ni: handle.getLong('Ni'),
nj: handle.getLong('Nj'),
lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'),
la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'),
di: handle.getDouble('iDirectionIncrementInDegrees'),
dj: handle.getDouble('jDirectionIncrementInDegrees'),
values: handle.getDoubleArray('values'),
shortName: handle.getString('shortName'),
level: handle.getLong('level'),
units: handle.getString('units'),
};
} finally {
if (handle) handle.delete();
await fs.unlink(file).catch(() => {});
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Parse a GFS `.idx` inventory into ordered message records.
*
* Each line is `msgNumber:byteOffset:d=YYYYMMDDHH:VAR:LEVEL:...:`. Malformed
* lines are skipped; a payload with no valid lines is rejected so callers never
* treat an HTML error page as an empty inventory.
*
* @param {string} text - Raw `.idx` body.
* @returns {{ messages: Array<{index: number, offset: number, variable: string, level: string}> }}
*/
export function parseGfsIdx(text) {
const messages = [];
for (const line of String(text).split(/\r?\n/)) {
if (!line) continue;
const parts = line.split(':');
if (
parts.length < 6 ||
!/^\d+$/.test(parts[0]) ||
!/^\d+$/.test(parts[1]) ||
!parts[3] ||
!parts[4]
)
continue;
messages.push({
index: Number(parts[0]),
offset: Number(parts[1]),
variable: parts[3],
level: parts[4],
});
}
if (!messages.length) throw new Error('malformed GFS index');
return { messages };
}
/**
* Byte ranges for the two 10 m wind components. A message runs from its own
* offset to one byte before the next message in file order.
*
* @param {{messages: Array<object>}} parsed - {@link parseGfsIdx} result.
* @param {{level?: string}} [options]
* @returns {{u: {start: number, end: number}, v: {start: number, end: number}}}
*/
export function windMessageRanges(parsed, { level = '10 m above ground' } = {}) {
const range = (variable) => {
const index = parsed.messages.findIndex(
(message) => message.variable === variable && message.level === level,
);
const message = parsed.messages[index];
const next = parsed.messages[index + 1];
if (!message || !next) throw new Error(`missing ${variable} at ${level}`);
return { start: message.offset, end: next.offset - 1 };
};
return { u: range('UGRD'), v: range('VGRD') };
}
/** Fetch a bounded text response as a Buffer. */
export async function fetchText({ url, fetchImpl = fetch, signal }) {
const response = await fetchImpl(url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
/**
* Fetch one bounded byte range. Rejects oversized ranges, non-206 responses
* (except a whole-object 200 starting at zero), and short bodies.
*/
export async function fetchRange({
url,
start,
end,
fetchImpl = fetch,
signal,
maxBytes = 8 * 1024 * 1024,
}) {
if (end < start || end - start + 1 > maxBytes)
throw new Error('range too large');
const response = await fetchImpl(url, {
signal,
headers: { Range: `bytes=${start}-${end}` },
});
const validStatus =
response.status === 206 || (response.status === 200 && start === 0);
if (!validStatus || !response.ok)
throw new Error(`invalid range response ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length !== end - start + 1)
throw new Error('invalid range length');
return buffer;
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Resample a GRIB wind grid into a compact equirectangular grid.
*
* Source grids are north-to-south (`la1` at the top, `dj` increasing toward the
* south) and start at `lo1`. Longitude wraps across the seam; latitude clamps.
*
* @param {{u: ArrayLike<number>, v: ArrayLike<number>, ni: number, nj: number,
* lo1: number, la1: number, di: number, dj: number, dx?: number, dy?: number}} input
* @returns {{nx: number, ny: number, lo1: number, la1: number, dx: number,
* dy: number, u: Float32Array, v: Float32Array}}
*/
export function resampleWindGrid({
u,
v,
ni,
nj,
lo1,
la1,
di,
dj,
dx = 1,
dy = 1,
}) {
const nx = Math.round(360 / dx);
const ny = Math.round(180 / dy) + 1;
const outU = new Float32Array(nx * ny);
const outV = new Float32Array(nx * ny);
const sample = (source, lon, lat) => {
const x = ((lon - lo1) / di + ni) % ni;
const y = Math.max(0, Math.min(nj - 1, (la1 - lat) / dj));
const x0 = Math.floor(x);
const x1 = (x0 + 1) % ni;
const y0 = Math.floor(y);
const y1 = Math.min(nj - 1, y0 + 1);
const fx = x - x0;
const fy = y - y0;
const top = source[y0 * ni + x0] * (1 - fx) + source[y0 * ni + x1] * fx;
const bottom = source[y1 * ni + x0] * (1 - fx) + source[y1 * ni + x1] * fx;
return top * (1 - fy) + bottom * fy;
};
for (let y = 0; y < ny; y += 1) {
for (let x = 0; x < nx; x += 1) {
const index = y * nx + x;
const lon = x * dx;
const lat = 90 - y * dy;
outU[index] = sample(u, lon, lat);
outV[index] = sample(v, lon, lat);
}
}
return { nx, ny, lo1: 0, la1: 90, dx, dy, u: outU, v: outV };
}