/**
* Optional loaders for public data (all endpoints send CORS headers, so they work from a static site).
*
* The SharpJS core never imports this module. Apps that already have their own data
* pipeline pass soundings straight to SharpJS(el, { sounding }) instead.
*
* - Iowa Environmental Mesonet RAOB archive https://mesonet.agron.iastate.edu/json/raob.py
* - Iowa Environmental Mesonet BUFKIT archive https://mtarchive.geol.iastate.edu/
* - Unidata NEXRAD Level III on AWS https://unidata-nexrad-level3.s3.amazonaws.com
*
* Please be kind to these free services: fetch on user action, not on a timer.
*/
import { parseIemRaob } from '../io/iem.js';
import { parseBufkit, listBufkitForecastHours } from '../io/bufkit.js';
import { buildVwpSounding } from '../io/vwp.js';
import { withLocation } from '../io/location.js';
import { pad } from '../io/general.js';
const IEM_RAOB = 'https://mesonet.agron.iastate.edu/json/raob.py';
const IEM_BUFKIT = 'https://mtarchive.geol.iastate.edu';
const NEXRAD_L3 = 'https://unidata-nexrad-level3.s3.amazonaws.com';
const IEM_RAOB_STATIONS = 'https://mesonet.agron.iastate.edu/geojson/network/RAOB.geojson';
/**
* Normalize a supported date input and reject invalid timestamps.
* @param {Date|string|number} time Date, parseable date string, or numeric timestamp (in milliseconds).
* @returns {Date} Valid date object representing the same instant.
*/
function toDate(time) {
const d = time instanceof Date ? time : new Date(time);
if (Number.isNaN(d.getTime())) throw new Error(`SharpJS: invalid time ${time}`);
return d;
}
/**
* Fetch a URL and reject non-success HTTP responses.
* @param {string} url Resource URL.
* @param {Function} fetchImpl Fetch-compatible function.
* @returns {Promise<Response>} Successful HTTP response.
*/
async function getOk(url, fetchImpl) {
const res = await (fetchImpl || fetch)(url);
if (!res.ok) {
const err = new Error(res.status === 404 ? 'Not found' : `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res;
}
let raobStations = null;
/** Location of an upper-air station from the IEM station table (cached), or null. */
export async function raobStationLocation(station, fetchImpl) {
const id = String(station || '').trim().toUpperCase();
try {
raobStations ??= getOk(IEM_RAOB_STATIONS, fetchImpl)
.then((res) => res.json())
.then((geo) => new Map(geo.features.map((f) => [String(f.id).toUpperCase(), f.geometry.coordinates])));
const table = await raobStations;
const c = table.get(id) || table.get(`K${id}`) || table.get(id.replace(/^K/, ''));
return c ? { lat: c[1], lon: c[0] } : null;
} catch {
raobStations = null;
return null;
}
}
/**
* Observed upper-air sounding from the IEM archive.
* @param {object} q
* @param {string} q.station e.g. "OAX" or "KOAX"
* @param {Date|string|number} q.time launch time (UTC); IEM returns the matching launch
* @param {{lat:number, lon:number}|false} [q.location] station location (default: looked up from IEM)
* @param {Function} [q.fetch] custom fetch implementation
*/
export async function fetchIemRaob({ station, time, location, fetch: fetchImpl }) {
const id = String(station || '').trim().toUpperCase();
if (!id) throw new Error('SharpJS: station is required');
const d = toDate(time);
const ts = `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:00:00Z`;
const [res, loc] = await Promise.all([
getOk(`${IEM_RAOB}?station=${encodeURIComponent(id)}&ts=${ts}`, fetchImpl),
location === undefined ? raobStationLocation(id, fetchImpl) : Promise.resolve(location || null),
]);
const sounding = parseIemRaob(await res.json());
if (loc) withLocation(sounding, loc);
return sounding;
}
/** IEM archive directory and file names per model (NAM 06/18Z runs live under "namm"). */
function bufkitPath(model, station, run) {
const m = model.toLowerCase();
const hr = run.getUTCHours();
let file = m;
if (m === 'nam' && (hr === 6 || hr === 18)) file = 'namm';
else if (m === 'gfs') file = 'gfs3';
return `${run.getUTCFullYear()}/${pad(run.getUTCMonth() + 1)}/${pad(run.getUTCDate())}/bufkit/${pad(hr)}/${m}/${file}_${station.toLowerCase()}.buf`;
}
/** Models the IEM BUFKIT archive carries. */
export const BUFKIT_MODELS = ['hrrr', 'rap', 'ruc', 'nam', 'nam4km', 'gfs'];
/**
* Archived model forecast sounding (BUFKIT) from the IEM archive.
* @param {object} q
* @param {string} q.model one of BUFKIT_MODELS
* @param {string} q.station BUFKIT site, e.g. "kdsm" (RAP/RUC) or "dvn" (HRRR/NAM)
* @param {Date|string|number} q.run model run time (UTC)
* @param {number} [q.forecastHour=0]
* @param {Function} [q.fetch]
*/
export async function fetchIemBufkit({ model = 'hrrr', station, run, forecastHour = 0, fetch: fetchImpl }) {
const { id, text } = await fetchIemBufkitText({ model, station, run, fetch: fetchImpl });
return parseBufkit(text, { forecastHour, model, station: id });
}
/**
* Fetch and parse every forecast hour from one IEM BUFKIT file.
* @param {object} q BUFKIT archive selection.
* @param {string} q.model Model identifier.
* @param {string} q.station BUFKIT station identifier.
* @param {Date|string|number} q.run Model run time (UTC).
* @param {Function} [q.fetch] Fetch-compatible function.
* @returns {Promise<object[]>} Soundings for each forecast hour, in file order.
*/
export async function fetchIemBufkitForecasts({ model = 'hrrr', station, run, fetch: fetchImpl }) {
const { id, text } = await fetchIemBufkitText({ model, station, run, fetch: fetchImpl });
const hours = listBufkitForecastHours(text);
if (hours.length === 0) throw new Error('SharpJS: no forecasts found in BUFKIT file');
return hours.map((forecastHour) => parseBufkit(text, { forecastHour, model, station: id }));
}
async function fetchIemBufkitText({ model, station, run, fetch: fetchImpl }) {
const id = String(station || '').trim();
if (!id) throw new Error('SharpJS: station is required');
const r = toDate(run);
const url = `${IEM_BUFKIT}/${bufkitPath(model, id, r)}`;
let res;
try {
res = await getOk(url, fetchImpl);
} catch (err) {
if (err.status === 404) {
throw new Error(`No ${model.toUpperCase()} BUFKIT for ${id.toUpperCase()} at ${pad(r.getUTCHours())}Z ${r.toISOString().slice(0, 10)}`, { cause: err });
}
throw err;
}
return { id, text: await res.text() };
}
/**
* List NEXRAD S3 object keys matching a prefix.
* @param {string} prefix Object-key prefix.
* @param {Function} fetchImpl Fetch-compatible function.
* @returns {Promise<string[]>} Matching S3 object keys.
*/
async function listKeys(prefix, fetchImpl) {
const res = await getOk(`${NEXRAD_L3}/?list-type=2&prefix=${encodeURIComponent(prefix)}&max-keys=1000`, fetchImpl);
const xml = await res.text();
return [...xml.matchAll(/<Key>([^<]+)<\/Key>/g)].map((m) => m[1]);
}
/** "OAX_NVW_2024_05_26_12_03_45" → unix seconds. */
function keyUnix(key) {
const m = /_(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})$/.exec(key);
return m ? Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]) / 1000 : NaN;
}
/**
* NEXRAD VAD Wind Profile (NVW) from Unidata's public Level III bucket (2020 onward).
*
* @param {object} q
* @param {string} q.radar e.g. "OAX" or "KOAX"
* @param {Date|string|number} [q.time] closest volume at or before this time (default: latest)
* @param {object} [q.background] sounding to take thermodynamics from (default: standard atmosphere)
* @param {Function} [q.fetch]
*/
export async function fetchNexradVwp({ radar, time, background, fetch: fetchImpl }) {
let id = String(radar || '').trim().toUpperCase();
if (id.length === 4) id = id.slice(1);
if (!/^[A-Z0-9]{3}$/.test(id)) throw new Error('SharpJS: radar must be a 3- or 4-letter id');
const target = time != null ? toDate(time) : new Date();
let keys = [];
// The current hour can be empty right after the top of the hour; look back a little.
for (let back = 0; back <= 2 && keys.length === 0; back++) {
const h = new Date(target.getTime() - back * 3600 * 1000);
const prefix = `${id}_NVW_${h.getUTCFullYear()}_${pad(h.getUTCMonth() + 1)}_${pad(h.getUTCDate())}_${pad(h.getUTCHours())}`;
keys = (await listKeys(prefix, fetchImpl)).filter((k) => keyUnix(k) <= target.getTime() / 1000 + 60);
}
if (keys.length === 0) throw new Error(`No VWP for ${id} near ${target.toISOString()}`);
keys.sort((a, b) => keyUnix(a) - keyUnix(b));
const key = keys[keys.length - 1];
const res = await getOk(`${NEXRAD_L3}/${key}`, fetchImpl);
const sounding = buildVwpSounding(await res.arrayBuffer(), { radar: id, background });
if (!sounding) throw new Error(`VWP for ${id} has no wind levels`);
return sounding;
}
/**
* Fetch every archived NEXRAD VWP volume in an inclusive UTC time window.
* @param {object} q Archive query and optional progress callback.
* @param {string} q.radar Three- or four-letter radar id.
* @param {Date|string|number} q.start Inclusive window start.
* @param {Date|string|number} q.end Inclusive window end.
* @param {object} [q.background] Sounding to take thermodynamics from.
* @param {Function} [q.fetch] Fetch-compatible function.
* @param {(sounding: object, progress: { index: number, total: number }) => void} [q.onVolume] Called as each volume is ready.
* @returns {Promise<object[]>} Soundings ordered by volume time.
*/
export async function fetchNexradVwpArchive({ radar, start, end, background, fetch: fetchImpl, onVolume }) {
let id = String(radar || '').trim().toUpperCase();
if (id.length === 4) id = id.slice(1);
if (!/^[A-Z0-9]{3}$/.test(id)) throw new Error('SharpJS: radar must be a 3- or 4-letter id');
const first = toDate(start);
const last = toDate(end);
if (first > last) throw new Error('SharpJS: VWP archive start must not be after end');
const keys = new Set();
const hour = new Date(first);
hour.setUTCMinutes(0, 0, 0);
const finalHour = new Date(last);
finalHour.setUTCMinutes(0, 0, 0);
for (; hour <= finalHour; hour.setUTCHours(hour.getUTCHours() + 1)) {
const prefix = `${id}_NVW_${hour.getUTCFullYear()}_${pad(hour.getUTCMonth() + 1)}_${pad(hour.getUTCDate())}_${pad(hour.getUTCHours())}`;
for (const key of await listKeys(prefix, fetchImpl)) {
const timestamp = keyUnix(key) * 1000;
if (timestamp >= first.getTime() && timestamp <= last.getTime()) keys.add(key);
}
}
const orderedKeys = [...keys].sort((a, b) => keyUnix(a) - keyUnix(b));
if (orderedKeys.length === 0) throw new Error(`No VWP volumes for ${id} from ${first.toISOString()} to ${last.toISOString()}`);
const soundings = [];
for (let index = 0; index < orderedKeys.length; index++) {
const key = orderedKeys[index];
const res = await getOk(`${NEXRAD_L3}/${key}`, fetchImpl);
const sounding = buildVwpSounding(await res.arrayBuffer(), { radar: id, background });
if (!sounding) throw new Error(`VWP for ${id} has no wind levels at ${key}`);
soundings.push(sounding);
onVolume?.(sounding, { index: index + 1, total: orderedKeys.length });
}
return soundings;
}