Source: nowcast.js

import { roundAndUseNull } from "./utils.js";

/**
 * Calculate an array of NowCast values from hourly PM measurements.
 *
 * Uses a 12-hour rolling window and the EPA NowCast algorithm to calculate
 * weighted averages. Missing values should be represented by `null`.
 *
 * The returned array is the same length as the input, but early entries
 * may contain `null` due to insufficient data.
 *
 * @param {Array<number|null>} pm - Hourly PM2.5 or PM10 values (no gaps).
 * @returns {Array<number|null>} - Array of NowCast values, rounded to 1 decimal place.
 */
export function pm_nowcast(pm) {
  // Validate input
  if (!Array.isArray(pm)) {
    throw new Error("Input to pm_nowcast() must be an array.");
  }

  // NOTE:  We only use the index `i`, not the actual PM value, so `_` is used to
  // NOTE:  indicate an unused parameter. The underscore `_` is a common
  // NOTE:  convention in JavaScript to mean "I don't need this value".
  const nowcast = pm.map((_, i) => {
    const end = i + 1;
    const start = end < 12 ? 0 : end - 12;
    const window = pm.slice(start, end);
    return nowcastPM(window);
  });

  // Round to one decimal place and convert non-numeric values to null
  return roundAndUseNull(nowcast);
}

/**
 * Compute a single NowCast value from up to 12 hours of data.
 *
 * Applies EPA's NowCast formula, using exponential weighting that depends
 * on how much values vary over time. Returns `null` if too little recent
 * data is available.
 *
 * @private
 * @param {Array<number|null>} x - Up to 12 hourly values in chronological order.
 * @returns {number|null} - Single NowCast value, or null if data is insufficient.
 */
function nowcastPM(x) {
  // Allow single number input
  if (typeof x === "number") x = [x];

  // NOTE:  The NowCast algorithm expects values in reverse chronological order
  // NOTE:  Missing values are treated as NaN to avoid incorrect math results,
  // NOTE:  because in JavaScript: null * 1 = 0, which would corrupt the weighting step.
  x = x.reverse().map((o) => (o === null ? NaN : o));

  // NOTE: EPA requires at least 2 valid values in the most recent 3 hours
  const recentValidCount = x
    .slice(0, 3)
    .reduce((a, o) => (Number.isNaN(o) ? a : a + 1), 0);
  if (recentValidCount < 2) return null;

  // Identify indices of valid values (non-NaN)
  const validIndices = x.reduce(
    (a, o, i) => (Number.isNaN(o) ? a : a.concat(i)),
    []
  );

  // Calculate min and max while ignoring NaN
  const validValues = x.filter((o) => !Number.isNaN(o));
  if (validValues.length === 0) return null;

  const max = validValues.reduce((a, o) => (o > a ? o : a));
  const min = validValues.reduce((a, o) => (o < a ? o : a));

  // Compute "scaled rate of change" = (max - min) / max
  const scaledRateOfChange = (max - min) / max;

  // Convert scaled rate into a weight factor within the range [0.5, 1.0]
  const weightFactor =
    1 - scaledRateOfChange < 0.5 ? 0.5 : 1 - scaledRateOfChange;

  // Compute weighted values, applying less weight to older values
  const weightedValues = x
    .map((o, i) => o * Math.pow(weightFactor, i))
    .filter((o) => !Number.isNaN(o));

  if (weightedValues.length === 0) return null;

  const weightedSum = weightedValues.reduce((a, o) => a + o);

  // Compute the sum of weights used for normalization
  const weightFactorSum = validIndices
    .map((i) => Math.pow(weightFactor, i))
    .reduce((a, o) => a + o);

  // Final NowCast value, rounded to 1 decimal place
  let returnVal = parseFloat((weightedSum / weightFactorSum).toFixed(1));

  // If the result is not a number, return null
  returnVal = Number.isNaN(returnVal) ? null : returnVal;

  return returnVal;
}