Files
gods-eye-view/server/providers/wind/decode.js
T
Gustavo Beneduzi deb34ebe35 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.
2026-09-14 12:59:18 -03:00

64 lines
2.2 KiB
JavaScript

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(() => {});
}
}