mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
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]>
This commit is contained in:
1 parent
140f7ebd21
commit
ef6c30d4c3
9 files changed
+888
-81
No files matched your search
+127
-35
@@ -48,38 +48,133 @@ export function resampleWindGrid({
|
||||
if (!Array.prototype.every.call(values, Number.isFinite))
|
||||
throw new Error('Invalid wind grid values');
|
||||
}
|
||||
const geometry = { ni, nj, lo1, la1, di, dj };
|
||||
const sampled = (values) => ({
|
||||
...geometry,
|
||||
dx,
|
||||
dy,
|
||||
samples: sampleValues(values, geometry, dx, dy),
|
||||
});
|
||||
return composeWindGrid({
|
||||
u: sampled(u),
|
||||
v: sampled(v),
|
||||
...(scalar === undefined ? {} : { scalar: sampled(scalar) }),
|
||||
scalarScale,
|
||||
scalarOffset,
|
||||
});
|
||||
}
|
||||
|
||||
/** Display-grid shape for a target spacing, under the point budget. */
|
||||
function displayShape(dx, dy) {
|
||||
const nx = Math.round(360 / dx);
|
||||
const ny = Math.round(180 / dy) + 1;
|
||||
if (nx * ny > 1_000_000)
|
||||
throw new Error('Resampled wind grid exceeds point budget');
|
||||
const outU = new Float32Array(nx * ny);
|
||||
const outV = new Float32Array(nx * ny);
|
||||
const outScalar = scalar === undefined ? null : 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;
|
||||
};
|
||||
return { nx, ny };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bilinearly sample one validated source field onto the display grid. The
|
||||
* result stays in doubles so a caller can scale it before narrowing to
|
||||
* Float32, exactly as if it had sampled, scaled and stored in one step.
|
||||
*/
|
||||
function sampleValues(source, { ni, nj, lo1, la1, di, dj }, dx, dy) {
|
||||
const { nx, ny } = displayShape(dx, dy);
|
||||
const samples = new Float64Array(nx * ny);
|
||||
for (let y = 0; y < ny; y += 1) {
|
||||
const lat = 90 - y * dy;
|
||||
const sy = Math.max(0, Math.min(nj - 1, (la1 - lat) / dj));
|
||||
const y0 = Math.floor(sy);
|
||||
const y1 = Math.min(nj - 1, y0 + 1);
|
||||
const fy = sy - y0;
|
||||
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);
|
||||
if (outScalar)
|
||||
outScalar[index] =
|
||||
sample(scalar, lon, lat) * scalarScale + scalarOffset;
|
||||
const sx = ((lon - lo1) / di + ni) % ni;
|
||||
const x0 = Math.floor(sx);
|
||||
const x1 = (x0 + 1) % ni;
|
||||
const fx = sx - x0;
|
||||
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;
|
||||
samples[y * nx + x] = top * (1 - fy) + bottom * fy;
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one decoded GRIB field and reduce it to display-grid samples.
|
||||
*
|
||||
* This is the expensive per-message step after the decode (a 0.25° message is
|
||||
* ~1.04 M doubles to check), so the decode worker runs it and transfers only
|
||||
* the result. A field that already carries samples for `targetDx` passes
|
||||
* through, which lets the same call normalise worker output, injected decoders
|
||||
* and the retained wind of the current cycle.
|
||||
*
|
||||
* @param {object} field - `decodeWindGribMessage` output, or a sampled field.
|
||||
* @param {number} [targetDx] - Display-grid spacing in degrees.
|
||||
* @returns {{ni: number, nj: number, lo1: number, la1: number, di: number,
|
||||
* dj: number, units: (string|undefined), dx: number, dy: number,
|
||||
* samples: Float64Array}}
|
||||
*/
|
||||
export function sampleWindField(field, targetDx = 1) {
|
||||
if (!field) throw new Error('Mismatched weather grid geometry');
|
||||
if (
|
||||
field.samples instanceof Float64Array &&
|
||||
field.dx === targetDx &&
|
||||
field.dy === targetDx
|
||||
)
|
||||
return field;
|
||||
const { ni, nj, lo1, la1, di, dj, values, units } = field;
|
||||
if (
|
||||
!Number.isInteger(ni) ||
|
||||
!Number.isInteger(nj) ||
|
||||
ni < 1 ||
|
||||
nj < 1 ||
|
||||
ni * nj > 2_000_000 ||
|
||||
values?.length !== ni * nj ||
|
||||
![lo1, la1, di, dj, targetDx].every(Number.isFinite) ||
|
||||
di <= 0 ||
|
||||
dj <= 0 ||
|
||||
targetDx < 0.25 ||
|
||||
targetDx > 180
|
||||
)
|
||||
throw new Error('Invalid wind grid geometry');
|
||||
if (!Array.prototype.every.call(values, Number.isFinite))
|
||||
throw new Error('Invalid wind grid values');
|
||||
const geometry = { ni, nj, lo1, la1, di, dj };
|
||||
return {
|
||||
...geometry,
|
||||
units,
|
||||
dx: targetDx,
|
||||
dy: targetDx,
|
||||
samples: sampleValues(values, geometry, targetDx, targetDx),
|
||||
};
|
||||
}
|
||||
|
||||
/** Narrow sampled components that share one display grid to Float32. */
|
||||
function composeWindGrid({ u, v, scalar, scalarScale = 1, scalarOffset = 0 }) {
|
||||
const { dx, dy } = u;
|
||||
const { nx, ny } = displayShape(dx, dy);
|
||||
const components = [u, v, ...(scalar ? [scalar] : [])];
|
||||
if (
|
||||
components.some(
|
||||
(field) =>
|
||||
field.dx !== dx ||
|
||||
field.dy !== dy ||
|
||||
!(field.samples instanceof Float64Array) ||
|
||||
field.samples.length !== nx * ny,
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid wind grid geometry');
|
||||
const outU = new Float32Array(u.samples);
|
||||
const outV = new Float32Array(v.samples);
|
||||
let outScalar = null;
|
||||
if (scalar) {
|
||||
outScalar = new Float32Array(nx * ny);
|
||||
for (let index = 0; index < outScalar.length; index += 1)
|
||||
outScalar[index] = scalar.samples[index] * scalarScale + scalarOffset;
|
||||
}
|
||||
for (const values of [outU, outV, ...(outScalar ? [outScalar] : [])]) {
|
||||
if (!values.every(Number.isFinite))
|
||||
throw new Error('Invalid resampled wind values');
|
||||
@@ -130,20 +225,17 @@ export function resampleWeatherFields({
|
||||
throw new Error('Mismatched weather grid geometry');
|
||||
if (metadata && scalar.units !== (overlay === 'temperature' ? 'K' : 'Pa'))
|
||||
throw new Error('Unexpected weather scalar units');
|
||||
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,
|
||||
// Raw fields are validated and sampled here; worker output and the retained
|
||||
// wind of this cycle already are, so only the cheap narrowing is left.
|
||||
const [sampledU, sampledV, sampledScalar] = fields.map((field) =>
|
||||
sampleWindField(field, targetDx),
|
||||
);
|
||||
const grid = composeWindGrid({
|
||||
u: sampledU,
|
||||
v: sampledV,
|
||||
...(metadata
|
||||
? {
|
||||
scalar: scalar.values,
|
||||
scalar: sampledScalar,
|
||||
scalarScale: overlay === 'pressure' ? 0.01 : 1,
|
||||
scalarOffset: overlay === 'temperature' ? -273.15 : 0,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user