mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
merge: integrate ECMWF IFS PR #464 with bounded model switching
This commit is contained in:
commit
26354d5ae1
16 files changed
+444
-182
No files matched your search
@@ -118,6 +118,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps.
|
||||
|
||||
- Give application request services, terrain/floor caches and annotation lookup state explicit owners and cancellation; share them across controls, layers and voice.
|
||||
|
||||
- Construct application layers from explicit sources, with standalone provider selection and catalog-owned aircraft classification; controls and voice queries use those instances.
|
||||
@@ -899,3 +901,4 @@ represent previously published GitHub Releases.
|
||||
## [0.1.0] — 2026-02-09
|
||||
|
||||
- Initial project version.
|
||||
|
||||
@@ -142,6 +142,10 @@ transaction quota. Requires a free `FIRMS_MAP_KEY`
|
||||
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
|
||||
|
||||
### ECMWF IFS wind
|
||||
|
||||
Wind also offers ECMWF IFS 10 m forecasts from the keyless [ECMWF Open Data](https://www.ecmwf.int/en/forecasts/datasets/open-data) service. The proxy reads its JSON Lines inventory and fetches only 10u/10v GRIB messages, resampling the forecast to the display grid. Data is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Attribution: “Contains modified Copernicus/ECMWF IFS forecast data.” Both model issue time and the selected forecast valid time appear in the layer row. These are model forecasts, not observations.
|
||||
|
||||
### NOAA GFS wind
|
||||
|
||||
The optional **Wind** layer animates the global 10 m wind field from NOAA's
|
||||
@@ -197,3 +201,4 @@ Douglas-Peucker simplification, 6-decimal rounding).
|
||||
## In-app attribution
|
||||
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
|
||||
@@ -3624,3 +3624,5 @@ releases imagery layers/listeners on replacement. Destroy invalidates pending
|
||||
work and releases owned resources, including late factory results. Supplied 3D
|
||||
tilesets remain owned by the caller; tilesets created through the controller's
|
||||
factory are added to its viewer and removed on destruction.
|
||||
|
||||
Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle and forecast step. Per-model caches retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results.
|
||||
+76
-170
@@ -1,193 +1,99 @@
|
||||
import {
|
||||
GFS_BUCKET,
|
||||
gfsObjectKey,
|
||||
selectLatestGfsCycle,
|
||||
} from './wind/catalog.js';
|
||||
import {
|
||||
fetchText,
|
||||
fetchRange,
|
||||
parseGfsIdx,
|
||||
windMessageRanges,
|
||||
} from './wind/gfs.js';
|
||||
import { fetchGfsWind } from './wind/gfs.js';
|
||||
import { fetchIfsWind } from './wind/ifs.js';
|
||||
import { decodeWindGribMessage } from './wind/decode.js';
|
||||
import { resampleWindGrid } from './wind/grid.js';
|
||||
|
||||
/**
|
||||
* NOAA GFS 10 m wind proxy.
|
||||
*
|
||||
* Selects the latest available GFS cycle, reads its `.idx` inventory, byte-range
|
||||
* fetches only the UGRD/VGRD 10 m GRIB2 messages, decodes them with ecCodes
|
||||
* (WASM), resamples to a compact grid, and serves a manifest plus a Float32
|
||||
* grid (U values then V values). Keyless; cached per cycle for an hour.
|
||||
*
|
||||
* Routes (mounted at `/api/wind`):
|
||||
* GET /api/wind/manifest → manifest JSON
|
||||
* GET /api/wind/grid/<id>.bin → Float32 U…V payload
|
||||
* GET /api/wind/status → manifest without `gridUrl`
|
||||
*
|
||||
* @param {{fetchImpl?: Function, now?: Function, decodeImpl?: Function,
|
||||
* targetDx?: number, ttlMs?: number}} [options]
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
export function windProxy({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
targetDx = 1,
|
||||
ttlMs = 3600_000,
|
||||
} = {}) {
|
||||
/** @type {?{id: string, idGrid: string, fetchedAt: number, manifest: object, grid: ?object}} */
|
||||
let cached = null;
|
||||
/** @type {?Promise<object>} */
|
||||
let loading = null;
|
||||
let lastAttempt = -Infinity;
|
||||
let controller = null;
|
||||
let waiters = 0;
|
||||
|
||||
/** Serve bounded, single-flight, per-model forecast snapshots from fixed providers. */
|
||||
export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImpl = decodeWindGribMessage, targetDx = 1, ttlMs = 3600_000, timeoutMs = 40_000, models = { gfs: fetchGfsWind, ifs: fetchIfsWind } } = {}) {
|
||||
const caches = new Map();
|
||||
const loadings = new Map();
|
||||
const grids = new Map();
|
||||
const attempts = new Map();
|
||||
const unavailable = (model) => ({ manifest: { model, schemaVersion: 1, unavailable: true, stale: true, reason: 'Wind upstream unavailable' } });
|
||||
const sendJson = (res, value, status = 200) => {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
|
||||
const cycleRunIso = (cycle) =>
|
||||
`${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z`;
|
||||
|
||||
async function refresh() {
|
||||
const cycle = selectLatestGfsCycle(now());
|
||||
const id = `${cycle.date}-${cycle.hour}`;
|
||||
if (cached && cached.id === id && now() - cached.fetchedAt < ttlMs)
|
||||
return cached;
|
||||
if (loading) return loading;
|
||||
if (cached && now() - lastAttempt < 60_000) return cached;
|
||||
lastAttempt = now();
|
||||
controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
const timer = setTimeout(() => controller.abort(), 40_000);
|
||||
loading = (async () => {
|
||||
function refresh(model) {
|
||||
const controller = new AbortController();
|
||||
const operation = { controller, waiters: 0, promise: null };
|
||||
loadings.set(model, operation);
|
||||
attempts.set(model, now());
|
||||
operation.promise = (async () => {
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`;
|
||||
const index = await fetchText({ url: `${base}.idx`, fetchImpl, signal });
|
||||
const ranges = windMessageRanges(parseGfsIdx(index.toString()));
|
||||
const [uBuffer, vBuffer] = await Promise.all([
|
||||
fetchRange({ url: base, ...ranges.u, fetchImpl, signal }),
|
||||
fetchRange({ url: base, ...ranges.v, fetchImpl, signal }),
|
||||
]);
|
||||
const [u, v] = await Promise.all([
|
||||
decodeImpl(uBuffer),
|
||||
decodeImpl(vBuffer),
|
||||
]);
|
||||
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,
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
if ((!grid.u.every(Number.isFinite) || !grid.v.every(Number.isFinite))) throw new Error('Invalid wind grid');
|
||||
const idGrid = `${id}-${targetDx}`;
|
||||
const value = await models[model]({ fetchImpl, now, decodeImpl, targetDx, signal: controller.signal });
|
||||
controller.signal.throwIfAborted();
|
||||
const { grid, cycle } = value;
|
||||
if (!grid.u.every(Number.isFinite) || !grid.v.every(Number.isFinite)) throw new Error('Invalid wind grid');
|
||||
const id = `${model}-${cycle.date}-${cycle.hour}-f${cycle.forecastHour || 0}-${targetDx}`;
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
model: 'gfs',
|
||||
cycle: { ...cycle, forecastHour: 0, runIso: cycleRunIso(cycle), validIso: cycleRunIso(cycle) },
|
||||
fetchedAt: now(),
|
||||
level: '10 m above ground',
|
||||
units: 'm/s',
|
||||
grid: {
|
||||
nx: grid.nx,
|
||||
ny: grid.ny,
|
||||
lo1: grid.lo1,
|
||||
la1: grid.la1,
|
||||
dx: grid.dx,
|
||||
dy: grid.dy,
|
||||
},
|
||||
stale: false,
|
||||
unavailable: false,
|
||||
reason: null,
|
||||
gridUrl: `/api/wind/grid/${idGrid}.bin`,
|
||||
schemaVersion: 1, model, cycle, fetchedAt: now(), level: value.level, units: value.units,
|
||||
grid: { nx: grid.nx, ny: grid.ny, lo1: grid.lo1, la1: grid.la1, dx: grid.dx, dy: grid.dy },
|
||||
stale: false, unavailable: false, reason: null,
|
||||
gridUrl: `/api/wind/grid/${id}.bin?model=${model}`,
|
||||
};
|
||||
cached = { id, idGrid, fetchedAt: manifest.fetchedAt, manifest, grid };
|
||||
return cached;
|
||||
} catch (error) {
|
||||
if (cached) {
|
||||
cached = {
|
||||
...cached,
|
||||
manifest: { ...cached.manifest, stale: true, reason: 'Wind upstream unavailable' },
|
||||
};
|
||||
} else {
|
||||
cached = {
|
||||
id: null,
|
||||
idGrid: null,
|
||||
fetchedAt: now(),
|
||||
grid: null,
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
model: 'gfs',
|
||||
stale: true,
|
||||
unavailable: true,
|
||||
reason: 'Wind upstream unavailable',
|
||||
},
|
||||
};
|
||||
const state = { id, grid, manifest, fetchedAt: now() };
|
||||
caches.set(model, state);
|
||||
// Retain the previous issued grid as well to cover a manifest/grid rollover.
|
||||
const history = grids.get(model) || new Map();
|
||||
history.set(id, state);
|
||||
while (history.size > 2) history.delete(history.keys().next().value);
|
||||
grids.set(model, history);
|
||||
return state;
|
||||
} catch {
|
||||
if (controller.signal.aborted && operation.waiters === 0) {
|
||||
attempts.delete(model);
|
||||
return unavailable(model);
|
||||
}
|
||||
return cached;
|
||||
const old = caches.get(model);
|
||||
if (old) {
|
||||
old.manifest = { ...old.manifest, stale: true, reason: 'Wind upstream unavailable' };
|
||||
return old;
|
||||
}
|
||||
return unavailable(model);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
loading = null;
|
||||
controller = null;
|
||||
controller.abort(); // Cancel a sibling request if the other component failed.
|
||||
if (loadings.get(model) === operation) loadings.delete(model);
|
||||
}
|
||||
})();
|
||||
return loading;
|
||||
return operation;
|
||||
}
|
||||
|
||||
const handler = async (req, res) => {
|
||||
const path = new URL(req.url, 'http://localhost').pathname;
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const model = url.searchParams.get('model') || 'gfs';
|
||||
if (req.method !== 'GET') return sendJson(res, { error: 'method_not_allowed' }, 405);
|
||||
if (!['/', '/manifest', '/status'].includes(path) && !/^\/grid\/[\w.-]+\.bin$/.test(path)) return sendJson(res, { error: 'not_found' }, 404);
|
||||
// Grid reads never initiate acquisition; an issued manifest owns its cached grid.
|
||||
if (path.startsWith('/grid/') && (!cached?.grid || path !== `/grid/${cached.idGrid}.bin`)) return sendJson(res, { error: 'unknown_grid' }, 404);
|
||||
let disconnected = false;
|
||||
const close = () => { disconnected = true; waiters -= 1; if (waiters === 0) controller?.abort(); };
|
||||
waiters += 1;
|
||||
res.once?.('close', close);
|
||||
let state;
|
||||
try { state = path.startsWith('/grid/') ? cached : await refresh(); }
|
||||
finally { res.removeListener?.('close', close); if (!disconnected) waiters -= 1; }
|
||||
if (disconnected) return;
|
||||
if (path === '/status') {
|
||||
const { gridUrl, ...manifest } = state.manifest;
|
||||
return sendJson(res, manifest);
|
||||
if (!['gfs', 'ifs'].includes(model) || !Object.hasOwn(models, model)) return sendJson(res, { error: 'unknown_model' }, 400);
|
||||
if (url.pathname.startsWith('/grid/')) {
|
||||
const id = url.pathname.slice(6).replace(/\.bin$/, '');
|
||||
const state = url.pathname === `/grid/${id}.bin` && grids.get(model)?.get(id);
|
||||
if (!state) return sendJson(res, { error: 'unknown_grid' }, 404);
|
||||
const bytes = 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(bytes);
|
||||
}
|
||||
if (path.startsWith('/grid/')) {
|
||||
if (!state.grid || path !== `/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);
|
||||
if (!['/', '/manifest', '/status'].includes(url.pathname)) return sendJson(res, { error: 'not_found' }, 404);
|
||||
let state = caches.get(model);
|
||||
if (!state || now() - state.fetchedAt >= ttlMs) {
|
||||
let operation = loadings.get(model);
|
||||
if (!operation && now() - (attempts.get(model) ?? -Infinity) >= 60_000) operation = refresh(model);
|
||||
if (operation) {
|
||||
let disconnected = false;
|
||||
operation.waiters += 1;
|
||||
const close = () => { disconnected = true; if (--operation.waiters === 0) operation.controller.abort(); };
|
||||
res.once?.('close', close);
|
||||
try { state = await operation.promise; }
|
||||
finally { res.removeListener?.('close', close); if (!disconnected) operation.waiters -= 1; }
|
||||
if (disconnected) return;
|
||||
}
|
||||
}
|
||||
return sendJson(res, state.manifest);
|
||||
const manifest = state?.manifest || unavailable(model).manifest;
|
||||
if (url.pathname === '/status') { const { gridUrl, ...status } = manifest; return sendJson(res, status); }
|
||||
return sendJson(res, manifest);
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'wind',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/wind', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/wind', handler);
|
||||
},
|
||||
configureServer({ middlewares }) { middlewares.use('/api/wind', handler); },
|
||||
configurePreviewServer({ middlewares }) { middlewares.use('/api/wind', handler); },
|
||||
};
|
||||
}
|
||||
@@ -14,3 +14,16 @@ export function selectLatestGfsCycle(nowMs, { availabilityLagMs = 5 * 3600_000 }
|
||||
date.setUTCHours(hour);
|
||||
return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), hour };
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a time offset to the nearest published GFS forecast step so the field
|
||||
* shown is the one valid closest to now, not always the analysis (f000).
|
||||
* GFS 0.25° is hourly through f120, then every 3 h through f384.
|
||||
* @param {number} hours - Hours elapsed since the cycle run time.
|
||||
* @returns {number} A valid forecast step.
|
||||
*/
|
||||
export function nearestGfsStep(hours) {
|
||||
const value = Math.max(0, Number.isFinite(hours) ? hours : 0);
|
||||
const step = value <= 120 ? Math.round(value) : Math.round(value / 3) * 3;
|
||||
return Math.max(0, Math.min(384, step));
|
||||
}
|
||||
@@ -88,3 +88,34 @@ export async function fetchRange({
|
||||
throw new Error('invalid range length');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
import { GFS_BUCKET, gfsObjectKey, selectLatestGfsCycle, nearestGfsStep } from './catalog.js';
|
||||
import { decodeWindGribMessage } from './decode.js';
|
||||
import { resampleWindGrid } from './grid.js';
|
||||
|
||||
/** 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 = decodeWindGribMessage, signal } = {}) {
|
||||
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 ranges = windMessageRanges(parseGfsIdx(index.toString()));
|
||||
const [uBuffer, vBuffer] = await Promise.all([
|
||||
fetchRange({ url: base, ...ranges.u, fetchImpl, signal }),
|
||||
fetchRange({ url: base, ...ranges.v, fetchImpl, signal }),
|
||||
]);
|
||||
const [u, v] = await Promise.all([decodeImpl(uBuffer), decodeImpl(vBuffer)]);
|
||||
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 });
|
||||
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', grid };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { fetchText, fetchRange } from './gfs.js';
|
||||
import { decodeWindGribMessage } from './decode.js';
|
||||
import { resampleWindGrid } from './grid.js';
|
||||
|
||||
/** Keyless ECMWF Open Data root for real-time IFS forecasts. */
|
||||
export const IFS_BASE = 'https://data.ecmwf.int/forecasts';
|
||||
|
||||
/**
|
||||
* Select the latest IFS cycle that should be available.
|
||||
* @param {number} nowMs - Reference epoch milliseconds.
|
||||
* @param {{availabilityLagMs?: number}} [options]
|
||||
* @returns {{date: string, hour: number}}
|
||||
*/
|
||||
export function selectLatestIfsCycle(
|
||||
nowMs,
|
||||
{ availabilityLagMs = 6 * 3600_000 } = {},
|
||||
) {
|
||||
const shifted = new Date(nowMs - availabilityLagMs);
|
||||
return {
|
||||
date: shifted.toISOString().slice(0, 10).replaceAll('-', ''),
|
||||
hour: Math.floor(shifted.getUTCHours() / 6) * 6,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the IFS GRIB2 and `.index` URLs for one cycle and step.
|
||||
* @param {{date: string, hour: number, step?: number}} cycle
|
||||
* @returns {{grib: string, index: string}}
|
||||
*/
|
||||
export function ifsObjectUrls({ date, hour, step = 0 }) {
|
||||
const hh = String(hour).padStart(2, '0');
|
||||
const stem = `${IFS_BASE}/${date}/${hh}z/ifs/0p25/oper/${date}${hh}0000-${step}h-oper-fc`;
|
||||
// The inventory sits beside the GRIB2 as `<stem>.index`, NOT `<grib2>.index`.
|
||||
return { grib: `${stem}.grib2`, index: `${stem}.index` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the IFS JSON Lines inventory into `{param, offset, length}` records.
|
||||
* Malformed lines are skipped; a payload with no valid lines is rejected.
|
||||
* @param {string} text - Raw `.index` body.
|
||||
* @returns {Array<{param: string, offset: number, length: number}>}
|
||||
*/
|
||||
export function parseIfsIndex(text) {
|
||||
const entries = [];
|
||||
for (const line of String(text).split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(line);
|
||||
} catch {
|
||||
continue; // non-JSON line in the index
|
||||
}
|
||||
if (
|
||||
value?.param &&
|
||||
Number.isFinite(value._offset) &&
|
||||
Number.isFinite(value._length)
|
||||
)
|
||||
entries.push({
|
||||
param: value.param,
|
||||
offset: value._offset,
|
||||
length: value._length,
|
||||
});
|
||||
}
|
||||
if (!entries.length) throw new Error('malformed IFS index');
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte ranges for the IFS 10 m wind components.
|
||||
* @param {Array<{param: string, offset: number, length: number}>} entries
|
||||
* @returns {{u: {start: number, end: number}, v: {start: number, end: number}}}
|
||||
*/
|
||||
export function ifsWindRanges(entries) {
|
||||
const range = (param) => {
|
||||
const item = entries.find((entry) => entry.param === param);
|
||||
if (!item) throw new Error(`missing ${param}`);
|
||||
return { start: item.offset, end: item.offset + item.length - 1 };
|
||||
};
|
||||
return { u: range('10u'), v: range('10v') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a time offset to the nearest published IFS forecast step. IFS 0.25°
|
||||
* oper is 3-hourly through f144 (00/12z) or f90 (06/18z).
|
||||
* @param {number} hours - Hours elapsed since the cycle run time.
|
||||
* @param {number} cycleHour - The cycle hour (0, 6, 12 or 18).
|
||||
* @returns {number} A valid forecast step.
|
||||
*/
|
||||
export function nearestIfsStep(hours, cycleHour) {
|
||||
const max = cycleHour === 6 || cycleHour === 18 ? 90 : 144;
|
||||
const value = Math.max(0, Number.isFinite(hours) ? hours : 0);
|
||||
return Math.max(0, Math.min(max, Math.round(value / 3) * 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and decode the IFS 10 m wind field valid closest to now.
|
||||
* @param {{fetchImpl?: Function, now?: Function, targetDx?: number,
|
||||
* decodeImpl?: Function}} [options]
|
||||
* @returns {Promise<{cycle: object, level: string, units: string, grid: object}>}
|
||||
*/
|
||||
export async function fetchIfsWind({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
targetDx = 1,
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
signal,
|
||||
} = {}) {
|
||||
const nowMs = now();
|
||||
const cycle = selectLatestIfsCycle(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,
|
||||
);
|
||||
// Show the field valid closest to now rather than always the analysis.
|
||||
const forecastHour = nearestIfsStep((nowMs - runMs) / 3600_000, cycle.hour);
|
||||
const urls = ifsObjectUrls({ ...cycle, step: forecastHour });
|
||||
const index = await fetchText({ url: urls.index, fetchImpl, signal });
|
||||
const ranges = ifsWindRanges(parseIfsIndex(index.toString()));
|
||||
const [uBuffer, vBuffer] = await Promise.all([
|
||||
fetchRange({ url: urls.grib, ...ranges.u, fetchImpl, signal }),
|
||||
fetchRange({ url: urls.grib, ...ranges.v, fetchImpl, signal }),
|
||||
]);
|
||||
const [u, v] = await Promise.all([decodeImpl(uBuffer), decodeImpl(vBuffer)]);
|
||||
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,
|
||||
});
|
||||
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',
|
||||
grid,
|
||||
};
|
||||
}
|
||||
@@ -204,6 +204,13 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://registry.opendata.aws/noaa-gfs-bdp-pds/" target="_blank" rel="noopener">NOAA Open Data on AWS</a> ' +
|
||||
'(U.S. public domain; forecast, not observations)',
|
||||
},
|
||||
{
|
||||
key: 'ecmwf-ifs',
|
||||
html:
|
||||
'Contains modified Copernicus/ECMWF IFS forecast data, licensed under CC BY 4.0. ' +
|
||||
'Source: https://data.ecmwf.int/forecasts/ ' +
|
||||
'(<a href="https://www.ecmwf.int/en/forecasts/datasets/open-data" target="_blank" rel="noopener">ECMWF Open Data</a>)',
|
||||
},
|
||||
// ── Bundled snapshots ───────────────────────────────────────────
|
||||
{
|
||||
key: 'datacenters',
|
||||
|
||||
@@ -182,6 +182,9 @@ function integerOption(key, token, defaultValue) {
|
||||
}
|
||||
|
||||
const OPTION_GROUPS = Object.freeze({
|
||||
wind: Object.freeze([
|
||||
enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }),
|
||||
]),
|
||||
flights: Object.freeze([
|
||||
// Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in
|
||||
// PROXIMITY mode. Proximity is itself the altitude/count gate — models only
|
||||
@@ -293,7 +296,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }),
|
||||
Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'wind', token: 'k', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'wind', token: 'k', disposition: 'enabled+options', optionOwner: 'wind' }),
|
||||
]);
|
||||
|
||||
export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id));
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ifsObjectUrls, parseIfsIndex, ifsWindRanges, nearestIfsStep, selectLatestIfsCycle } from '../../server/providers/wind/ifs.js';
|
||||
import { nearestGfsStep } from '../../server/providers/wind/catalog.js';
|
||||
test('IFS inventory selects only 10 m components and distinct index suffix', () => {
|
||||
const urls = ifsObjectUrls({ date: '20260915', hour: 6, step: 9 });
|
||||
assert.match(urls.index, /09000|9h-oper-fc.index$/);
|
||||
assert.equal(urls.index.includes('.grib2.index'), false);
|
||||
assert.deepEqual(ifsWindRanges(parseIfsIndex('{"param":"10u","_offset":20,"_length":10}\n{"param":"10v","_offset":40,"_length":15}')), { u: {start:20,end:29}, v:{start:40,end:54} });
|
||||
assert.deepEqual(selectLatestIfsCycle(Date.UTC(2026,8,15,4)), { date:'20260914', hour:18 });
|
||||
assert.equal(nearestIfsStep(8,6),9); assert.equal(nearestGfsStep(8.2),8);
|
||||
});
|
||||
@@ -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-f6-90\.bin\?model=gfs$/);
|
||||
assert.equal(body.stale, false);
|
||||
});
|
||||
|
||||
@@ -168,3 +171,65 @@ 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-f0-90\.bin\?model=gfs$/);
|
||||
assert.match(ifs.gridUrl, /ifs-20260914-0-f0-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.equal(body.reason, 'Wind upstream unavailable');
|
||||
});
|
||||
|
||||
test('wind rejects unknown models, paths and methods before acquisition', async () => {
|
||||
let calls = 0;
|
||||
const request = install(proxy({ models: { gfs: async () => { calls++; throw new Error(); } } }));
|
||||
assert.equal((await request('/?model=toString')).statusCode, 400);
|
||||
assert.equal((await request('/secret')).statusCode, 404);
|
||||
assert.equal((await request('/manifest', 'POST')).statusCode, 405);
|
||||
assert.equal((await request('/grid/unknown.bin')).statusCode, 404);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('wind caches forecast steps separately and keeps a prior issued grid readable', async () => {
|
||||
let clock = Date.UTC(2026, 8, 14, 12); let step = 6;
|
||||
const request = install(proxy({ now: () => clock, models: { gfs: async () => ({ cycle: { date: '20260914', hour: 6, forecastHour: step }, level:'10 m', units:'m/s', grid: { ...GRID, u: new Float32Array(12).fill(step), v: new Float32Array(12) } }) } }));
|
||||
const first = JSON.parse((await request('/')).body);
|
||||
clock += 3600_000; step = 7;
|
||||
const second = JSON.parse((await request('/')).body);
|
||||
assert.notEqual(first.gridUrl, second.gridUrl);
|
||||
assert.equal((await request(first.gridUrl.replace('/api/wind',''))).statusCode, 200);
|
||||
});
|
||||
|
||||
test('wind failure backoff prevents unbounded repeated upstream acquisition', async () => {
|
||||
let calls = 0;
|
||||
const request = install(proxy({ models: { gfs: async () => { calls++; throw new Error('private upstream detail'); } } }));
|
||||
const results = await Promise.all([request('/'), request('/')]);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(JSON.parse(results[0].body).reason, 'Wind upstream unavailable');
|
||||
await request('/'); assert.equal(calls, 1);
|
||||
});
|
||||
@@ -27,10 +27,12 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender
|
||||
let manifest = null;
|
||||
let error = null;
|
||||
let loading = false;
|
||||
let model = 'gfs';
|
||||
let generation = 0;
|
||||
let rowControlsListener = null;
|
||||
const notify = () => rowControlsListener?.();
|
||||
const layer = {
|
||||
id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS · FORECAST', updateInterval: 3600_000,
|
||||
id: 'wind', name: 'Wind', icon: '🌬', source: 'GFS / ECMWF IFS · FORECAST', updateInterval: 3600_000,
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
rendering = createRendering({ cesium, container: container ?? nextViewer.container, getViewer: () => viewer });
|
||||
@@ -39,6 +41,7 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender
|
||||
enable() { enabled = true; rendering?.start(); },
|
||||
disable() {
|
||||
enabled = false;
|
||||
generation += 1;
|
||||
request?.abort(); request = null; loading = false;
|
||||
rendering?.stop(); rendering?.clear();
|
||||
},
|
||||
@@ -49,11 +52,12 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
request = controller;
|
||||
generation += 1;
|
||||
loading = true;
|
||||
notify();
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const snapshot = await feed.getSnapshot({ signal: controller.signal });
|
||||
const snapshot = await feed.getSnapshot({ signal: controller.signal, model });
|
||||
if (!enabled || controller.signal.aborted || request !== controller) return false;
|
||||
manifest = snapshot;
|
||||
error = null;
|
||||
@@ -69,14 +73,27 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender
|
||||
if (request === controller) { request = null; loading = false; notify(); }
|
||||
}
|
||||
},
|
||||
setParams(params = {}) {
|
||||
if (!['gfs', 'ifs'].includes(params.model) || params.model === model) return;
|
||||
model = params.model;
|
||||
request?.abort(); request = null;
|
||||
manifest = null; error = null; loading = enabled;
|
||||
rendering?.stop(); rendering?.clear();
|
||||
const current = ++generation;
|
||||
notify();
|
||||
if (enabled) queueMicrotask(() => {
|
||||
if (enabled && generation === current) void layer.update(viewer);
|
||||
});
|
||||
},
|
||||
getParams() { return { model }; },
|
||||
getRowControls() {
|
||||
const valid = formatWindValidTime(manifest?.cycle?.validIso);
|
||||
const run = formatWindValidTime(manifest?.cycle?.runIso);
|
||||
return { chips: [], legend: [], info: `GFS forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}` };
|
||||
return { chips: ['gfs', 'ifs'].map((value) => ({ id: `model-${value}`, label: value.toUpperCase(), active: model === value, params: { model: value }, title: value === 'ifs' ? 'ECMWF IFS 10 m forecast' : 'NOAA GFS 10 m forecast' })), legend: [{ label: '0', color: '#1e3a8a' }, { label: '10', color: '#22d3ee' }, { label: '20', color: '#fbbf24' }, { label: '30+ m/s', color: '#ef4444' }], info: `${model.toUpperCase()} forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}` };
|
||||
},
|
||||
setRowControlsListener(listener) { rowControlsListener = typeof listener === 'function' ? listener : null; },
|
||||
destroy() { layer.disable(); rendering?.destroy(); rendering = null; viewer = null; rowControlsListener = null; },
|
||||
getStats() { return { ...windStats(manifest), loading, error: error || windStats(manifest).error }; },
|
||||
getStats() { return { ...windStats(manifest), loading, model: model.toUpperCase(), source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS', validTime: formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable', error: error || windStats(manifest).error }; },
|
||||
getParticleCount() { return rendering?.getParticleCount() || 0; },
|
||||
};
|
||||
return layer;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindLayer, formatWindValidTime, windStats } from './index.js';
|
||||
|
||||
const snapshot = (model) => ({ model, grid: { nx: 2, ny: 2 }, cycle: { runIso: '2026-09-14T12:00:00Z', validIso: '2026-09-14T18:00:00Z' } });
|
||||
function harness(feed) {
|
||||
const calls = [];
|
||||
const rendering = Object.fromEntries(['attach','start','stop','clear','destroy','setField'].map(name => [name, (...args) => calls.push([name, ...args])]));
|
||||
const layer = createWindLayer({ feed, createRendering: () => rendering });
|
||||
layer.init({ container: {} }); layer.enable();
|
||||
return { layer, calls };
|
||||
}
|
||||
test('forecast valid time and source age stay distinct', () => {
|
||||
assert.equal(formatWindValidTime('invalid'), null);
|
||||
assert.equal(formatWindValidTime('2026-01-05T06:30:00Z'), '2026-01-05 06:30 UTC');
|
||||
assert.equal(windStats(snapshot('gfs')).lastUpdate, Date.parse('2026-09-14T12:00:00Z'));
|
||||
});
|
||||
test('switching models clears old data and ignores an abort-insensitive late source', async () => {
|
||||
const pending = [];
|
||||
const { layer, calls } = harness({ getSnapshot: args => new Promise(resolve => pending.push({ ...args, resolve })) });
|
||||
const first = layer.update();
|
||||
layer.setParams({ model: 'ifs' });
|
||||
await Promise.resolve();
|
||||
assert.equal(pending.length, 2);
|
||||
assert.equal(pending[0].signal.aborted, true);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(layer.getStats().model, 'IFS');
|
||||
pending[0].resolve(snapshot('gfs'));
|
||||
await first;
|
||||
assert.equal(calls.filter(([name]) => name === 'setField').length, 0);
|
||||
pending[1].resolve(snapshot('ifs'));
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(calls.find(([name]) => name === 'setField')[1].model, 'ifs');
|
||||
assert.match(layer.getRowControls().info, /IFS forecast.*Valid: 2026-09-14 18:00 UTC.*Issued: 2026-09-14 12:00 UTC/);
|
||||
layer.destroy();
|
||||
});
|
||||
test('external and owned cancellation both cancel source work; queued switches stop on disable', async () => {
|
||||
let signal;
|
||||
const { layer } = harness({ getSnapshot: args => { signal = args.signal; return new Promise(resolve => signal.addEventListener('abort', () => resolve(snapshot('gfs')), { once: true })); } });
|
||||
const external = new AbortController();
|
||||
const update = layer.update(null, { signal: external.signal });
|
||||
layer.disable();
|
||||
assert.equal(signal.aborted, true); assert.equal(external.signal.aborted, false);
|
||||
assert.equal(await update, false);
|
||||
layer.enable();
|
||||
const other = layer.update(null, { signal: external.signal });
|
||||
external.abort(); assert.equal(signal.aborted, true); await other;
|
||||
layer.setParams({ model: 'ifs' }); layer.disable();
|
||||
await Promise.resolve(); assert.equal(layer.getStats().loading, false);
|
||||
layer.destroy();
|
||||
});
|
||||
@@ -36,9 +36,9 @@ export function windSpeed(u, v) {
|
||||
return Math.hypot(u, v);
|
||||
}
|
||||
|
||||
/** Map wind magnitude to a fixed CSS colour ramp (bright from calm upward). */
|
||||
/** Map wind magnitude to a fixed CSS colour ramp (calm blue → strong red). */
|
||||
export function windColor(speed, { max = 30 } = {}) {
|
||||
const stops = ['#38bdf8', '#22d3ee', '#34d399', '#fbbf24', '#f87171'];
|
||||
const stops = ['#1e3a8a', '#2563eb', '#22d3ee', '#34d399', '#fbbf24', '#f97316', '#ef4444'];
|
||||
const t = Math.max(0, Math.min(1, speed / max));
|
||||
const position = t * (stops.length - 1);
|
||||
const index = Math.min(stops.length - 2, Math.floor(position));
|
||||
|
||||
@@ -3,7 +3,8 @@ import { readWindBody } from '../../sources/windBody.js';
|
||||
/** Create a bounded, cancellable source for the same-origin wind provider. */
|
||||
export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args), timeoutMs = 45_000 } = {}) {
|
||||
return {
|
||||
async getSnapshot({ signal } = {}) {
|
||||
async getSnapshot({ signal, model = 'gfs' } = {}) {
|
||||
if (!['gfs', 'ifs'].includes(model)) throw new Error('Unknown wind model');
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
@@ -11,12 +12,12 @@ export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...
|
||||
const active = controller.signal;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/wind/manifest', { signal: active, cache: 'no-store', redirect: 'error' });
|
||||
const response = await fetchImpl(`/api/wind/manifest?model=${model}`, { signal: active, cache: 'no-store', redirect: 'error' });
|
||||
if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
|
||||
const manifest = JSON.parse(new TextDecoder().decode(await readWindBody(response, 16_384, active)));
|
||||
if (manifest?.unavailable) return manifest;
|
||||
const grid = manifest?.grid;
|
||||
if (manifest?.model !== 'gfs' || !grid || !Number.isInteger(grid.nx) || !Number.isInteger(grid.ny) || grid.nx < 1 || grid.ny < 1 || grid.nx * grid.ny > 1_000_000 || ![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) || grid.dx <= 0 || grid.dy <= 0 || Math.abs(grid.nx * grid.dx - 360) > 0.01 || !/^\/api\/wind\/grid\/[\w.-]+\.bin$/.test(manifest.gridUrl))
|
||||
if (manifest?.model !== model || !grid || !Number.isInteger(grid.nx) || !Number.isInteger(grid.ny) || grid.nx < 1 || grid.ny < 1 || grid.nx * grid.ny > 1_000_000 || ![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) || grid.dx <= 0 || grid.dy <= 0 || Math.abs(grid.nx * grid.dx - 360) > 0.01 || !new RegExp(`^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?model=${model}$`).test(manifest.gridUrl))
|
||||
throw new Error('Malformed wind manifest');
|
||||
const count = grid.nx * grid.ny;
|
||||
const gridResponse = await fetchImpl(manifest.gridUrl, { signal: active, redirect: 'error' });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindSource } from './source.js';
|
||||
const manifest = { model: 'gfs', grid: { nx: 2, ny: 1, lo1: 0, la1: 90, dx: 180, dy: 180 }, gridUrl: '/api/wind/grid/test.bin' };
|
||||
const manifest = { model: 'gfs', grid: { nx: 2, ny: 1, lo1: 0, la1: 90, dx: 180, dy: 180 }, gridUrl: '/api/wind/grid/gfs-test.bin?model=gfs' };
|
||||
const response = (value) => new Response(typeof value === 'object' && !(value instanceof ArrayBuffer) ? JSON.stringify(value) : value);
|
||||
test('wind source splits a bounded field and preserves unavailable responses', async () => {
|
||||
const source = createWindSource({ fetchImpl: async (url) => response(url.includes('manifest') ? manifest : Float32Array.from([1,2,3,4]).buffer) });
|
||||
|
||||
Reference in new issue
Block a user