merge: integrate ECMWF IFS PR #464 with bounded model switching

This commit is contained in:
Bilawal Sidhu committed 2026-09-15 02:25:35 -05:00
commit 26354d5ae1
16 files changed
+444 -182

No files matched your search

+13
View File
@@ -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));
}
+31
View File
@@ -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 };
}
+146
View File
@@ -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,
};
}