/**
* Headless sounding analysis — the "do the heavy lifting for me" API.
*
* import { parseSounding, analyze } from 'sharpjs/core';
* const result = analyze(parseSounding(text), { parcel: 'ML', motion: 'RM' });
* result.parcels.ML.cape, result.kinematics.layers['0-1km'].srh, result.indices.stpEffective, …
*
* Uses exactly the calculations the chart draws (calc/params.js), with no DOM.
*/
import { GetBM, GetLayerByPres, GetLayerByHght, LiftParcel, UpdateCSMotStorm, DrawInsetParams, ConvertPolartoCart } from './calc/skewtCalcs.js';
import { computeThermo, computeWindParams } from './calc/params.js';
import { toSounding } from './io/input.js';
const MISSING = -9999;
const MOTIONS = ['RM', 'LM', 'MM', 'US', 'DS', 'DTMR', 'DTML'];
const round = (v, digits = 1) => (Number.isFinite(v) && v !== MISSING ? Math.round(v * 10 ** digits) / 10 ** digits : null);
/**
* Wind vector (knots, as produced by the calc functions) → direction it blows from and speed.
* Same conversion the chart uses for its labels.
*/
function polar(vec) {
if (!vec || typeof vec !== 'object' || !Number.isFinite(vec.x) || !Number.isFinite(vec.y)) return null;
const direction = ((Math.atan2(vec.y, vec.x) * 180) / Math.PI + 270 + 360) % 360;
return { direction: round(direction, 0), speed: round(Math.hypot(vec.x, vec.y), 1) };
}
/** Storm motions are kept with y flipped (screen space) once lifted into the chart; undo that. */
const polarMotion = (vec) => (vec ? polar({ x: vec.x, y: -vec.y }) : null);
/**
* Convert chart-space storm motion vectors back to meteorological y orientation.
* @param {object} motions Motion vectors keyed by storm-motion identifier; components are in kt.
* @returns {object} A new motion map with each y component sign reversed.
*/
function flipY(motions) {
const out = {};
for (const key of MOTIONS) out[key] = { x: motions[key].x, y: -motions[key].y };
return out;
}
/**
* Select and round the public diagnostic fields from a parcel calculation.
* @param {object|null} p Parcel result, including thermodynamic diagnostics.
* @returns {object|null} Rounded parcel fields: pressure (in hPa), heights (in m), CAPE/CIN (in J/kg), and NCAPE (in UNK); or null.
*/
function summarizeParcel(p) {
if (!p) return null;
return {
startPressure: round(p.Start && p.Start.P, 1),
startHeight: round(p.Start && p.Start.H, 0),
cape: round(p.Cape, 0),
cin: round(p.CINH, 0),
cape3km: round(p.Cape3, 0),
cape6km: round(p.Cape6, 0),
ecape: round(p.ECape && p.ECape.Cape, 0),
ncape: round(p.NCape, 3),
lcl: round(p.LCL, 0),
lfc: round(p.LFC, 0),
el: round(p.EL, 0),
mpl: round(p.MPL, 0),
};
}
/**
* Normalize signed storm-relative helicity data to its rounded total.
* @param {number|object|null} srh A precomputed total or a result with SRHP and SRHM (in m²/s²).
* @returns {number|null} Total SRH (in m²/s²), rounded to an integer, or null when unavailable.
*/
function srhTotal(srh) {
if (typeof srh === 'number') return round(srh, 0);
if (!srh || !Number.isFinite(srh.SRHP)) return null;
return round(srh.SRHP + srh.SRHM, 0);
}
const LAYERS = {
'0-500m': '005',
'0-1km': '01',
'0-3km': '03',
'0-6km': '06',
'1-3km': '13',
'3-6km': '36',
'0-9km': '09',
'0-12km': '012',
'9-12km': '912',
'sfc-SBLCL': '0SBLCL',
'sfc-MLLFC': '0MLLFC',
'sfc-700mb': '0mb700',
'sfc-500mb': '0mb500',
'sfc-300mb': '0mb300',
'effBot-1km': 'Eb01',
'effBot-3km': 'Eb03',
'effBot-6km': 'Eb06',
'DGZ': 'DGZ',
'HGZ': 'HGZ',
};
/**
* Analyze a sounding.
*
* @param {string|object} input sounding text, a parsed sounding, or arrays for fromArrays()
* @param {object} [options]
* @param {'MU'|'SB'|'ML'|'CU'} [options.parcel='MU'] parcel used for EHI
* @param {number} [options.parcelPressure=900] start pressure for parcel "CU"
* @param {'RM'|'LM'|'MM'|'US'|'DS'|'DTMR'|'DTML'|{direction:number,speed:number}} [options.motion='RM']
* storm motion for SRH / ECAPE (a custom motion is degrees-from and knots)
* @returns {object} see README "Headless analysis"
*/
export function analyze(input, options = {}) {
const sounding = toSounding(input);
const Sound = sounding.Sounding;
const motions = flipY(GetBM(Sound));
let CSMot;
if (options.motion && typeof options.motion === 'object') {
const c = ConvertPolartoCart({ WD: Number(options.motion.direction) || 0, WS: Number(options.motion.speed) || 0 });
CSMot = { x: c.x, y: -c.y };
} else {
CSMot = motions[MOTIONS.includes(options.motion) ? options.motion : 'RM'];
}
UpdateCSMotStorm(CSMot);
const Info = computeThermo(Sound);
const parcelKey = options.parcel || 'MU';
let DP = Info.MUParcel;
if (parcelKey === 'SB') DP = Info.SBParcel;
else if (parcelKey === 'ML') DP = Info.MLParcel;
else if (parcelKey === 'CU') DP = LiftParcel(Sound, 'CU', Number(options.parcelPressure) || 900);
const Wnd = computeWindParams(sounding, Info, DP, CSMot);
const inset = DrawInsetParams(sounding, Wnd, { DCapeProf: Info.DCapeProf }, Info.SBParcel, Info.MLParcel, Info.MUParcel, true, {
WindLapse: 'Default',
Inset: 'Default',
Vertical: 'Default',
});
const layers = {};
for (const [name, key] of Object.entries(LAYERS)) {
if (!(`SRH${key}` in Wnd)) continue;
const mean = Wnd.MeanWind[`M${key}`];
layers[name] = {
srh: srhTotal(Wnd[`SRH${key}`]),
bulkShear: polar(Wnd[`BWD${key}`]),
meanWind: polar(mean),
stormRelativeWind: mean && typeof mean === 'object' ? polar({ x: mean.x - CSMot.x, y: mean.y + CSMot.y }) : null,
ehi: round(Wnd[`EHI${key}`], 1),
};
}
const sfc = GetLayerByPres(Sound, 'SFC');
const eff = Info.EffInflow;
const effBottom = eff ? GetLayerByPres(Sound, eff.Bot) : null;
const effTop = eff ? GetLayerByPres(Sound, eff.Top) : null;
/**
* Read an inset diagnostic and normalize numeric strings and missing values.
* @param {string} k Inset field key.
* @returns {number|null} Finite diagnostic value (in UNK), or null when absent or invalid.
*/
const pick = (k) => {
if (!inset || !(k in inset) || inset[k] === '--') return null;
const v = inset[k];
if (typeof v === 'string') {
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
}
return Number.isFinite(v) ? v : null;
};
const motionOut = {};
for (const key of MOTIONS) motionOut[key] = polarMotion(motions[key]);
return {
title: sounding.Title ? String(sounding.Title).trim() : '',
surface: {
pressure: round(sfc.P, 1),
height: round(sfc.H, 0),
temperature: round(sfc.T, 1),
dewpoint: round(sfc.D, 1),
windDirection: round(sfc.WD, 0),
windSpeed: round(sfc.WS, 0),
},
parcels: {
SB: summarizeParcel(Info.SBParcel),
ML: summarizeParcel(Info.MLParcel),
MU: summarizeParcel(Info.MUParcel),
...(parcelKey === 'CU' ? { CU: summarizeParcel(DP) } : {}),
},
downdraft: {
dcape: round(Info.DCapeProf && Info.DCapeProf.DCape, 0),
downdraftTempF: round(Info.DCapeProf && Info.DCapeProf.DownT, 0),
},
stormMotion: { used: polarMotion(CSMot), ...motionOut },
effectiveInflow: eff
? {
bottomPressure: round(eff.Bot, 1),
topPressure: round(eff.Top, 1),
bottomHeightAgl: round(effBottom.H - sfc.H, 0),
topHeightAgl: round(effTop.H - sfc.H, 0),
srh: srhTotal(Wnd.ESRH),
/** shear across the inflow layer (the chart's "Eff Inflow" row) */
bulkShear: polar(Wnd.BWDE),
/** effective bulk wind difference: inflow base to half the parcel EL height */
ebwd: polar(Wnd.EBWD),
meanWind: polar(Wnd.MeanWind.ME),
stormRelativeWind: Wnd.MeanWind.ME && typeof Wnd.MeanWind.ME === 'object'
? polar({ x: Wnd.MeanWind.ME.x - CSMot.x, y: Wnd.MeanWind.ME.y + CSMot.y })
: null,
ehi: round(Wnd.EHIE, 1),
}
: null,
kinematics: { layers },
indices: {
stpFixed: pick('STP (fixed)'),
stpEffective: pick('STP (cin)'),
scp: pick('Supercell Comp'),
ship: pick('SHIP'),
sigSevere: pick('SigSvr'),
wndg: pick('WNDG'),
derecho: pick('Derecho Comp'),
pwat: pick('PWAT'),
kIndex: pick('K Index'),
totalTotals: pick('TT Index'),
freezingLevel: pick('Freezing Level'),
meanMixingRatio: pick('Mean Mix Rat'),
convectiveTempF: pick('ConvT'),
maxTempF: pick('MaxT'),
lclTempF: pick('LCL Temp'),
theta3kmDiff: pick('3km Theta Diff'),
rhSurface: pick('Sfc RelH'),
rhLow: pick('Low RelH'),
rhMid: pick('Mid RelH'),
rhDgzMean: pick('Avg DGZ RH'),
},
/** Raw engine structures, for anything the summary above leaves out. */
raw: { Info, WindParams: Wnd, inset, motions, sounding },
};
}
/** Interpolated level at a pressure (mb) or height (m AGL with { agl: true }). */
export function levelAt(input, { pressure, height, agl = true } = {}) {
const Sound = toSounding(input).Sounding;
let lvl;
if (pressure != null) lvl = GetLayerByPres(Sound, Number(pressure));
else if (height != null) {
// GetLayerByHght measures from the first level unless told the height is already MSL.
lvl = GetLayerByHght(Sound, Number(height), agl);
} else throw new Error('SharpJS: levelAt needs pressure or height');
return {
pressure: round(lvl.P, 1),
height: round(lvl.H, 0),
temperature: round(lvl.T, 2),
dewpoint: round(lvl.D, 2),
windDirection: round(lvl.WD, 0),
windSpeed: round(lvl.WS, 1),
wetbulb: round(lvl.WB, 2),
virtualTemperature: round(lvl.VT, 2),
};
}