mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(wind): scope grid ids and URLs to the model
The grid id and URL omitted the model, so two models sharing a cycle produced the same `/api/wind/grid/<id>.bin`. Because the grid route defaults to `gfs`, an IFS grid request resolved against the GFS cache: when both cycles matched it silently served the GFS field as IFS, and when they differed it 404'd and the client parsed the error body as float data. Include the model in both the id and the URL, and reject a resampled grid that contains non-finite values instead of caching it. Verified against live data: the IFS grid now decodes to 65,160 finite values (u -20.2..27.6 m/s) and renders a field distinct from GFS.
This commit is contained in:
1 parent
a2d38391e6
commit
8db53063a9
2 files changed
+162
-17
No files matched your search
+125
-16
@@ -2,47 +2,156 @@ import { fetchGfsWind } from './wind/gfs.js';
|
||||
import { fetchIfsWind } from './wind/ifs.js';
|
||||
import { decodeWindGribMessage } from './wind/decode.js';
|
||||
|
||||
/** 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 } } = {}) {
|
||||
/**
|
||||
* Return whether every value in a resampled grid is finite. A decode that
|
||||
* yields NaN/Infinity must never be cached or served as a valid wind field.
|
||||
* @param {{u: Float32Array, v: Float32Array}} grid
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function gridIsFinite(grid) {
|
||||
for (const array of [grid.u, grid.v]) {
|
||||
for (let i = 0; i < array.length; i += 1)
|
||||
if (!Number.isFinite(array[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* GFS/IFS 10 m wind proxy.
|
||||
*
|
||||
* Dispatches by `?model=gfs|ifs` (default gfs), caches each model's latest
|
||||
* cycle for an hour (single-flight, last-good on failure), and serves a
|
||||
* manifest plus a Float32 grid (U values then V values).
|
||||
*
|
||||
* Routes (mounted at `/api/wind`):
|
||||
* GET /api/wind/manifest?model=… → manifest JSON
|
||||
* GET /api/wind/grid/<id>.bin?model=… → Float32 U…V payload
|
||||
* GET /api/wind/status?model=… → manifest without `gridUrl`
|
||||
*
|
||||
* @param {{fetchImpl?: Function, now?: Function, decodeImpl?: Function,
|
||||
* targetDx?: number, ttlMs?: number, models?: object}} [options]
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
export function windProxy({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
targetDx = 1,
|
||||
ttlMs = 3600_000,
|
||||
models = { gfs: fetchGfsWind, ifs: fetchIfsWind },
|
||||
} = {}) {
|
||||
/** @type {Map<string, object>} */
|
||||
const caches = new Map();
|
||||
/** @type {Map<string, Promise<object>>} */
|
||||
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 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 value = await loader(options);
|
||||
const id = `${value.cycle.date}-${value.cycle.hour}`;
|
||||
const idGrid = `${id}-${targetDx}`;
|
||||
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 };
|
||||
if (!gridIsFinite(value.grid))
|
||||
throw new Error('wind grid contains non-finite values');
|
||||
// The model is part of both the id and the URL: two models can share a
|
||||
// cycle, and the grid route must resolve to the requesting model.
|
||||
const idGrid = `${model}-${value.cycle.date}-${value.cycle.hour}-${targetDx}`;
|
||||
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?model=${model}`,
|
||||
};
|
||||
const state = {
|
||||
idGrid,
|
||||
fetchedAt: manifest.fetchedAt,
|
||||
manifest,
|
||||
grid: value.grid,
|
||||
};
|
||||
caches.set(model, state);
|
||||
return state;
|
||||
} catch (error) {
|
||||
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); }
|
||||
if (old) {
|
||||
old.manifest = { ...old.manifest, stale: true, reason: error.message };
|
||||
return old;
|
||||
}
|
||||
return {
|
||||
idGrid: null,
|
||||
grid: null,
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
model,
|
||||
stale: true,
|
||||
unavailable: true,
|
||||
reason: error.message,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
loadings.delete(model);
|
||||
}
|
||||
})();
|
||||
loadings.set(model, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
const handler = async (req, res) => {
|
||||
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);
|
||||
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 === '/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' });
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,9 @@ const DECODED_U = {
|
||||
};
|
||||
const DECODED_V = { ...DECODED_U, values: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32] };
|
||||
|
||||
/** Resampled-grid metadata shared by the injected model fakes. */
|
||||
const GRID = { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 };
|
||||
|
||||
/** Fetch double: `.idx` text, and range bodies of the requested length. */
|
||||
function makeFetch({ fail = false, counter } = {}) {
|
||||
return async (url, options = {}) => {
|
||||
@@ -87,7 +90,7 @@ test('wind manifest describes the GFS cycle and resampled grid', async () => {
|
||||
assert.equal(body.cycle.date, '20260914');
|
||||
assert.equal(body.units, 'm/s');
|
||||
assert.deepEqual(body.grid, { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 });
|
||||
assert.match(body.gridUrl, /^\/api\/wind\/grid\/20260914-6-90\.bin$/);
|
||||
assert.match(body.gridUrl, /^\/api\/wind\/grid\/gfs-20260914-6-90\.bin\?model=gfs$/);
|
||||
assert.equal(body.stale, false);
|
||||
});
|
||||
|
||||
@@ -168,3 +171,36 @@ test('wind returns a JSON 404 for an unknown grid', async () => {
|
||||
assert.equal(res.statusCode, 404);
|
||||
assert.deepEqual(JSON.parse(res.body), { error: 'unknown_grid' });
|
||||
});
|
||||
|
||||
test('wind grid ids and URLs are model-scoped', async () => {
|
||||
const request = install(
|
||||
proxy({
|
||||
models: {
|
||||
gfs: async () => ({ cycle: { date: '20260914', hour: 6 }, level: 'x', units: 'm/s', grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) } }),
|
||||
ifs: async () => ({ cycle: { date: '20260914', hour: 0 }, level: 'x', units: 'm/s', grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) } }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const gfs = JSON.parse((await request('/')).body);
|
||||
const ifs = JSON.parse((await request('/?model=ifs')).body);
|
||||
assert.match(gfs.gridUrl, /gfs-20260914-6-90\.bin\?model=gfs$/);
|
||||
assert.match(ifs.gridUrl, /ifs-20260914-0-90\.bin\?model=ifs$/);
|
||||
// Each model's grid resolves through its own model parameter.
|
||||
assert.equal((await request(gfs.gridUrl.replace('/api/wind', ''))).statusCode, 200);
|
||||
assert.equal((await request(ifs.gridUrl.replace('/api/wind', ''))).statusCode, 200);
|
||||
});
|
||||
|
||||
test('wind rejects a non-finite grid instead of caching it', async () => {
|
||||
const bad = new Float32Array(12);
|
||||
bad[3] = Number.POSITIVE_INFINITY;
|
||||
const request = install(
|
||||
proxy({
|
||||
models: {
|
||||
gfs: async () => ({ cycle: { date: '20260914', hour: 6 }, level: 'x', units: 'm/s', grid: { ...GRID, u: bad, v: new Float32Array(12) } }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const body = JSON.parse((await request('/')).body);
|
||||
assert.equal(body.unavailable, true);
|
||||
assert.match(body.reason, /non-finite/);
|
||||
});
|
||||
Reference in new issue
Block a user