Source: nowcast.js

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

/**
 * Returns an array of NowCast values derived from the incoming time series.
 *
 * **NOTE:** Incoming data must be on an hourly axis with no gaps. Missing
 * values should be represented by 'null'.
 *
 * @param {Array.<number>} pm Array of hourly PM2.5 or PM10  measurements.
 * @returns {Array.<number>} Array of NowCast values.
 */
export function pm_nowcast(pm) {
  // TODO: Validate that pm is numeric
  // See:  https://observablehq.com/@openaq/epa-pm-nowcast
  let nowcast = Array(pm.length);
  for (let i = 0; i < pm.length; i++) {
    let end = i + 1;
    let start = end < 12 ? 0 : end - 12;
    nowcast[i] = nowcastPM(pm.slice(start, end));
  }

  // Round to one decimal place and use null as the missing value
  nowcast = roundAndUseNull(nowcast);

  return nowcast;
}

/**
 * Convert an array of up to 12 PM2.5 or PM10 measurements in chronological order
 * into a single NowCast value.
 *
 * **NOTE:** Incoming data must be on an hourly axis with no gaps.  Missing
 * values should be represented by 'null'.
 *
 * @private
 * @param {Array.<number>} x Array of 12 hourly values in chronological order.
 * @returns {number} NowCast value.
 */
function nowcastPM(x) {
  // NOTE:  We don't insist on 12 hours of data. Convert single values into arrays.
  if (typeof x === "number") x = [x];

  // NOTE:  map/reduce syntax: a: accumulator; o: object; i: index

  // NOTE:  The algorithm below assumes reverse chronological order.
  // NOTE:  WARNING:  In javascript `null * 1 = 0` which messes up things in
  // NOTE:  our mapping functions. So we convert all null to NaN
  // NOTE:  and then back to null upon return.
  x = x.reverse().map((o) => (o === null ? NaN : o));

  // Check for recent values;
  let recentValidCount = x
    .slice(0, 3)
    .reduce((a, o) => (Number.isNaN(o) ? a : a + 1), 0);
  if (recentValidCount < 2) return null;

  let validIndices = x.reduce(
    (a, o, i) => (Number.isNaN(o) ? a : a.concat(i)),
    []
  );

  // NOTE:  max and min calculations need to be tolerant of missing values
  let max = x.filter((o) => !Number.isNaN(o)).reduce((a, o) => (o > a ? o : a));
  let min = x.filter((o) => !Number.isNaN(o)).reduce((a, o) => (o < a ? o : a));
  let scaledRateOfChange = (max - min) / max;
  let weightFactor =
    1 - scaledRateOfChange < 0.5 ? 0.5 : 1 - scaledRateOfChange;

  // TODO:  Check for any valid values before attempting to reduce.
  // TODO:  If all NaN, then simply return null.

  let weightedValues = x
    .map((o, i) => o * Math.pow(weightFactor, i)) // maps onto an array including NaN
    .filter((x) => !Number.isNaN(x)); // remove NaN before calculating sum

  let weightedSum = null;
  if (weightedValues.length == 0) {
    return null;
  } else {
    weightedSum = weightedValues.reduce((a, o) => a + o);
  }

  let weightFactorSum = validIndices
    .map((o) => Math.pow(weightFactor, o))
    .reduce((a, o) => a + o);

  let returnVal = parseFloat((weightedSum / weightFactorSum).toFixed(1));

  // Convert NaN back to null
  returnVal = Number.isNaN(returnVal) ? null : returnVal;
  return returnVal;
}