/**
* BUFKIT (model point forecast) files → SharpJS sounding objects.
*
* Column order is read from the file's SNPARM header, so any model's BUFKIT
* layout works (RAP/HRRR/NAM include CFRL, GFS does not).
* Pure functions: no DOM, no network.
*/
import { RelHum, DewFromTRh, wetbulb, GetLayerByPres, VirtTemp } from '../calc/skewtCalcs.js';
import { Process } from './sharppy.js';
import { pad } from './general.js';
const DEFAULT_COLUMNS = ['PRES', 'TMPC', 'TMWC', 'DWPC', 'THTE', 'DRCT', 'SKNT', 'OMEG', 'CFRL', 'HGHT'];
const GFS_COLUMNS = ['PRES', 'TMPC', 'TMWC', 'DWPC', 'THTE', 'DRCT', 'SKNT', 'OMEG', 'HGHT'];
/** Model id from a BUFKIT file name, e.g. "rap_kdsm.buf" → "rap", "gfs3_kdsm.buf" → "gfs". */
function modelFromFileName(fileName) {
const m = /(?:^|[\\/])([a-z0-9]+)_[a-z0-9]+\.buf$/i.exec(String(fileName || ''));
if (!m) return null;
const id = m[1].toLowerCase();
if (id === 'gfs3') return 'gfs';
if (id === 'namm') return 'nam';
return id;
}
/** True when the text looks like a BUFKIT file. */
export function isBufkitText(text) {
return typeof text === 'string' && text.includes('STID') && text.includes('STNM') && /STIM\s*=/.test(text);
}
/** "KEY = value" pairs from a BUFKIT station block. An empty value (e.g. "STID = STNM = 1") stays ''. */
function readKeyValues(block) {
const tokens = block.split(/\s+/).filter(Boolean);
const out = {};
for (let i = 0; i + 1 < tokens.length; i++) {
if (tokens[i + 1] !== '=') continue;
const value = tokens[i + 2];
out[tokens[i]] = value !== undefined && tokens[i + 3] !== '=' ? value : '';
i += 1;
}
return out;
}
/**
* Split BUFKIT text into station forecast sections and trim trailing station metadata.
* @param {string} text BUFKIT file contents.
* @returns {string[]} Forecast section strings in file order.
*/
function splitSections(text) {
const sections = text.split('STID').slice(1).map((s) => `STID${s}`);
if (sections.length > 0) {
const last = sections[sections.length - 1];
const sfc = last.indexOf('STN YYMMDD/HHMM');
if (sfc !== -1) sections[sections.length - 1] = last.substring(0, sfc);
}
return sections;
}
/**
* Read the forecast lead time from a BUFKIT section.
* @param {string} section BUFKIT forecast section.
* @returns {number} Forecast lead time (in hours), or NaN when no STIM field is present.
*/
function forecastHourOf(section) {
const m = /STIM\s*=\s*(-?[\d.]+)/.exec(section);
return m ? parseFloat(m[1]) : NaN;
}
/** Forecast hours available in a BUFKIT file, in file order. */
export function listBufkitForecastHours(text) {
return splitSections(text).map(forecastHourOf).filter((h) => Number.isFinite(h));
}
/** "240526/1200" → Date (UTC). */
function parseBufkitTime(value) {
const m = /^(\d{2})(\d{2})(\d{2})\/(\d{2})(\d{2})$/.exec(String(value || ''));
if (!m) return null;
return new Date(Date.UTC(2000 + parseInt(m[1], 10), parseInt(m[2], 10) - 1, parseInt(m[3], 10), parseInt(m[4], 10), parseInt(m[5], 10)));
}
const mdy = (d) => `${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}-${pad(d.getUTCFullYear(), 4)}`;
/**
* Parse one forecast hour of a BUFKIT file.
*
* @param {string} text
* @param {object} [options]
* @param {number} [options.forecastHour] STIM to pull (default: first forecast in the file)
* @param {string} [options.model] model name for the title (default: from fileName, else "BUFKIT")
* @param {string} [options.station] station id for files with an empty STID
* @param {string} [options.fileName] e.g. "rap_kdsm.buf"
* @returns {object} sounding
*/
export function parseBufkit(text, options = {}) {
if (!isBufkitText(text)) throw new Error('SharpJS: not a BUFKIT file');
const headerEnd = text.indexOf('STID');
const snparm = /SNPARM\s*=\s*([A-Z0-9;]+)/.exec(text.slice(0, headerEnd));
const model = String(options.model || modelFromFileName(options.fileName) || 'bufkit').toLowerCase();
const columns = snparm ? snparm[1].split(';') : (model === 'gfs' ? GFS_COLUMNS : DEFAULT_COLUMNS);
const col = (name) => columns.indexOf(name);
for (const name of ['PRES', 'TMPC', 'DWPC', 'DRCT', 'SKNT', 'HGHT']) {
if (col(name) === -1) throw new Error(`SharpJS: BUFKIT file has no ${name} column`);
}
const sections = splitSections(text);
if (sections.length === 0) throw new Error('SharpJS: no forecasts found in BUFKIT file');
const wantHour = options.forecastHour != null ? parseFloat(options.forecastHour) : forecastHourOf(sections[0]);
const Selected = sections.find((s) => forecastHourOf(s) === wantHour);
if (!Selected) throw new Error(`SharpJS: F${wantHour} not found in BUFKIT file`);
const FHour = wantHour;
const hghtIndex = Selected.lastIndexOf('HGHT');
const presIndex = Selected.lastIndexOf('PRES');
if (hghtIndex === -1 || presIndex === -1) throw new Error('SharpJS: corrupt BUFKIT section (missing header)');
const meta = readKeyValues(Selected.slice(0, presIndex));
if (!meta.STID && options.station) meta.STID = String(options.station).toUpperCase();
// Every level is columns.length numbers; files wrap them over two lines.
const values = Selected.slice(hghtIndex + 4).split(/\s+/).filter(Boolean).map(parseFloat);
const rows = [];
for (let i = 0; i + columns.length <= values.length; i += columns.length) {
rows.push(values.slice(i, i + columns.length));
}
if (rows.length < 2) throw new Error('SharpJS: BUFKIT section has fewer than two levels');
const iP = col('PRES'), iT = col('TMPC'), iD = col('DWPC'), iWD = col('DRCT'), iWS = col('SKNT'), iH = col('HGHT'), iO = col('OMEG');
// Above the last moist level, hold the last relative humidity (same as the IEM RAOB path).
let LastMoisture = 0;
let LastRH = 0;
for (let x = 0; x < rows.length; x++) {
if (rows[x][iD] !== -9999) {
LastMoisture = x;
LastRH = RelHum(rows[x][iT], rows[x][iD]);
}
}
const Slices = rows.map((r, x) => {
let DWPT = r[iD];
if (x >= LastMoisture) DWPT = DewFromTRh(r[iT], LastRH);
const omega = iO !== -1 && r[iO] !== -9999 ? r[iO] / 30 : 0;
return [r[iP], r[iH], r[iT], DWPT, r[iWD], r[iWS], omega];
});
Slices.sort((a, b) => b[0] - a[0]);
let InfoString = '';
for (const P of Slices) {
InfoString += ` ${P[0]},\t${P[1]},\t${P[2]},\t${P[3]},\t${P[4]},\t${P[5]}\n`;
}
const valid = parseBufkitTime(meta.TIME);
const run = valid ? new Date(valid.getTime() - FHour * 3600 * 1000) : null;
const station = meta.STID || '';
const Title = run
? `${model.toUpperCase()} ${mdy(run)} ${pad(run.getUTCHours())}Z - F${pad(FHour, 3)}; Valid: ${mdy(valid)} ${pad(valid.getUTCHours())}Z; Location: ${meta.SLAT}N, ${meta.SLON}W`
: `${model.toUpperCase()} ${station} F${pad(FHour, 3)}`;
const text2 = `%TITLE%
${Title}
Saved by: SharpJS
LEVEL HGHT TEMP DWPT WDIR WSPD
-------------------------------------------------------------------
%RAW%
${InfoString}%END%`;
const ProcessedSounding = Process(text2);
const lat = parseFloat(meta.SLAT);
const lon = parseFloat(meta.SLON);
if (Number.isFinite(lat) && Number.isFinite(lon)) {
// Location keeps the original SharpJS BUFKIT convention (west-positive longitude).
ProcessedSounding.Location = [lat, -lon];
ProcessedSounding.Location2 = ProcessedSounding.Location;
ProcessedSounding.Coords = { lat, lon };
}
ProcessedSounding.Sounding.OMEGA = Slices.map((k) => k[6]);
for (let x = 0; x < ProcessedSounding.Sounding.Slice.length; x++) {
ProcessedSounding.Sounding.Slice[x].push(Slices[x][6]);
ProcessedSounding.Sounding.Slice[x][6] = wetbulb(ProcessedSounding.Sounding.Slice[x][0], ProcessedSounding.Sounding.Slice[x][2], ProcessedSounding.Sounding.Slice[x][3]);
}
for (let x = 0; x < ProcessedSounding.Sounding.TW.length; x++) {
const TWI = ProcessedSounding.Sounding.TW[x];
const Layer = GetLayerByPres(ProcessedSounding.Sounding, TWI.P);
TWI.WB = wetbulb(Layer.P, Layer.T, Layer.D);
TWI.VT = VirtTemp(Layer, ProcessedSounding.Sounding.P[0], false, true);
}
ProcessedSounding.meta = {
source: 'bufkit',
model,
station,
forecastHour: FHour,
valid: valid ? valid.toISOString() : null,
run: run ? run.toISOString() : null,
};
return ProcessedSounding;
}