Source: io/arrays.js

/**
 * Plain arrays → SharpJS sounding. This is the entry point for apps that already have a profile
 * in memory (model output, a radiosonde decoder, a database row, …).
 */
import { DewFromTRh } from '../calc/skewtCalcs.js';
import { Process } from './sharppy.js';
import { withLocation } from './location.js';

const MISSING = -9999;
const KT_PER = { kt: 1, 'm/s': 1.943844, mph: 0.868976, 'km/h': 0.539957 };

const num = (v) => (v === null || v === undefined || v === '' ? NaN : Number(v));
const ok = (v) => Number.isFinite(v) && v !== MISSING;

/**
 * Convert a temperature value from the selected unit to Celsius.
 * @param {number} v Temperature value (in the unit named by unit).
 * @param {string} unit Input temperature unit: C, K, or F.
 * @returns {number} Temperature (in °C), or NaN when v is invalid.
 */
function toCelsius(v, unit) {
  if (!ok(v)) return NaN;
  if (unit === 'K') return v - 273.15;
  if (unit === 'F') return ((v - 32) * 5) / 9;
  return v;
}

/** Heights (m) from the hypsometric equation, integrating up from `elevation` at the first level. */
function hypsometricHeights(p, tC, elevation) {
  const h = new Array(p.length).fill(NaN);
  h[0] = elevation;
  for (let i = 1; i < p.length; i++) {
    const t0 = ok(tC[i - 1]) ? tC[i - 1] : tC[i];
    const t1 = ok(tC[i]) ? tC[i] : t0;
    const tMeanK = ((ok(t0) ? t0 : 15) + (ok(t1) ? t1 : 15)) / 2 + 273.15;
    h[i] = h[i - 1] + (287.05 / 9.80665) * tMeanK * Math.log(p[i - 1] / p[i]);
  }
  return h;
}

/**
 * Build a sounding from parallel arrays (one entry per level, any vertical order).
 *
 * @param {object} profile
 * @param {number[]} profile.pressure          hPa (mb), required
 * @param {number[]} [profile.height]          m above sea level (computed from T and p when omitted)
 * @param {number[]} profile.temperature       °C unless options.units.temperature says otherwise
 * @param {number[]} [profile.dewpoint]        °C (same unit as temperature) …
 * @param {number[]} [profile.relativeHumidity] … or relative humidity in %
 * @param {number[]} [profile.windDirection]   degrees the wind blows from …
 * @param {number[]} [profile.windSpeed]       … and speed (kt unless options.units.windSpeed)
 * @param {number[]} [profile.u]               … or u/v components (m/s unless options.units.windSpeed)
 * @param {number[]} [profile.v]
 * @param {number[]} [profile.omega]           vertical velocity, Pa/s
 * @param {object} [options]
 * @param {string} [options.title]
 * @param {{ lat: number, lon: number }} [options.location]
 * @param {number} [options.elevation=0]       surface height (m) when heights must be computed
 * @param {{ temperature?: 'C'|'K'|'F', windSpeed?: 'kt'|'m/s'|'mph'|'km/h' }} [options.units]
 * @param {boolean} [options.isModel]          gridded model data: skip the hodograph thinning used for
 *                                             high-resolution observations
 * @param {boolean} [options.isDropsonde]      use the dropsonde layout
 * @returns {object} sounding
 */
export function fromArrays(profile, options = {}) {
  if (!profile || !Array.isArray(profile.pressure)) throw new Error('SharpJS: fromArrays needs a pressure array');
  const n = profile.pressure.length;
  if (!Array.isArray(profile.temperature) || profile.temperature.length !== n) {
    throw new Error('SharpJS: temperature must be an array the same length as pressure');
  }
  const units = options.units || {};
  const hasUV = Array.isArray(profile.u) && Array.isArray(profile.v);
  const speedUnit = units.windSpeed || (hasUV && !Array.isArray(profile.windSpeed) ? 'm/s' : 'kt');
  const toKt = KT_PER[speedUnit];
  if (!toKt) throw new Error(`SharpJS: unknown wind speed unit ${speedUnit}`);

  let levels = [];
  for (let i = 0; i < n; i++) {
    const p = num(profile.pressure[i]);
    if (!ok(p) || p <= 0 || p > 1100) continue;
    const t = toCelsius(num(profile.temperature[i]), units.temperature);
    let d = NaN;
    if (Array.isArray(profile.dewpoint)) {
      d = toCelsius(num(profile.dewpoint[i]), units.temperature);
    } else if (Array.isArray(profile.relativeHumidity) && ok(t)) {
      const rh = num(profile.relativeHumidity[i]);
      if (ok(rh) && rh > 0) d = DewFromTRh(t, Math.min(100, rh));
    }
    let wd = NaN;
    let ws = NaN;
    if (Array.isArray(profile.windDirection) && Array.isArray(profile.windSpeed)) {
      wd = num(profile.windDirection[i]);
      ws = num(profile.windSpeed[i]) * toKt;
    } else if (hasUV) {
      const u = num(profile.u[i]);
      const v = num(profile.v[i]);
      if (ok(u) && ok(v)) {
        ws = Math.hypot(u, v) * toKt;
        wd = ((Math.atan2(-u, -v) * 180) / Math.PI + 360) % 360;
      }
    }
    const om = Array.isArray(profile.omega) ? num(profile.omega[i]) : NaN;
    levels.push({ p, h: Array.isArray(profile.height) ? num(profile.height[i]) : NaN, t, d, wd, ws, om });
  }
  levels.sort((a, b) => b.p - a.p);
  levels = levels.filter((l, i) => i === 0 || l.p < levels[i - 1].p);
  if (levels.length < 2) throw new Error('SharpJS: need at least two levels with valid pressure');

  if (!levels.some((l) => ok(l.h))) {
    const hs = hypsometricHeights(levels.map((l) => l.p), levels.map((l) => l.t), num(options.elevation) || 0);
    levels.forEach((l, i) => { l.h = hs[i]; });
  }

  const cell = (v, digits = 4) => (ok(v) ? String(Math.round(v * 10 ** digits) / 10 ** digits) : String(MISSING));
  const title = options.title || 'Custom Sounding';
  const rows = levels.map((l) => ` ${cell(l.p)},\t${cell(l.h)},\t${cell(l.t)},\t${cell(l.d)},\t${cell(l.wd)},\t${cell(l.ws)}`);
  const text = `%TITLE%
${title}

     LEVEL       HGHT       TEMP       DWPT       WDIR       WSPD
-------------------------------------------------------------------
%RAW%
${rows.join('\n')}
%END%`;
  const sounding = Process(text);
  sounding.Title = title;
  sounding.STitle = title;

  // Omega in the scale the chart's omega bars expect: -omega(Pa/s) / 50.
  const omegas = levels.map((l) => (ok(l.om) ? Math.round(200 * -l.om) / 10000 : 0));
  if (sounding.Sounding.Slice.length === levels.length) {
    sounding.Sounding.OMEGA = omegas;
    sounding.Sounding.Slice.forEach((row, i) => { row[7] = omegas[i]; });
  }

  if (options.location) withLocation(sounding, options.location);
  if (options.isDropsonde) sounding.isDropsonde = true;
  sounding.meta = { source: 'arrays', isModel: options.isModel === true };
  return sounding;
}