/**
* 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;