Source: io/vwp.js

/**
 * NEXRAD VAD Wind Profile (Level III product 48, "NVW") → SharpJS sounding.
 *
 * Pure functions: no DOM, no network. Fetching NVW files is up to the host
 * (see src/sources/nexrad.js for a public Unidata S3 example).
 */
import { GetLayerByHght, GetLayerByPres } from '../calc/skewtCalcs.js';
import { parseVWP } from './nexrad-vwp-parser.js';

export { parseVWP };

/** @returns {{ volumeUnix: number, title: string }} */
function vwpVolumeMetaFromParse(P, radarLabel) {
  const isoString = P.scan.volume_time;
  const date = new Date(isoString);
  const volumeUnix = Math.floor(date.getTime() / 1000);
  const components = {
    year: date.getUTCFullYear(),
    day: date.getUTCDate(),
    hours: date.getUTCHours(),
    minutes: date.getUTCMinutes(),
  };
  const utcMonthAbbrevs = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  const mon = utcMonthAbbrevs[date.getUTCMonth()];
  const hm = `${String(components.hours).padStart(2, '0')}:${String(components.minutes).padStart(2, '0')}`;
  const title = `VWP ${radarLabel} — valid ${components.day} ${mon} ${components.year} ${hm} UTC`;
  return { volumeUnix, title };
}

/**
 * Calculate the signed shortest angular difference between two wind directions.
 * @param {number} dir1 First wind direction (in degrees).
 * @param {number} dir2 Second wind direction (in degrees).
 * @returns {number} Signed angular difference (in degrees).
 */
function windDirDiff(dir1, dir2) {
    let diff = (dir1 - dir2 + 180) % 360 - 180;
    return diff < -180 ? diff + 360 : diff;
}

const avgAngle = (a, b) => {
  const diff = ((b - a + 540) % 360) - 180;
  return (a + diff / 2 + 360) % 360;
};

/**
 * Populate a VWP sounding's wind and thermodynamic profile columns from parsed radar levels.
 * @param {object} VWPSounding Sounding structure to update in place.
 * @param {object} P Parsed VWP product containing times, profiles, and wind levels.
 * @returns {void} Updates the sounding's wind, pressure, height, temperature, moisture, and slice columns.
 */
function fillVwpWindColumnsFromParse(VWPSounding, P) {
  const rProf = P.profiles[P.times[0]];
  let sW = [];
  let sSlice = [];
  let sTW = [];
  let sDW = [];
  let sP = [];
  let sT = [];
  let sO = [];
  let sH = [];
  let sD = [];

  // Hodograph: first point = surface (H=0 AGL); second = lowest NEXRAD gate. Slice[0][1]=0 keeps
  // GetLayerByHght(..., aglM, addTo) aligned with true AGL after this fill.
  const surfLayer = GetLayerByHght(VWPSounding.Sounding, 'SFC');
  const surfH_m = 0;
  sH.push(surfH_m);
  sP.push(surfLayer.P);
  sT.push(surfLayer.T);
  sO.push(surfLayer.OM);
  sD.push(surfLayer.D);
  sW.push({
    WD: surfLayer.WD,
    WS: surfLayer.WS,
    H: surfH_m,
  });
  sSlice.push([
    surfLayer.P,
    surfH_m,
    surfLayer.T,
    surfLayer.D,
    surfLayer.WD,
    surfLayer.WS,
    surfLayer.WB,
    surfLayer.OM,
  ]);
  sDW.push({
    H: surfH_m,
    P: surfLayer.P,
    D: surfLayer.D,
  });
  sTW.push({
    H: surfH_m,
    P: surfLayer.P,
    T: surfLayer.T,
    WB: surfLayer.WB,
    VT: surfLayer.VT,
  });

  for (let i = 0; i < rProf.length; i++) {
    const ProfLyr = rProf[i];
    const Layer = GetLayerByHght(VWPSounding.Sounding, ProfLyr.altitude_km_agl * 1000);

    if (sH.includes(ProfLyr.altitude_km_agl * 1000)) {
      continue;
    }

    if (i <= 0){
      if (Math.abs(ProfLyr.speed_kts - surfLayer.WS) > 30 || windDirDiff(ProfLyr.direction_deg, surfLayer.WD) > 30){
        ProfLyr.speed_kts = (rProf[1]== undefined  ? 0 : rProf[1].speed_kts + surfLayer.WS)/2; //(ProfLyr.speed_kts + Layer.WS)/2;
        ProfLyr.direction_deg = rProf[1] == undefined ? surfLayer.WD : rProf[1].direction_deg; //avgAngle(ProfLyr.direction_deg, Layer.WD);
      }
    }

    sH.push(ProfLyr.altitude_km_agl * 1000);
    sP.push(Layer.P);
    sT.push(Layer.T);
    sO.push(Layer.OM);
    sD.push(Layer.D);

    sW.push({
      WD: ProfLyr.direction_deg,
      WS: ProfLyr.speed_kts,
      ...(ProfLyr.rms_kts != null && Number.isFinite(ProfLyr.rms_kts)
        ? { ERR_KTS: ProfLyr.rms_kts }
        : {}),
      H: ProfLyr.altitude_km_agl * 1000,
    });
    sSlice.push([
      Layer.P,
      ProfLyr.altitude_km_agl * 1000,
      Layer.T,
      Layer.D,
      ProfLyr.direction_deg,
      ProfLyr.speed_kts,
      Layer.WB,
      Layer.OM,
    ]);

    sDW.push({
      H: ProfLyr.altitude_km_agl * 1000,
      P: Layer.P,
      D: Layer.D,
    });

    sTW.push({
      H: ProfLyr.altitude_km_agl * 1000,
      P: Layer.P,
      T: Layer.T,
      WB: Layer.WB,
      VT: Layer.VT,
    });
  }

  // Surface row (H=0) uses SFC thermo but if its wind differs from the lowest radar gate, the first
  // hodograph segment is a visible spur toward the plot center. Default: match lowest gate; nearest-ASOS
  // METAR patch overwrites W[0]/Slice[0] later when enabled.
  if (sW.length > 1 && sSlice.length > 1) {
    sW[0].WD = sW[1].WD;
    sW[0].WS = sW[1].WS;
    delete sW[0].ERR_KTS;
    sSlice[0][4] = sSlice[1][4];
    sSlice[0][5] = sSlice[1][5];
  }

  VWPSounding.Sounding.W = sW;
  VWPSounding.Sounding.T = sT;
  VWPSounding.Sounding.P = sP;
  VWPSounding.Sounding.H = sH;
  VWPSounding.Sounding.D = sD;
  VWPSounding.Sounding.OMEGA = sO;
  VWPSounding.Sounding.TW = sTW;
  VWPSounding.Sounding.DW = sDW;
  VWPSounding.DWL = sDW.length;
  VWPSounding.TWL = sTW.length;
  VWPSounding.WWL = sW.length;
  VWPSounding.Sounding.Slice = sSlice;
  VWPSounding.End = GetLayerByPres(VWPSounding.Sounding, 'Last');
}


/** ISA-style thermo column for VWP-only soundings. Winds still come from the NVW product. */
function buildNexradVwpSkeletonSoundingFromParse(P, snding) {
  const rProf = P.profiles && P.times && P.times[0] ? P.profiles[P.times[0]] : null;
  if (!rProf || rProf.length === 0) return null;

  const h0Msl =
    (P.radar && typeof P.radar.height_ft === 'number' ? P.radar.height_ft : 300) * 0.3048;
  const aglMaxM = Math.max(...rProf.map((l) => l.altitude_km_agl * 1000));
  const topMsl = h0Msl + aglMaxM + 3500;
  const step = 200;

  const g = 9.80665;
  const R = 287.05;
  const L = 0.0065;
  const T0 = 288.15;
  const P0 = 1013.25;

  /**
   * Estimate standard-atmosphere pressure at a mean-sea-level height.
   * @param {number} h Height above mean sea level (in m).
   * @returns {number} Pressure (in hPa).
   */
  function pressureMbAtHeightMsl(h) {
    const hh = Math.min(Math.max(h, 0), 11000);
    const T = T0 - L * hh;
    if (T <= 150) return 110;
    return P0 * Math.pow(T / T0, g / (R * L));
  }

  /**
   * Estimate standard-atmosphere temperature at a mean-sea-level height.
   * @param {number} h Height above mean sea level (in m).
   * @returns {number} Temperature (in °C).
   */
  function tempCAtHeightMsl(h) {
    return 15 - L * h;
  }

  const slice = [];
  if (snding !== false){
    for (let h = h0Msl; h <= topMsl; h += step) {
      const Lyr = GetLayerByHght(snding.Sounding, h);
      const Pmb = Lyr.P || pressureMbAtHeightMsl(h);
      const T = Lyr.T || tempCAtHeightMsl(h);
      const D = Lyr.D || T-30;
      const WD = Lyr.WD || 270;
      const WS = Lyr.WS || 5;
      const WB = Lyr.WB || T;
      const OM = Lyr.OM || 0;
      slice.push([Pmb, h, T, D, WD, WS, WB, OM]);
    }
  } else {
    for (let h = h0Msl; h <= topMsl; h += step) {
      const Pmb = pressureMbAtHeightMsl(h);
      const T = tempCAtHeightMsl(h);
      const D = T-30;
      const WD = 270;
      const WS = 5;
      const WB = T;
      const OM = 0;
      slice.push([Pmb, h, T, D, WD, WS, WB, OM]);
    }
  }
  
  return {
    Sounding: {
      W: [],
      T: [],
      P: [],
      H: [],
      D: [],
      OMEGA: [],
      TW: [],
      DW: [],
      Slice: slice,
    },
    Location: [P.radar.latitude, P.radar.longitude],
    Location2: [P.radar.latitude, -P.radar.longitude],
    Title: '',
    STitle: '',
  };
}


/** Radar id from the WMO/AWIPS text header (e.g. "NVWOAX" → "OAX"), if the file still has it. */
function radarIdFromHeader(raw) {
  try {
    const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw;
    if (!bytes || typeof bytes.length !== 'number') return null;
    let head = '';
    for (let i = 0; i < Math.min(bytes.length, 64); i++) head += String.fromCharCode(bytes[i]);
    const m = head.match(/NVW([A-Z0-9]{3})/);
    return m ? m[1] : null;
  } catch {
    return null;
  }
}

/**
 * Build a SharpJS VWP sounding from a NEXRAD Level III NVW product.
 *
 * @param {ArrayBuffer|Uint8Array|object} input  Raw NVW bytes, or the result of parseVWP().
 * @param {object} [options]
 * @param {string} [options.radar]       Radar id for the title (read from the file header when omitted).
 * @param {object} [options.background]  A parsed sounding to take temperature/moisture from.
 *                                       Without one, a standard atmosphere is used.
 * @param {boolean} [options.resample]   With `background`: resample it onto a 200 m column first
 *                                       (the "VWP only" layout) instead of keeping its levels.
 * @returns {object|null} sounding object for viewer.update({ vwp }) / engine.applyNexradVwpStoredSounding
 */
export function buildVwpSounding(input, options = {}) {
  const parsed = input && input.profiles && input.radar ? input : parseVWP(input);
  const radar = (options.radar || (input && !input.profiles ? radarIdFromHeader(input) : null) || 'NEXRAD')
    .toString()
    .toUpperCase()
    .replace(/^K(?=[A-Z]{3}$)/, '');

  let built;
  const background = options.background && options.background.Sounding ? options.background : null;
  if (background && !options.resample) {
    built = JSON.parse(JSON.stringify(background));
  } else {
    built = buildNexradVwpSkeletonSoundingFromParse(parsed, background || false);
  }
  if (!built) return null;

  built.Coords = { lat: parsed.radar.latitude, lon: parsed.radar.longitude };
  built.Location = [parsed.radar.latitude, parsed.radar.longitude];
  built.Location2 = [parsed.radar.latitude, -parsed.radar.longitude];
  const { volumeUnix, title } = vwpVolumeMetaFromParse(parsed, radar);
  built.Title = title;
  built.STitle = title;
  built.volumeUnix = volumeUnix;
  fillVwpWindColumnsFromParse(built, parsed);
  return built;
}