Files
gods-eye-view/server/providers/wind/gfs.js
T
Mazeyar Moeini FeizabadiandClaude Fable 5.2 ef6c30d4c3 perf(wind): decode forecast GRIB in a worker and reuse the cycle's wind
Each /api/wind refresh decoded two or three 0.25 degree GRIB2 messages with
ecCodes (WASM), validated ~1.04 M doubles per message and resampled them, all
synchronously on the server thread. Measured with cached upstream bytes: 43
to 164 ms of event-loop block per warm refresh, 230 to 381 ms for the first
one in a process, 316 to 974 ms when all six keys refresh together.

Decode, validation and resampling now run on one lazily started worker
thread, which transfers back only the display-grid samples. The server keeps
the sampled U/V of the last forecast object per model (1.04 MB each), so an
overlay change, or a refresh within the same forecast step, fetches and
decodes only the scalar. Manifests and grid bodies are byte-identical to
before for the same upstream bytes.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 17:15:20 +01:00

288 lines
8.3 KiB
JavaScript

import { readWindBody } from '../../../src/sources/windBody.js';
/**
* 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', overlay = 'none' } = {},
) {
weatherScalarMetadata(overlay);
const range = (variable, fieldLevel = level) => {
const index = parsed.messages.findIndex(
(message) =>
message.variable === variable && message.level === fieldLevel,
);
const message = parsed.messages[index];
const next = parsed.messages[index + 1];
if (!message || !next)
throw new Error(`missing ${variable} at ${fieldLevel}`);
return { start: message.offset, end: next.offset - 1 };
};
return {
u: range('UGRD'),
v: range('VGRD'),
...(overlay === 'temperature'
? { scalar: range('TMP', '2 m above ground') }
: {}),
...(overlay === 'pressure'
? { scalar: range('PRMSL', 'mean sea level') }
: {}),
};
}
/** Fetch a bounded text response as a Buffer. */
export async function fetchText({ url, fetchImpl = fetch, signal }) {
const response = await fetchImpl(url, { signal, redirect: 'error' });
if (!response.ok) {
await response.body?.cancel();
throw new Error('Wind upstream unavailable');
}
return Buffer.from(await readWindBody(response, 2 * 1024 * 1024, signal));
}
/**
* 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 (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start < 0 ||
end < start ||
end - start + 1 > maxBytes
)
throw new Error('range too large');
const response = await fetchImpl(url, {
signal,
redirect: 'error',
headers: { Range: `bytes=${start}-${end}` },
});
const validStatus =
response.status === 206 || (response.status === 200 && start === 0);
if (!validStatus || !response.ok) {
await response.body?.cancel();
throw new Error('Wind upstream unavailable');
}
const buffer = Buffer.from(
await readWindBody(response, end - start + 1, signal),
);
if (buffer.length !== end - start + 1)
throw new Error('invalid range length');
return buffer;
}
/** A bounded optional request may fail without cancelling valid wind siblings. */
export async function loadOptionalScalar({
range,
url,
fetchImpl,
decodeImpl,
signal,
targetDx = 1,
timeoutMs = 12_000,
}) {
if (!range) return undefined;
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
signal?.throwIfAborted();
const bytes = await fetchRange({
url,
...range,
fetchImpl,
signal: controller.signal,
});
// Sampling here keeps an unusable companion on the same wind-only path as
// an undecodable one. The decoder gets the signal so a timeout or a
// cancelled refresh stops waiting for a decode that runs off this thread.
const value = sampleWindField(
await decodeImpl(bytes, { targetDx, signal: controller.signal }),
targetDx,
);
controller.signal.throwIfAborted();
return value;
} catch {
return undefined;
} finally {
clearTimeout(timer);
controller.abort();
signal?.removeEventListener('abort', abort);
}
}
import {
GFS_BUCKET,
gfsObjectKey,
selectLatestGfsCycle,
nearestGfsStep,
} from './catalog.js';
import { decodeWindGribInWorker } from './decoder.js';
import {
resampleWeatherSnapshot,
sampleWindField,
weatherScalarMetadata,
} from './grid.js';
/**
* Load U, V and the optional companion from one forecast object and resample
* them to the display grid.
*
* `windCache` (a Map owned by one `windProxy`) retains the sampled U/V of the
* last object loaded per model. A later request for the same object and byte
* ranges, such as an overlay change or a refresh within the same forecast
* step, then fetches and decodes only the companion. It holds two Float64
* display grids per model (2 x 65,160 x 8 B = 1.04 MB at 1 degree) and is
* written only after the pair has passed every wind check.
*/
export async function loadWeatherSnapshot({
model,
url,
ranges,
scalarRange,
overlay,
targetDx,
fetchImpl,
decodeImpl,
signal,
windCache,
}) {
const key = [
url,
`${ranges.u.start}-${ranges.u.end}`,
`${ranges.v.start}-${ranges.v.end}`,
targetDx,
].join('|');
const kept = windCache?.get(model);
const wind = kept?.key === key ? kept : null;
// All selected messages come from this one issue/valid-time object.
const load = async (range) =>
sampleWindField(
await decodeImpl(await fetchRange({ url, ...range, fetchImpl, signal }), {
targetDx,
signal,
}),
targetDx,
);
const [u, v, scalar] = await Promise.all([
wind ? wind.u : load(ranges.u),
wind ? wind.v : load(ranges.v),
loadOptionalScalar({
range: scalarRange,
url,
fetchImpl,
decodeImpl,
signal,
targetDx,
}),
]);
signal?.throwIfAborted();
const fields = resampleWeatherSnapshot({ u, v, scalar, overlay, targetDx });
if (!wind) windCache?.set(model, { key, u, v });
return fields;
}
/** 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 = decodeWindGribInWorker,
signal,
overlay = 'none',
windCache,
} = {}) {
weatherScalarMetadata(overlay);
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 inventory = parseGfsIdx(index.toString());
const ranges = windMessageRanges(inventory);
let scalarRange;
if (overlay !== 'none') {
try {
scalarRange = windMessageRanges(inventory, { overlay }).scalar;
} catch {
/* A missing optional field does not discard valid wind. */
}
}
const fields = await loadWeatherSnapshot({
model: 'gfs',
url: base,
ranges,
scalarRange,
overlay,
targetDx,
fetchImpl,
decodeImpl,
signal,
windCache,
});
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',
...fields,
};
}