pacificclimate/climate-explorer-frontend

View on GitHub
src/core/util.js

Summary

Maintainability
C
1 day
Test Coverage
/***********************************************************
 * util.js - a collection of data-handling functions
 ***********************************************************/

import moment from "moment/moment";
import _ from "lodash";
import yaml from "js-yaml";
import { getVariableOptions as httpGetVariableOptions } from "../data-services/public";

/*****************************************************************
 * Functions for working with data from the Climate Explorer API
 *****************************************************************/

/*
 * Decimal precision of numbers displayed onscreen (graphs and tables)
 * Used in functions in util.js, chart.js, and export.js.
 */
export const PRECISION = 2;

/*
 * Takes a multistats object of the following form and 1) flattens it, and 2)
 * rounds numeric values for passing to the DataTable component for rendering:
 * {
 *  'tasmin_Amon_CanESM2_historical_r1i1p1_19610101-19901231':
 *    { 'median': 278.34326171875,
 *      'min': 225.05545043945312,
 *      'units': 'K',
 *      'mean': 273.56732177734375,
 *      'max': 303.601318359375,
 *      'ncells': 8192,
 *      'stdev': 22.509726901403784,
 *      'run': 'r1i1p1'
 *    },
 * 'tasmin_Amon_CanESM2_historical_r1i1p1_19710101-20001231':
 *   { ... }
 *   };
 */
export function parseBootstrapTableData(data, metadata) {
  return _.map(data, function (stats, model) {
    const modelMetadata = _.find(metadata, (m) => m.unique_id === model);
    const period = `${modelMetadata.start_date} - ${modelMetadata.end_date}`;
    const variable_id = modelMetadata.variable_id;
    const configuredPrecision = getVariableOptions(
      variable_id,
      "decimalPrecision",
    );
    const precision = configuredPrecision ? configuredPrecision : PRECISION;
    const modelInfo = {
      model_period: period,
      run: stats.run,
      min: +stats.min.toFixed(precision),
      max: +stats.max.toFixed(precision),
      mean: +stats.mean.toFixed(precision),
      median: +stats.median.toFixed(precision),
      stdev: +stats.stdev.toFixed(precision),
      units: getDataUnits(stats, modelMetadata.variable_id),
    };
    return modelInfo;
  });
}

/*
 * Assembles a list of dictionaries describing a watershed from
 * the area parameter (latlon) and API response (watershed calculations).
 * Doesn't use the hypsometric curve or the shape data, only scalars.
 */
export function parseWatershedTableData(data, area) {
  let avus = [];
  // convert the WKT point from the API call to geoJSON
  const point = WKTPointToGeoJSONPoint(area);
  function prec(num) {
    return +num.toFixed(PRECISION);
  }
  avus.push(["Outlet Longitude", point.coordinates[0], "Degrees East"]);
  avus.push(["Outlet Latitude", point.coordinates[1], "Degrees North"]);
  avus.push([
    "Source Elevation",
    prec(data.elevation.maximum),
    data.elevation.units,
  ]);
  avus.push([
    "Outlet Elevation",
    prec(data.elevation.minimum),
    data.elevation.units,
  ]);
  avus.push(["Area", prec(data.area.value), data.area.units]);
  avus.push([
    "Melton Ratio",
    prec(data.melton_ratio.value),
    data.melton_ratio.units,
  ]);

  return _.map(avus, function (avu) {
    return {
      attribute: avu[0],
      value: avu[1],
      units: avu[2],
    };
  });
}

/*
 * Basic validation of data fetched from a "data" call to the climate
 * explorer backend. Accepts an axios response object, throws an error if
 * anything is missing, otherwise returns the object unaltered.
 */
export function validateLongTermAverageData(response) {
  if (_.isEmpty(response.data) || typeof response.data === "string") {
    throw new Error("Error: long term data unavailable for this model.");
  }
  for (const run in response.data) {
    if (!("data" in response.data[run]) || !("units" in response.data[run])) {
      throw new Error("Error: long term data for this model is incomplete.");
    }
  }
  return response;
}

/*
 * Basic validation of data fetched from a "multistats" call to the climate
 * explorer API. Accepts an axios response object, throws an error if
 * any of the expected stats are missing, otherwise, returns the object unaltered.
 */
export function validateStatsData(response) {
  if (_.isEmpty(response.data) || typeof response.data === "string") {
    throw new Error("Error: statistical data unavailable for this model");
  }
  for (const file in response.data) {
    if (
      _.some(
        "mean stdev min max median ncells".split(" "),
        (attr) =>
          !(attr in response.data[file]) || isNaN(response.data[file][attr]),
      ) ||
      _.some("units time".split(" "), (attr) => !(attr in response.data[file]))
    ) {
      throw new Error("Error: statistical data for this model is incomplete");
    }
  }
  return response;
}

/*
 * Basic validation of data fetched from a "timeseries" call to the climate
 * explorer API. Accepts an axios response object, throws an error if
 * any expected data is missing, or if the time resolution isn't monthly,
 * seasonal, or yearly. Otherwise returns the axios response object unaltered.
 */
export function validateAnnualCycleData(response) {
  if (_.isEmpty(response.data) || typeof response.data === "string") {
    throw new Error("Error: timeseries data is unavailable for this model.");
  }
  if (!_.every("id units data".split(" "), (attr) => attr in response.data)) {
    throw new Error("Error: timeseries data for this model is incomplete");
  }
  const resolution = Object.keys(response.data.data).length;
  if ([1, 4, 12].indexOf(resolution) === -1) {
    throw new Error("Error: unrecognized time resolution for timeseries");
  }
  return response;
}

/*
 * Very basic validation of data fetched from a "timeseries" call to the
 * climate explorer API. Accepts an axios response object and checks to make
 * sure it has id, units, and at least one timestamp.
 */
export function validateUnstructuredTimeseriesData(response) {
  if (_.isEmpty(response.data) || typeof response.data === "string") {
    throw new Error("Error: timeseries data is unavailable for this model.");
  }
  if (!_.every("id units data".split(" "), (attr) => attr in response.data)) {
    throw new Error("Error: timeseries data for this model is incomplete");
  }
  if (_.isEmpty(response.data.data)) {
    throw new Error("Error: no timestamps available for time series");
  }
  return response;
}

/*
 * Basic validation of data fetched from a "watershed" call to the climate
 * explorer backend. Accepts an axios response object, throws an error if
 * anything is missing, otherwise returns the object unaltered.
 */
export function validateWatershedData(response) {
  if (_.isEmpty(response.data) || typeof response.data === "string") {
    throw new Error("Error: watershed information unavailable for this point.");
  }
  if (
    !_.every(
      "area elevation boundary hypsometric_curve melton_ratio".split(" "),
      (attr) => attr in response.data,
    )
  ) {
    throw new Error("Error: watershed data for this point is incomplete");
  }
  return response;
}

/*
 * Get an option defined in the variable options yaml config file.
 *
 * This file is used to set formatting options (default map colours,
 * decimal precision, logarithmic scales, etc) at an individual
 * variable level.
 *
 * Returns the option value, or "undefined" if the variable or option
 * is not listed.
 *
 * NOTE: A variable option can legitimately have a value of "false",
 * so callers of this function may need to distinguish between "false"
 * and "undefined" when acting on its results.
 *
 * The variable options config file is located in the `public` directory so
 * that it can be updated without making a new release of the entire app.
 * The filepath within the `public` directory is specified by the environment
 * variable `REACT_APP_VARIABLE_OPTIONS`.
 *
 * The benefit of placing the variable options file in the public directory
 * however comes with the complication that it must be loaded _asynchronously_.
 * Function `loadVariableOptions()` below performs this action.
 *
 * Complication: Until the file successfully loads,
 * the object `variableOptions`, from which this information is retrieved,
 * is empty, and `getVariableOptions()` will return `undefined` for all calls.
 *
 * Complication: Any code that depends on valid values for variable options
 * must wait for the asynchronous load to complete. To enable this,
 * `loadVariableOptions()` returns a Promise that resolves (or rejects,
 * on failure) when the loading completes. A convenient way to avoid
 * complicating a component which depends on variable options is to wrap it
 * in an `Await` component, which was built for this very purpose.
 *
 * As is evident from the above explanation, we decided to take a "global"
 * approach to signalling the loading of the variable options config file.
 * An alternative would be to have `getVariableOptions()` return a promise
 * for the value, but that would weave asynchronousness into all the client
 * code, which would be complicated and expensive to introduce into a
 * substantial amount of existing code. Global signalling is less
 * obvious to the reader, but makes for much simpler client code.
 */
let variableOptions = {};
let variableOptionsPromise;
export function loadVariableOptions() {
  if (!variableOptionsPromise) {
    variableOptionsPromise = httpGetVariableOptions()
      .then((response) => response.data)
      .then(yaml.safeLoad)
      .then((result) => {
        variableOptions = result;
        return result;
      });
  }
  return variableOptionsPromise;
}

// Pre-load the variable options
loadVariableOptions();

// TODO: Make this function safer by having it throw an error when it is
// called when variable options have not been loaded.
export function getVariableOptions(variable, option) {
  if (nestedAttributeIsDefined(variableOptions, variable, option)) {
    return variableOptions[variable][option];
  }
  return undefined;
}

/*
 * Get the units string associated with a data object and associated
 * variable. This is usually just the "units" attribute of the
 * data object.
 *
 * However, if the variable configuration file provides a user-friendly
 * substitute unit string (such as "mm/day" instead of "kg m-2 day-1")
 * for this variable+unit pair, the user friendly unit will be
 * returned instead.
 */
export function getDataUnits(data, variable_id) {
  const units = data.units;
  const userFriendlyUnits = getVariableOptions(
    variable_id,
    "userFriendlyUnits",
  );
  if (userFriendlyUnits) {
    for (let i = 0; i < userFriendlyUnits.length; i++) {
      if (units in userFriendlyUnits[i]) {
        return userFriendlyUnits[i][units];
      }
    }
  }
  return units;
}

/************************************************************
 * Metadata helper functions
 ************************************************************/

export const valuesWithin = (tolerance, a, b) => Math.abs(+a - +b) <= tolerance;

export const findMatchingMetadata = (
  metadata,
  tolerance,
  {
    model_id,
    experiment,
    variable_id,
    timescale,
    start_date,
    end_date,
    ensemble_member,
  },
) =>
  _.find(
    metadata,
    (metadatum) =>
      // Match exactly on these parameters
      _.matches({
        model_id,
        experiment,
        variable_id,
        timescale,
        ensemble_member,
      })(metadatum) &&
      // Match within `tolerance` on start and end date
      valuesWithin(tolerance, start_date, metadatum.start_date) &&
      valuesWithin(tolerance, end_date, metadatum.end_date),
  );

/************************************************************
 * Date and calendar helper functions
 *
 * Several different representations of time are needed to
 * query external systems or display to users. These functions
 * convert between them.
 *
 * Date formats:
 * - timeKey: a numerical index of times of year, used to key
 *     dropdowns
 * - resolution and index: object with two attributes
 *   (timescale and timeidx) representing a time of year;
 *   used to query backend
 * - timeString: a human friendly string for the UI, like
 *     "Spring" or "Annual 1950", describing a period, not a
 *     specific moment.
 * - extendedDate: moment with timezone, date, and time of day.
 *     Passed to the ncWMS server and generated by backend.
 * - basicDate: a moment in time, simpler formatting. Used in
 *     graph displays.
 * - year: just a four-digit year, used to determine/represent
 *     climatology periods.
 ************************************************************/

/*
 * Helper function for the TimeOfYearSelector component and its controllers.
 * Converts the numerical time key generated by TimeOfYearSelector to a UI string.
 */
export function timeKeyToTimeOfYear(timeidx) {
  const timesOfYear = [
    "January",
    "February",
    "March",
    "April",
    "May",
    "June",
    "July",
    "August",
    "September",
    "October",
    "November",
    "December",
    "Winter-DJF",
    "Spring-MAM",
    "Summer-JJA",
    "Fall-SON",
    "Annual",
  ];
  return timesOfYear[timeidx];
}

/*
 * Helper Function for the TimeOfYearSelector component and its controllers.
 * Converts the numerical time key generated by TimeOfYearSelector to an object
 * containing an index (0-11) and a resolution (yearly, seasonal, or monthly).
 *   January would be {timescale: monthly, index: 1}
 *   Summer would be {timescale: seasonal, index:2}
 */
export function timeKeyToResolutionIndex(index) {
  if (index === 16) {
    return { timescale: "yearly", timeidx: 0 };
  } else if (index > 11 && index < 16) {
    return { timescale: "seasonal", timeidx: index - 12 };
  } else if (index >= 0 && index < 12) {
    return { timescale: "monthly", timeidx: index };
  }
  return undefined;
}

/*
 * Helper function for the TimeOfYearSelector component and its controllers.
 * Encodes an object containing an index (0-11) and a resolution (yearly,
 * seasonal, or monthly) as a numerical key for TimeOfYearSelector.
 */
export function resolutionIndexToTimeKey(res, idx) {
  idx = parseInt(idx);
  const offset = { monthly: 0, seasonal: 12, yearly: 16 }[res];
  return idx + offset;
}

/*
 * Converts a combination of a timescale (yearly, seasonal, or monthly)
 * and index (0-11) to a string.
 */
export function timeResolutionIndexToTimeOfYear(res, idx) {
  const timesOfYear = {
    monthly: [
      "January",
      "February",
      "March",
      "April",
      "May",
      "June",
      "July",
      "August",
      "September",
      "October",
      "November",
      "December",
    ],
    seasonal: ["Winter-DJF", "Spring-MAM", "Summer-JJA", "Fall-SON"],
    yearly: ["Annual"],
  };
  if (res in timesOfYear && idx in timesOfYear[res]) {
    return timesOfYear[res][idx];
  }
  //fall back to just stringifying the arguments.
  return `${res} ${idx}`;
}

export function timeResolutions(meta) {
  // Given an array of (standard) metadata,
  // return an object containing flags indicating whether each of the
  // 3 standard timescales are present in the datasets described by
  // the metadata.
  const timescales = _.map(meta, "timescale");
  return {
    monthly: _.includes(timescales, "monthly"),
    seasonal: _.includes(timescales, "seasonal"),
    yearly: _.includes(timescales, "yearly"),
  };
}

/*
 * extendedDateToBasicDate: converts an ISO8601 extended-formatted date
 * (like "1997-01-15T00:00:00Z") to an ISO8601 basic-formatted date
 * (like "1997-01-15")
 */
export function extendedDateToBasicDate(timestamp) {
  return moment(timestamp, moment.ISO_8601).utc().format("YYYY-MM-DD");
}

/*
 * Produces a human-readable string describing the time of year of displayed data.
 * Used by MapController, since ncWMS doesn't provide any human-friendly time info.
 */
export function timestampToTimeOfYear(
  timestamp,
  resolution = "monthly",
  disambiguateYear = true,
) {
  const year = disambiguateYear ? " ".concat(timestampToYear(timestamp)) : "";
  const month = moment(timestamp, moment.ISO_8601).utc().format("MMMM");

  if (resolution === "yearly") {
    return `Annual${year}`;
  } else if (resolution === "monthly") {
    return `${month}${year}`;
  } else if (resolution === "seasonal") {
    switch (month) {
      case "December":
      case "January":
      case "February":
        return `Winter-DJF${year}`;
      case "March":
      case "April":
      case "May":
        return `Spring-MAM${year}`;
      case "June":
      case "July":
      case "August":
        return `Summer-JJA${year}`;
      case "September":
      case "October":
      case "November":
        return `Fall-SON${year}`;
      default:
        return timestamp;
    }
  } else {
    return timestamp;
  }
}

/*
 * extract the four digit year from an ISO 8601 timestamp
 */
export function timestampToYear(date) {
  return moment(date, moment.ISO_8601).utc().format("YYYY");
}

/*
 * Predicate that calculates whether two dates are the same calendar year.
 * (Not whether they're 365 days apart.)
 */
export function sameYear(date1, date2) {
  return timestampToYear(date1) === timestampToYear(date2);
}

/*****************************************************
 * String-related helper functions
 *****************************************************/

/*
 * Returns a string with the first letter of each word capitalized
 * "a 1st string" -> "A 1st String"
 */
export function capitalizeWords(s) {
  return s.replace(/\b\w/g, (c) => c.toUpperCase());
}

/*
 * Returns true if the second argument is a substring of the first,
 * ignoring case.
 */
export function caseInsensitiveStringSearch(s1, s2) {
  return s1.toLowerCase().search(s2.toLowerCase()) !== -1;
}

/**********************************************************
 * Object-related helper function
 **********************************************************/

// TODO: Equivalent to Lodash `has`. Replace.
/*
 * Given an object and any number of arguments arg1, arg2, arg3,
 * et cetera, returns true if object.arg1.arg2.arg3 is defined
 */
export function nestedAttributeIsDefined(o, ...attributes) {
  if (_.isUndefined(o)) {
    return false;
  }
  for (let i = 0; i < attributes.length; i++) {
    if (_.isUndefined(o[attributes[i]])) {
      return false;
    }
    o = o[attributes[i]];
  }
  return true;
}

/**********************************************************
 * Geo-related helper function
 **********************************************************/
// Converts a single WKT point string to a geoJSON point object.
// There are libraries that do this sort of thing, but right now,
// this is the only such conversion we need.
export function WKTPointToGeoJSONPoint(wkt) {
  const elements = _.split(wkt, " ");
  if (elements[0] !== "POINT") {
    throw new Error(`Invalid WKT Point: ${wkt}`);
  }
  const lon = parseFloat(_.trim(elements[1], "()"));
  const lat = parseFloat(_.trim(elements[2], "()"));
  return {
    type: "Point",
    coordinates: [lon, lat],
  };
}