Home Identifier Source

src/series.js

import _ from "underscore";
import Immutable from "immutable";
import Index from "./index";
import TimeRange from "./range";
import {Event, IndexedEvent} from "./event";
import util from "./util";

/**
 * Base class for a series of events.
 *
 * A series is compact representation for a list of events, with some additional
 * meta data on top of that.
 *
 */

export class Series {

    /**
     * A Series is constructed by either:
     *
     *  1) passing in another series (copy constructor)
     *  2) passing in three arguments:
     *      name - the name of the series
     *      columns - an array containing the title of each data column
     *      data - an array containing the data of each column
     *             Note: data may be either:
     *               a) An Immutable.List of Immutable.Map data objects
     *               b) An array of objects
     *
     * Internally a Series is List of Maps. Each item in the list is one data
     * map, and is stored as an Immutable Map, where the keys are the column
     * names and the value is the data for that column at that index.
     *
     * This enables efficient extraction of Events, since the internal data
     * of the Event can be simply a reference to the Immutable Map in this
     * Series, combined with the time, Timerange or Index.
     */
    constructor(arg1, arg2, arg3, arg4) {
        // Series(Series other) - copy
        if (arg1 instanceof Series) {
            const other = arg1;
            this._name = other._name;
            this._meta = other._meta;
            this._columns = other._columns;
            this._series = other._series;

        // Series(string name, object meta, list columns, list | ImmutableList
        // points)
        } else if (_.isString(arg1) &&
                   _.isObject(arg2) &&
                   _.isArray(arg3) &&
                  (_.isArray(arg4) || Immutable.List.isList(arg4))) {

            //
            // Object constructor
            //

            const name = arg1;
            const meta = arg2;
            const columns = arg3;
            const data = arg4;

            this._name = name;
            this._meta = Immutable.fromJS(meta);
            this._columns = Immutable.fromJS(columns);

            if (Immutable.List.isList(data)) {
                this._series = data;
            } else {
                this._series = Immutable.fromJS(
                    _.map(data, function (d) {
                        let pointMap = {};
                        _.each(d, function (p, i) {
                            pointMap[columns[i]] = p;
                        });
                        return pointMap;
                    })
                );
            }

        }
    }

    //
    // Serialize
    //

    /**
     * Returns a JSON representation, the same format as accepted by the
     * constructor.
     * @return {Object} JSON representation
     */
    toJSON() {
        const cols = this._columns;
        const series = this._series;
        return {
            name: this._name,
            columns: cols.toJSON(),
            points: series.map(value => {
                return cols.map(column => {
                    return value.get(column);
                });
            })
        };
    }

    /**
     * Return a string representation of the Series.
     * @return {string} The Series, as a string.
     */
    toString() {
        return JSON.stringify(this.toJSON());
    }

    //
    // Access meta data about the series
    //

    /**
     * Returns the same of the series
     * @return {string} The name
     */
    name() {
        return this._name;
    }

    /**
     * Return the list of columns
     * @return {string[]} The columns
     */
    columns() {
        return this._columns.toJSON();
    }

    /**
     * Return the meta data associated with the Series. To use, supply
     * the key and the get back the value matching that key.
     */
    meta(key) {
        return this._meta.get(key);
    }

    //
    // Access the series itself
    //

    /**
     * Returns the number of rows in the series.
     * @return {number} Size of the series
     */
    size() {
        return this._series.size;
    }
    /**
     * Returns the number of rows in the series. (Same as size())
     * @return {number} Size of the series
     */
    count() {
        return this.size();
    }

    /**
     * Returns the number of rows in the series.
     * @return {number} Size of the series
     */
    at(i) {
        return this._series.get(i);
    }

    //
    // Aggregate the series
    //

    sum(column) {
        const c = column || "value";
        if (!this._columns.contains(c)) {
            return undefined;
        }
        return this._series.reduce((memo, data) => {
            return data.get(c) + memo;
        }, 0);
    }

    avg(column) {
        const c = column || "value";
        if (!this._columns.contains(c)) {
            return undefined;
        }
        return this.sum(column) / this.size();
    }

    max(column) {
        const c = column || "value";
        if (!this._columns.contains(c)) {
            return undefined;
        }
        const max = this._series.maxBy((a) => {
            return a.get(c);
        });
        return max.get(c);
    }

    min(column) {
        const c = column || "value";
        if (!this._columns.contains(c)) {
            return undefined;
        }
        const min = this._series.minBy((a) => {
            return a.get(c);
        });
        return min.get(c);
    }

    mean(column) {
        return this.avg(column);
    }

    medium(column) {
        const c = column || "value";
        if (!this._columns.contains(c)) {
            return undefined;
        }
        const sorted = this._series.sortBy((event) => event.get(c));
        return sorted.get(Math.floor(sorted.size / 2)).get(c);
    }

    stdev(column) {
        const c = column || "value";
        if (!this._columns.contains(c)) {
            return undefined;
        }

        const mean = this.mean();
        return Math.sqrt(this._series.reduce((memo, event) => {
            return Math.pow(event.get(c) - mean, 2) + memo;
        }
        , 0) / this.size());
    }

    static equal(series1, series2) {
        return (series1._name === series2._name &&
                series1._meta === series2._meta &&
                series1._columns === series2._columns &&
                series1._series === series2._series);
    }

    static is(series1, series2) {
        return (series1._name === series2._name &&
                Immutable.is(series1._meta, series2._meta) &&
                Immutable.is(series1._columns, series2._columns) &&
                Immutable.is(series1._series, series2._series));
    }

}

/** Internal function to find the unique keys of a bunch
  * of immutable maps objects. There's probably a more elegent way
  * to do this.
  */
function uniqueKeys(events) {
    let arrayOfKeys = [];
    for (let e of events) {
        for (let k of e.data().keySeq()) {
            arrayOfKeys.push(k);
        }
    }
    return new Immutable.Set(arrayOfKeys);
}

/**
 * Functions used to determine slice indexes. Copied from immutable.js.
 */
function resolveBegin(begin, size) {
    return resolveIndex(begin, size, 0);
}

function resolveEnd(end, size) {
    return resolveIndex(end, size, size);
}

function resolveIndex(index, size, defaultIndex) {
    return index === undefined ?
        defaultIndex : index < 0 ?
            Math.max(0, size + index) : size === undefined ?
                index : Math.min(size, index);
}

/**
 * A TimeSeries is a a Series where each event is an association of a timestamp
 * and some associated data.
 *
 * Data passed into it may have the following format, which corresponds to
 * InfluxDB's wire format:
 *
 *   {
 *     "name": "traffic",
 *     "columns": ["time", "value", ...],
 *     "points": [
 *        [1400425947000, 52, ...],
 *        [1400425948000, 18, ...],
 *        [1400425949000, 26, ...],
 *        [1400425950000, 93, ...],
 *        ...
 *      ]
 *   }
 *
 * Alternatively, the TimeSeries may be constructed from a list of Events.
 *
 * Internaly the above series is represented as two lists, one of times and
 * one of data associated with those times. The position in the list links them
 * together. For each position, therefore, you have a time and an event:
 *
 * 'time'  -->  Event
 *
 * The time may be of several forms:
 *
 *   - a time
 *   - an index (which represents a timerange)
 *   - a timerange
 *
 * The event itself is stored is an Immutable Map. Requesting a particular
 * position in the list will return an Event that will in fact internally
 * reference the Immutable Map within the series, making it efficient to get
 * back items within the TimeSeries.
 *
 * You can fetch the full item at index n using get(n).
 *
 * The timerange associated with a TimeSeries is simply the bounds of the
 * events within it (i.e. the min and max times).
 */
export class TimeSeries extends Series {

    constructor(arg1) {

        // TimeSeries(TimeSeries other)
        if (arg1 instanceof TimeSeries) {
            super();

            //
            // Copy constructor
            //

            // Construct the base series
            let other = arg1;

            this._name = other._name;
            this._meta = other._meta;
            this._utc = other._utc;
            this._index = other._index;
            this._columns = other._columns;
            this._series = other._series;
            this._times = other._times;

        // TimeSeries(object data) where data may be
        //    {"events": Event list} or
        //    {"columns": string list, "points": value list}
        } else if (_.isObject(arg1)) {

            //
            // Object constructor
            //
            // There are two forms of Timeseries construction:
            //   - As a list of Events
            //   - As a list of points and columns
            //
            // See below.
            //

            const obj = arg1;

            let times = [];
            let data = [];

            if (_.has(obj, "events")) {

                //
                // If events is passed in, then we construct the series out of
                // a list of Event objects
                //

                const {events, utc, index, name, meta} = obj;

                const columns = uniqueKeys(events).toJSON();
                _.each(events, event => {
                    times.push(event.timestamp());
                    data.push(event.data());
                });

                // Optional index associated with this TimeSeries
                if (index) {
                    if (_.isString(index)) {
                        this._index = new Index(index);
                    } else if (index instanceof(Index)) {
                        this._index = index;
                    }
                }

                this._utc = true;
                if (_.isBoolean(utc)) {
                    this._utc = utc;
                }

                // Construct the base series
                super(name, meta, columns, new Immutable.List(data));

                // List of times, as Immutable List
                this._times = new Immutable.List(times);

            } else if (_.has(obj, "columns") && _.has(obj, "points")) {

                const {name, index, utc, points, columns, ...meta} = obj;
                const seriesPoints = points || [];
                const seriesName = name || "";
                const seriesMeta = meta || {};
                const seriesColumns = columns.slice(1) || [];
                const seriesUTC = _.isBoolean(utc) ? utc : true;

                //
                // If columns and points are passed in, then we construct the
                // series out of those, assuming the format of each point is:
                //
                //   [time, col1, col2, col3]
                //
                // TODO: check to see if the first item is the time

                _.each(seriesPoints, point => {
                    const [time, ...others] = point;
                    times.push(time);
                    data.push(others);
                });

                super(seriesName, seriesMeta, seriesColumns, data);

                // Optional index associated with this TimeSeries
                if (index) {
                    if (_.isString(index)) {
                        this._index = new Index(index);
                    } else if (index instanceof(Index)) {
                        this._index = index;
                    }
                }

                // Is this data in UTC or local?
                this._utc = seriesUTC;

                // List of times, as Immutable List
                this._times = Immutable.fromJS(times);
            }
        }
    }

    //
    // Serialize
    //

    /**
     * Turn the TimeSeries into regular javascript objects
     */
    toJSON() {
        const name = this._name;
        const index = this._index;
        const cols = this._columns;
        const series = this._series;
        const times = this._times;

        const points = series.map((value, i) => {
            const data = [times.get(i)]; // time
            cols.forEach((column) => {
                data.push(value.get(column));
            });
            return data;
        }).toJSON();

        // The JSON output has 'time' as the first column
        const columns = ["time"];
        cols.forEach((column) => {
            columns.push(column);
        });

        let result = {
            name: name
        };

        if (index) {
            result.index = index.toString();
        }

        result = _.extend(result, {
            columns: columns,
            points: points
        });

        result = _.extend(result, this._meta.toJSON());

        return result;
    }

    /**
     * Represent the TimeSeries as a string
     */
    toString() {
        return JSON.stringify(this.toJSON());
    }

    //
    // Series range
    //

    /**
     * From the range of times, or Indexes within the TimeSeries, return
     * the extents of the TimeSeries as a TimeRange.
     * @return {TimeRange} The extents of the TimeSeries
     */
    range() {
        let min;
        let max;
        this._times.forEach((time) => {
            if (_.isString(time)) {
                const r = util.rangeFromIndexString(time, this.isUTC());
                if (!min || r.begin() < min) {
                    min = r.begin();
                }
                if (!max || r.end() > max) {
                    max = r.end();
                }
            } else if (_.isNumber(time)) {
                if (!min || time < min) {
                    min = time;
                }
                if (!max || time > max) {
                    max = time;
                }
            }
        });
        return new TimeRange(min, max);
    }

    /**
     * From the range of times, or Indexes within the TimeSeries, return
     * the extents of the TimeSeries as a TimeRange.
     * @return {TimeRange} The extents of the TimeSeries
     */
    timerange() {
        return this.range();
    }

    /**
     * Gets the earliest time represented in the TimeSeries.
     * @return {Date} Begin time
     */
    begin() {
        return this.range().begin();
    }

    /**
     * Gets the latest time represented in the TimeSeries.
     * @return {Date} End time
     */
    end() {
        return this.range().end();
    }

    /**
     * Access the Index, if this TimeSeries has one
     */

    index() {
        return this._index;
    }

    indexAsString() {
        return this._index ? this._index.asString() : undefined;
    }

    indexAsRange() {
        return this._index ? this._index.asTimerange() : undefined;
    }

    /**
     * Is the data in UTC or Local?
     */
    isUTC() {
        return this._utc;
    }

    /**
     * Access the series data via index. The result is an Event.
     */
    at(i) {
        const time = this._times.get(i);
        if (_.isString(time)) {
            const index = time;
            return new IndexedEvent(index, this._series.get(i), this._utc);
        } else {
            return new Event(time, this._series.get(i));
        }
    }

    /**
     * Finds the index that is just less than the time t supplied.
     * In other words every event at the returned index or less
     * has a time before the supplied t, and every sample after the
     * index has a time later than the supplied t.
     *
     * Optionally supply a begin index to start searching from.
     */
    bisect(t, b) {
        const tms = t.getTime();
        const size = this.size();
        let i = b || 0;

        if (!size) {
            return undefined;
        }

        for (; i < size; i++) {
            let ts = this.at(i).timestamp().getTime();
            if (ts > tms) {
                return i - 1 >= 0 ? i - 1 : 0;
            } else if (ts === tms) {
                return i;
            }
        }
        return i - 1;
    }

    /**
     * Perform a slice of events within the TimeSeries, returns a new
     * TimeSeries representing a portion of this TimeSeries from begin up to
     * but not including end.
     */
    slice(begin, end) {
        const size = this.size();
        const b = resolveBegin(begin, size);
        const e = resolveEnd(end, size);

        if (b === 0 && e === size ) {
            return this;
        }

        let events = [];
        for (let i = b; i < e; i++) {
            events.push(this.at(i));
        }

        return new TimeSeries({name: this._name,
                               index: this._index,
                               utc: this._utc,
                               meta: this._meta,
                               events: events});
    }

    /**
     *  Generator to allow for..of loops over series.events()
     */
    * events() {
        for (let i = 0; i < this.size(); i++) {
            yield this.at(i);
        }
    }

    static equal(series1, series2) {
        return (series1._name === series2._name &&
                series1._meta === series2._meta &&
                series1._utc === series2._utc &&
                series1._columns === series2._columns &&
                series1._series === series2._series &&
                series1._times === series2._times);
    }

    static is(series1, series2) {
        return (series1._name === series2._name &&
                series1._utc === series2._utc &&
                Immutable.is(series1._meta, series2._meta) &&
                Immutable.is(series1._columns, series2._columns) &&
                Immutable.is(series1._series, series2._series) &&
                Immutable.is(series1._times, series2._times));
    }
}