class BufReader {
constructor(bytes) {
if (bytes instanceof ArrayBuffer) bytes = new Uint8Array(bytes);
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(bytes)) {
bytes = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
this.bytes = bytes;
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
this.length = bytes.byteLength;
}
u16(off) { return this.view.getUint16(off, false); } // big-endian
i16(off) { return this.view.getInt16(off, false); }
u32(off) { return this.view.getUint32(off, false); }
i32(off) { return this.view.getInt32(off, false); }
slice(start, end) {
const sub = this.bytes.subarray(start, end);
return new BufReader(sub);
}
ascii(start, len) {
let s = '';
const end = Math.min(start + len, this.length);
for (let i = start; i < end; i++) s += String.fromCharCode(this.bytes[i]);
return s;
}
}
// ─── Zlib inflate (portable) ─────────────────────────────────────────────────
let _inflate;
/**
* Decompress a zlib-compressed product using the available runtime implementation.
* @param {Uint8Array|ArrayBuffer} bytes Compressed product bytes (in bytes).
* @returns {Uint8Array} Decompressed product bytes.
*/
function inflate(bytes) {
// Node.js — use built-in zlib (getBuiltinModule works from ES modules, Node ≥ 20.16)
const zlib = typeof process !== 'undefined' && typeof process.getBuiltinModule === 'function'
? process.getBuiltinModule('node:zlib')
: null;
if (zlib) {
try {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
const nodeResult = zlib.inflateSync(Buffer.from(input));
return new Uint8Array(nodeResult.buffer, nodeResult.byteOffset, nodeResult.byteLength);
} catch (_) { /* fall through */ }
}
// Browser — use DecompressionStream (available in all modern browsers)
if (typeof DecompressionStream !== 'undefined') {
// DecompressionStream is async; we use a sync workaround via raw inflate
// Since DecompressionStream is stream-based, we need a sync path.
// Fall back to a tiny inline inflate implementation.
}
// Minimal sync inflate — use pako if available
if (typeof globalThis.pako !== 'undefined') {
return globalThis.pako.inflate(bytes);
}
throw new Error(
'Zlib decompression needed but no runtime support found. ' +
'In the browser, add pako: <script src="https://cdn.jsdelivr.net/npm/pako/dist/pako_inflate.min.js"></script> ' +
'or import pako and set globalThis.pako = pako. ' +
'Uncompressed VWP files (most real-time feeds) work without this.'
);
}
// ─── Constants ───────────────────────────────────────────────────────────────
const MSG_CODE_VWP = 48;
const PACKET_WIND_BARB = 0x0004;
const PACKET_TEXT = 0x0008;
const PACKET_VECTOR = 0x000A;
// Color code → RMS error (knots), per RPG ICD / vad-plotter
const COLOR_TO_RMS = { 1: 2, 2: 6, 3: 10, 4: 14, 5: 18 };
// 4/3 effective Earth radius in km (standard radar beam propagation model)
const R_E = (4 / 3) * 6371;
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Parse a NEXRAD Level III VWP product.
* @param {Uint8Array|ArrayBuffer|Buffer} raw - Raw file bytes.
* @returns {object} Structured wind profile data.
*/
function parseVWP(raw) {
const buf = unwrap(raw);
// ── Message Header Block (18 bytes) ──
const msgCode = buf.u16(0);
if (msgCode !== MSG_CODE_VWP) {
throw new Error(
`Not a VWP product (message code ${msgCode}, expected ${MSG_CODE_VWP}). ` +
`This parser only handles product 48 (VAD Wind Profile).`
);
}
// ── Product Description Block (102 bytes starting at offset 18) ──
const pdb = parsePDB(buf, 18);
const radarHeightKm = pdb.height * 0.0003048; // ft → km
// ── Try tabular block first (preferred — matches vad-plotter) ──
const tabOff = findBlock(buf, 3);
let tabData = null;
let metadata = {};
if (tabOff !== -1) {
const tabResult = parseTabularFull(buf, tabOff, radarHeightKm);
tabData = tabResult.winds;
metadata = tabResult.metadata;
}
// ── Fall back to symbology block if tabular is empty/missing ──
const symOff = findBlock(buf, 1);
let symData = null;
if (symOff !== -1) {
symData = parseSymbologyWinds(buf, symOff, pdb);
}
// Prefer tabular (like vad-plotter), fall back to symbology
const useTabular = tabData && tabData.length > 0;
const winds = useTabular ? tabData : (symData || []);
const source = useTabular ? 'tabular' : 'symbology';
// ── Group into profiles by time ──
const profiles = {};
for (const w of winds) {
if (!profiles[w.time]) profiles[w.time] = [];
profiles[w.time].push({
altitude_km_agl: w.altitude_km_agl,
altitude_ft_msl: w.altitude_ft_msl,
direction_deg: w.direction_deg,
speed_kts: w.speed_kts,
rms_kts: w.rms_kts,
divergence: w.divergence,
slant_range_nm: w.slant_range_nm,
elev_angle_deg: w.elev_angle_deg,
});
}
for (const key of Object.keys(profiles)) {
profiles[key].sort((a, b) => a.altitude_km_agl - b.altitude_km_agl);
}
// Ordered time list (most recent first)
const times = Object.keys(profiles).sort((a, b) => {
return parseInt(b, 10) - parseInt(a, 10);
});
// If we only got symbology, still try to get adaptation params from tabular
if (!useTabular && tabOff !== -1) {
const tabResult = parseTabularFull(buf, tabOff, radarHeightKm);
Object.assign(metadata, tabResult.metadata);
}
return {
radar: {
latitude: pdb.latitude,
longitude: pdb.longitude,
height_ft: pdb.height,
vcp: pdb.vcp,
mode: pdb.opMode === 1 ? 'clear_air' : 'precipitation',
},
scan: {
volume_time: nexradDate(pdb.volDate, pdb.volTime),
generation_time: nexradDate(pdb.genDate, pdb.genTime),
},
source,
times,
profiles,
metadata,
};
}
// ─── Unwrapping ──────────────────────────────────────────────────────────────
/**
* Remove transport headers and decompress a raw NEXRAD product when needed.
* @param {Uint8Array|ArrayBuffer|Buffer} raw Product data from the feed (in bytes).
* @returns {BufReader} Reader positioned at the NEXRAD message header.
*/
function unwrap(raw) {
let buf = new BufReader(raw);
// Strip text WMO/AWIPS header (lines ending \r\r\n)
const textEnd = findTextHeaderEnd(buf);
if (textEnd > 0) buf = buf.slice(textEnd, buf.length);
// Decompress zlib if present
if (buf.length > 2 && buf.bytes[0] === 0x78 &&
(buf.bytes[1] === 0xDA || buf.bytes[1] === 0x9C || buf.bytes[1] === 0x01)) {
const decompressed = inflate(buf.bytes);
buf = new BufReader(decompressed);
}
// After decompression there may be NOAAPORT routing + another WMO header.
if (buf.u16(0) !== MSG_CODE_VWP) {
const off = findMessageStart(buf);
if (off === -1) throw new Error('Could not locate VWP message header in buffer');
buf = buf.slice(off, buf.length);
}
return buf;
}
/**
* Locate the end of a leading WMO/AWIPS text header.
* @param {BufReader} buf Product bytes.
* @returns {number} Header end offset (in bytes), or 0 when no delimiter is found.
*/
function findTextHeaderEnd(buf) {
let last = 0;
const limit = Math.min(buf.length - 2, 300);
for (let i = 0; i < limit; i++) {
if (buf.bytes[i] === 0x0D && buf.bytes[i + 1] === 0x0D && buf.bytes[i + 2] === 0x0A) {
last = i + 3;
if (last < buf.length) {
const next = buf.bytes[last];
if (next === 0x78 || next === 0x00) return last;
}
}
}
return last;
}
/**
* Find a valid VWP message header inside a routed or padded byte buffer.
* @param {BufReader} buf Product bytes to search.
* @returns {number} Message start offset (in bytes), or -1 when no VWP message is found.
*/
function findMessageStart(buf) {
for (let i = 0; i < buf.length - 20; i++) {
if (buf.u16(i) === MSG_CODE_VWP) {
const date = buf.u16(i + 2);
if (date > 10000 && date < 35000) {
if (i + 18 < buf.length && buf.u16(i + 18) === 0xFFFF) {
return i;
}
}
}
}
return -1;
}
// ─── Product Description Block ───────────────────────────────────────────────
/**
* Read radar and volume metadata from a NEXRAD Product Description Block.
* @param {BufReader} buf Product bytes.
* @param {number} offset PDB start offset (in bytes).
* @returns {object} Radar metadata with latitude/longitude (in degrees), height (in ft), product/VCP codes, and volume/generation dates and times.
*/
function parsePDB(buf, offset) {
const o = offset;
return {
latitude: buf.i32(o + 2) / 1000,
longitude: buf.i32(o + 6) / 1000,
height: buf.i16(o + 10),
productCode: buf.u16(o + 12),
opMode: buf.u16(o + 14),
vcp: buf.u16(o + 16),
volDate: buf.u16(o + 22),
volTime: buf.u32(o + 24),
genDate: buf.u16(o + 28),
genTime: buf.u32(o + 30),
};
}
// ─── Block / Packet parsing ──────────────────────────────────────────────────
/**
* Find a length-validated NEXRAD product block by identifier.
* @param {BufReader} buf Product bytes.
* @param {number} blockId Block identifier (in unitless).
* @returns {number} Block offset (in bytes), or -1 when absent.
*/
function findBlock(buf, blockId) {
for (let i = 0; i < buf.length - 8; i += 2) {
if (buf.u16(i) === 0xFFFF && buf.u16(i + 2) === blockId) {
const len = buf.u32(i + 4);
if (len > 0 && len < 500000) return i;
}
}
return -1;
}
// ─── Symbology Block (fallback path) ─────────────────────────────────────────
/**
* Decode wind barbs and chart labels from the symbology block fallback.
* @param {BufReader} buf Product bytes.
* @param {number} offset Symbology block offset (in bytes).
* @param {object} pdb Parsed product description, including radar height (in ft).
* @returns {object[]} Wind levels with altitude (in km AGL and ft MSL), direction (in degrees), and speed/error (in kt).
*/
function parseSymbologyWinds(buf, offset, pdb) {
const blockLen = buf.u32(offset + 4);
const blockEnd = Math.min(offset + 8 + blockLen, buf.length);
let pos = offset + 16;
const windBarbs = [];
const textLabels = [];
const vectorPackets = [];
while (pos + 4 <= blockEnd) {
const code = buf.u16(pos);
const len = buf.u16(pos + 2);
if (len === 0 || len > 50000 || pos + 4 + len > buf.length) break;
const d = pos + 4;
if (code === PACKET_WIND_BARB && len === 10) {
windBarbs.push({
color: buf.u16(d),
i: buf.u16(d + 2),
j: buf.u16(d + 4),
direction_deg: buf.u16(d + 6),
speed_kts: buf.u16(d + 8),
});
} else if (code === PACKET_TEXT && len >= 8) {
textLabels.push({
color: buf.u16(d),
i: buf.u16(d + 2),
j: buf.u16(d + 4),
text: buf.ascii(d + 6, len - 6).trim(),
});
} else if (code === PACKET_VECTOR) {
const vectors = [];
for (let v = d + 2; v + 8 <= d + len; v += 8) {
vectors.push({
i0: buf.u16(v), j0: buf.u16(v + 2),
i1: buf.u16(v + 4), j1: buf.u16(v + 6),
});
}
vectorPackets.push({ color: buf.u16(d), vectors });
}
pos = d + len;
}
// ── Altitude map from tick marks + text labels ──
const altLabelsFileOrder = textLabels
.filter(l => (l.i === 31 || l.i < 40))
.map(l => parseInt(l.text, 10))
.filter(n => !isNaN(n) && n >= 1 && n <= 70);
let altMap = {};
if (vectorPackets.length >= 2 && altLabelsFileOrder.length > 0) {
const ticks = vectorPackets[1].vectors.map(v => v.j0).reverse();
if (ticks.length === altLabelsFileOrder.length) {
for (let k = 0; k < ticks.length; k++) {
altMap[ticks[k]] = altLabelsFileOrder[k];
}
}
}
if (Object.keys(altMap).length === 0) {
for (const l of textLabels) {
if (l.i === 31 || l.i < 40) {
const n = parseInt(l.text, 10);
if (!isNaN(n) && n >= 1 && n <= 70) altMap[l.j] = n;
}
}
}
// ── Time map ──
const timeRef = textLabels.find(l => l.text === 'TIME');
const timeJ = timeRef ? timeRef.j : 490;
const timeLabelMap = {};
for (const l of textLabels) {
if (/^\d{4}$/.test(l.text) && Math.abs(l.j - timeJ) < 20) {
timeLabelMap[l.i] = l.text;
}
}
const timeIs = Object.keys(timeLabelMap).map(Number).sort((a, b) => b - a);
// ── Map wind barbs → winds ──
const radarElFt = pdb.height;
const winds = [];
for (const barb of windBarbs) {
const timeI = nearestVal(timeIs, barb.i, 30);
if (timeI === null) continue;
const time = timeLabelMap[timeI];
const altJs = Object.keys(altMap).map(Number);
const altJ = nearestVal(altJs, barb.j, 20);
if (altJ === null) continue;
const altKft = altMap[altJ];
const altKmAgl = (altKft - radarElFt / 1000) / 3.281;
winds.push({
time,
altitude_km_agl: Math.round(altKmAgl * 1000) / 1000,
altitude_ft_msl: altKft * 1000,
direction_deg: barb.direction_deg,
speed_kts: barb.speed_kts,
rms_kts: COLOR_TO_RMS[barb.color] || null,
divergence: null,
slant_range_nm: null,
elev_angle_deg: null,
});
}
return winds;
}
// ─── Tabular Block (preferred path) ──────────────────────────────────────────
/**
* Decode the tabular VAD output and its adaptation metadata.
* @param {BufReader} buf Product bytes.
* @param {number} offset Tabular block offset (in bytes).
* @param {number} radarHeightKm Radar height above mean sea level (in km).
* @returns {{winds: object[], metadata: object}} Wind levels (altitude in km AGL/ft MSL, direction in degrees, speed/error in kt, range in nmi, elevation in degrees) and parsed thresholds.
*/
function parseTabularFull(buf, offset, radarHeightKm) {
const blockLen = buf.u32(offset + 4);
const blockEnd = Math.min(offset + 8 + blockLen, buf.length);
// Skip block header(8) + dup MHB(18) + dup PDB(102) + divider(2) = 130 bytes
let pos = offset + 130;
if (pos + 2 > blockEnd) return { winds: [], metadata: {} };
const numPages = buf.i16(pos);
pos += 2;
if (numPages <= 0 || numPages > 100) return { winds: [], metadata: {} };
const pages = [];
let currentPage = [];
while (pos + 2 <= blockEnd) {
const numChars = buf.i16(pos);
pos += 2;
if (numChars === -1) {
if (currentPage.length > 0) pages.push(currentPage);
currentPage = [];
continue;
}
if (numChars <= 0 || numChars > 200 || pos + numChars > blockEnd) {
pos += Math.max(numChars, 0);
continue;
}
currentPage.push(buf.ascii(pos, numChars));
pos += numChars;
}
if (currentPage.length > 0) pages.push(currentPage);
const winds = [];
const metadata = {};
for (const page of pages) {
if (page.length > 0 && page[0].includes('VAD Algorithm Output')) {
const timeMatch = page[0].match(/(\d{2}):(\d{2})/);
const time = timeMatch ? timeMatch[1] + timeMatch[2] : 'unknown';
for (let i = 3; i < page.length; i++) {
const line = page[i].trim();
if (!line || line.startsWith('-')) continue;
const vals = line.split(/\s+/);
if (vals.length < 10) continue;
const windDir = parseFloat(vals[4]);
const windSpd = parseFloat(vals[5]);
const rmsError = parseFloat(vals[6]);
const divStr = vals[7];
const divergence = divStr === 'NA' ? null : parseFloat(divStr);
const slantRange = parseFloat(vals[8]);
const elevAngle = parseFloat(vals[9]);
if (isNaN(windDir) || isNaN(windSpd) || isNaN(slantRange) || isNaN(elevAngle)) continue;
const slantKm = slantRange * 6067.1 / 3281.0;
const elevRad = elevAngle * Math.PI / 180;
const altKm = Math.sqrt(
R_E * R_E + slantKm * slantKm + 2 * R_E * slantKm * Math.sin(elevRad)
) - R_E;
const altKmAgl = Math.round(altKm * 1000) / 1000;
const altFtMsl = parseInt(vals[0], 10) * 100;
winds.push({
time,
altitude_km_agl: altKmAgl,
altitude_ft_msl: isNaN(altFtMsl) ? null : altFtMsl,
direction_deg: windDir,
speed_kts: windSpd,
rms_kts: isNaN(rmsError) ? null : rmsError,
divergence,
slant_range_nm: slantRange,
elev_angle_deg: elevAngle,
});
}
}
const fullText = page.join('\n');
const m1 = fullText.match(/RMS THRESHOLD\s+([\d.]+)\s+KNOTS/);
if (m1) metadata.rms_threshold_kts = parseFloat(m1[1]);
const m2 = fullText.match(/SYMMETRY THRESHOLD\s+([\d.]+)\s+KNOTS/);
if (m2) metadata.symmetry_threshold_kts = parseFloat(m2[1]);
const m3 = fullText.match(/DATA POINTS THRESHOLD\s+(\d+)/);
if (m3) metadata.data_points_threshold = parseInt(m3[1], 10);
const m4 = fullText.match(/OPTIMUM SLANT RANGE\s+([\d.]+)/);
if (m4) metadata.optimum_slant_range_nm = parseFloat(m4[1]);
}
return { winds, metadata };
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Find the array value nearest to a target when it is within the allowed distance.
* @param {number[]} arr Candidate numeric values
* @param {number} target Target value
* @param {number} maxDist Maximum accepted absolute distance
* @returns {number|null} Nearest candidate, or null when none is close enough.
*/
function nearestVal(arr, target, maxDist) {
let best = null;
let bestDist = Infinity;
for (const v of arr) {
const dist = Math.abs(v - target);
if (dist < bestDist) { bestDist = dist; best = v; }
}
return bestDist <= maxDist ? best : null;
}
/**
* Convert a NEXRAD epoch-based Julian day and seconds-of-day to an ISO timestamp.
* @param {number} julianDate One-based day count since 1970-01-01 UTC (in days).
* @param {number} seconds Seconds elapsed in the day (in seconds).
* @returns {string} UTC timestamp in ISO 8601 format.
*/
function nexradDate(julianDate, seconds) {
// NEXRAD Julian date is 1-based (day 1 = 1970-01-01 UTC); Unix ms uses 0-based day count.
// Matches e.g. MetPy nexrad_to_datetime(julian_date - 1, ...).
const dayIndex = Math.max(0, julianDate - 1);
const ms = dayIndex * 86400000 + seconds * 1000;
return new Date(ms).toISOString();
}
// ─── Exports (dual CJS/ESM) ─────────────────────────────────────────────────
export { parseVWP };