Source: series.js

/**
 * Series module.
 * @module lib/series
 */

const Ajv = require("ajv");
const { Range } = require("immutable");
const is = require("is_js");
const { jStat } = require("jStat");
const math = require("mathjs");
const moment = require("moment");
const R = require("ramda");

const { randomInt } = require("./random");
const Ratio = require("./ratio");

/* Enum of Types */
const TYPE_MONOSPACED = "monospaced";
const TYPE_RANDOM = "random";

/**
 * Class generating time series data.
 * @example
 * const from     = '2016-01-01T00:00:00Z';
 * const until    = '2016-01-01T01:00:00Z';
 * const interval = 5 * 60; // seconds
 * const keyName  = 'favorite name';
 * new Series({                    from, until, interval, keyName}); // => Series' instance
 * new Series({type: 'monospaced', from, until, interval, keyName}); // => same as above
 * @example
 * const from      = '2016-01-01T00:00:00Z';
 * const until     = '2016-01-01T01:00:00Z';
 * const numOfData = 5 * 60; // seconds
 * const keyName   = 'favorite name';
 * new Series({type: 'random', from, until, numOfData, keyName}); // => Series' instance
 */
class Series {
  /**
   * Create a series.
   * @param {Object}           [options={}]
   * @param {(integer|string)} [options.type='monospaced']   - 'monospaced' or 'random'.
   * @param {(integer|string)} [options.from=<now - 1 hour>] - Lower bound of date range.
   * @param {string}           [options.until=<now>]         - Upper bound of date range.
   * @param {integer}          [options.interval=5 * 60]     - ('monospaced' only) Intervel of seconds between two data. [1 <= interval]
   * @param {integer}          [options.numOfData=10]        - ('random' only) Number of data. [0 <= numOfData]
   * @param {string}           [options.keyName='value']     - Value's key name of result.
   */
  constructor(options = {}) {
    const now = moment().startOf("second");
    const defaults = {
      type: TYPE_MONOSPACED,
      from: moment(now)
        .subtract(1, "hour")
        .toISOString(),
      until: now.toISOString(),
      interval: 5 * 60, // seconds
      numOfData: 10,
      keyName: "value"
    };
    const schema = {
      $schema: "http://json-schema.org/schema#",
      type: "object",
      properties: {
        type: {
          type: "string",
          enum: [TYPE_MONOSPACED, TYPE_RANDOM],
          default: defaults.type
        },
        from: {
          type: ["integer", "string"],
          format: "date-time",
          default: defaults.from
        },
        until: {
          type: ["integer", "string"],
          format: "date-time",
          default: defaults.until
        },
        interval: { type: "integer", minimum: 1, default: defaults.interval },
        numOfData: { type: "integer", minimum: 0, default: defaults.numOfData },
        keyName: { type: "string", default: defaults.keyName }
      },
      additionalProperties: false
    };
    const ajv = new Ajv({ useDefaults: true });
    const isValid = ajv.validate(schema, options);
    if (!isValid) {
      const error = ajv.errors[0];
      throw Error(`options${error.dataPath} ${error.message}`);
    }

    this.type = options.type;
    this.from = is.integer(options.from)
      ? moment.unix(options.from)
      : moment(options.from);
    this.until = is.integer(options.until)
      ? moment.unix(options.until)
      : moment(options.until);
    this.interval = moment.duration(options.interval, "seconds");
    this.numOfData = options.numOfData;
    this.keyName = options.keyName;
  }

  /**
   * Clone self instance.
   * @param {Object}           [options={}]
   * @param {string}           [options.type=this.type]           - 'monospaced' or 'random'.
   * @param {(integer|string)} [options.from=this.from]           - Lower bound of date range.
   * @param {(integer|string)} [options.until=this.until]         - Upper bound of date range.
   * @param {integer}          [options.interval=this.interval]   - ('monospaced' only) Intervel of seconds between two data. [1 <= interval]
   * @param {integer}          [options.numOfData=this.numOfData] - ('random' only) Number of data. [0 <= numOfData]
   * @param {string}           [options.keyName=this.keyName]     - Value's key name of result.
   * @return {Series}
   * @example
   * new Series().clone({keyName: 'changed name'}); // => Series' instance with keyName 'changed name'
   */
  clone(options = {}) {
    const defaults = {
      type: this.type,
      from: this.from.unix(),
      until: this.until.unix(),
      interval: this.interval.asSeconds(),
      numOfData: this.numOfData,
      keyName: this.keyName
    };
    return new Series(is.json(options) ? R.merge(defaults, options) : options);
  }

  /**
   * (Private) Create UNIX timestamps.
   */
  _timestamps() {
    switch (this.type) {
      case TYPE_MONOSPACED: {
        return Range(
          this.from.unix(),
          this.until.unix() + 1,
          this.interval.asSeconds()
        );
      }
      case TYPE_RANDOM: {
        const min = this.from.unix();
        const max = this.until.unix();
        return Range(0, this.numOfData)
          .map(() => randomInt(min, max))
          .sort();
      }
      default: {
        throw Error("Illegal use of Series");
      }
    }
  }

  /**
   * Return time series data by any functions using UNIX timestamp.
   * @param {function} func - (unixTimestamp) => any
   * @return {Array.<{timestamp: string, (string): any}>}
   * @example
   * new Series().generate((unix) => unix); // => [{timestamp: '2017-05-31T02:43:57.000Z', value: 1496198637}, ...]
   */
  generate(func) {
    if (is.not.function(func)) {
      throw new Error("1st argument(func) must be function");
    }
    return this._timestamps()
      .map(unix =>
        R.assoc(this.keyName, func(unix), {
          timestamp: moment.unix(unix).toISOString()
        })
      )
      .toJSON();
  }

  /**
   * (Private) Return time series data by trigonometric functions.
   */
  _trigonometric(func, options = {}) {
    const defaults = {
      coefficient: 1.0,
      constant: 0.0,
      decimalDigits: 2,
      period: 1 * 60 * 60 // seconds
    };
    const schema = {
      $schema: "http://json-schema.org/schema#",
      type: "object",
      properties: {
        coefficient: { type: "number", default: defaults.coefficient },
        constant: { type: "number", default: defaults.constant },
        decimalDigits: {
          type: "integer",
          minimum: 0,
          maximum: 10,
          default: defaults.decimalDigits
        },
        period: { type: "integer", minimum: 1, default: defaults.period }
      },
      additionalProperties: false
    };
    const ajv = new Ajv({ useDefaults: true });
    const isValid = ajv.validate(schema, options);
    if (!isValid) {
      const error = ajv.errors[0];
      throw Error(`options${error.dataPath} ${error.message}`);
    }

    const scale = (2 * Math.PI) / options.period;
    return this.generate(unix => {
      const value = options.coefficient * func(unix * scale) + options.constant;
      return math.round(value, options.decimalDigits);
    });
  }

  /**
   * Return time series data describing sine curve.
   * @param {Object}  [options={}]
   * @param {number}  [options.coefficient=1.0]    - Coefficient of sine curve.
   * @param {number}  [options.constant=0.0]       - Constant of sine curve.
   * @param {integer} [options.decimalDigits=2]    - Number of decimal places. [0 <= decimalDigits <= 10]
   * @param {integer} [options.period=1 * 60 * 60] - Period of sine curve. [1 <= period]
   * @return {Array.<{timestamp: string, (string): number}>}
   * @example
   * const coefficient   = 1;
   * const constant      = 1;
   * const decimalDigits = 3;
   * const period        = 1 * 60 * 60; // seconds
   * new Series().sin({coefficient, constant, decimalDigits, period})); // => [{timestamp: '2017-05-31T02:17:23.000Z', value: 1.969}, ...]
   */
  sin(options) {
    return this._trigonometric(Math.sin, options);
  }

  /**
   * Return time series data describing cosine curve.
   * @param {Object}  [options={}]
   * @param {number}  [options.coefficient=1.0]    - Coefficient of cosine curve.
   * @param {number}  [options.constant=0.0]       - Constant of cosine curve.
   * @param {integer} [options.decimalDigits=2]    - Number of decimal places. [0 <= decimalDigits <= 10]
   * @param {integer} [options.period=1 * 60 * 60] - Period of cosine curve. [1 <= period]
   * @return {Array.<{timestamp: string, (string): number}>}
   * @example
   * const coefficient   = 1;
   * const constant      = 1;
   * const decimalDigits = 3;
   * const period        = 1 * 60 * 60; // seconds
   * new Series().cos({coefficient, constant, decimalDigits, period})); // => [{timestamp: '2017-05-31T02:20:48.000Z', value: 0.429}, ...]
   */
  cos(options) {
    return this._trigonometric(Math.cos, options);
  }

  /**
   * Return time series data by normal distribution.
   * @param {Object}  [options={}]
   * @param {number}  [options.mean=10]         - Mean of normal distribution.
   * @param {number}  [options.variance=1]      - Variance of normal distribution.
   * @param {integer} [options.decimalDigits=2] - Number of decimal places. [0 <= decimalDigits <= 10]
   * @return {Array.<{timestamp: string, (string): number}>}
   * @example
   * const mean          = 5;
   * const variance      = 1.5;
   * const decimalDigits = 3;
   * new Series().gaussian({mean, variance, decimalDigits}); // => [{timestamp: '2017-05-31T02:25:38.000Z', value: 2.56}, ...]
   */
  gaussian(options = {}) {
    const defaults = {
      mean: 10.0,
      variance: 1.0,
      decimalDigits: 2
    };
    const schema = {
      $schema: "http://json-schema.org/schema#",
      type: "object",
      properties: {
        mean: { type: "number", default: defaults.mean },
        variance: { type: "number", default: defaults.variance },
        decimalDigits: {
          type: "integer",
          minimum: 0,
          maximum: 10,
          default: defaults.decimalDigits
        }
      },
      additionalProperties: false
    };
    const ajv = new Ajv({ useDefaults: true });
    const isValid = ajv.validate(schema, options);
    if (!isValid) {
      const error = ajv.errors[0];
      throw Error(`options${error.dataPath} ${error.message}`);
    }

    return this.generate(() => {
      const value = jStat.normal.sample(options.mean, options.variance);
      return math.round(value, options.decimalDigits);
    });
  }

  /**
   * Return time series data by ratio.
   * @param {Object.<string, integer>} weights - Map representing pairs of key and weight.
   * @return {Array.<{timestamp: string, (string): string}>}
   * @example
   * const weights = {
   *   rock    : 1,
   *   scissors: 2,
   *   paper   : 1,
   * };
   * new Series().ratio(weights); // => [{timestamp: '2017-05-31T02:30:25.000Z', value: 'rock'}, ...]
   */
  ratio(weights) {
    const ratio = new Ratio(weights);
    return this.generate(() => ratio.sample());
  }
}

module.exports = Series;