tfmalt/power-meter-api

View on GitHub
lib/power-meter-kwh.js

Summary

Maintainability
F
4 days
Test Coverage

/**
 * Power Meter API controller object.
 *
 * Power meter app. An express frontend to reading a power meter with a
 * flashing led using a photo resistive sensor on an Arduino Uno board.
 *
 * This is most of all a toy experiment to get me up to speed on some of
 * the latest web technologies.
 *
 * @author Thomas Malt <thomas@malt.no>
 * @copyright 2015-2017 (c) Thomas Malt <thomas@malt.no>
 */
const util  = require('../lib/power-meter-util');

class PowerMeterKwh {
  assertYear(year) {
    if (!util.isValidYear(year)) {
      throw new TypeError(
        'Usage: /power/kwh/date/:year. ' +
        'Year must be a valid four digit year.'
      );
    }

    if (!util.isYearInRange(year)) {
      throw new RangeError(
        'Usage: /power/kwh/date/:year. Year must be a year ' +
        `between ${util.config.start.year} and this year.`
      );
    }

    return true;
  }

  assertMonth(year, month) {
    const test  = parseInt(month, 10);
    const tyear = parseInt(year, 10);
    const today = new Date();

    if (isNaN(test) || test < 1 || test > 12) {
      throw new TypeError(
        'Usage: /power/kwh/:year/:month - ' +
        ':month must be a valid month between 01 and 12'
      );
    }

    if (tyear >= today.getFullYear() && month > (today.getMonth() + 1)) {
      throw new TypeError(
        ':month is in the future. Please provide a date in the past.'
      );
    }

    return true;
  }

  assertDay(input) {
    const day = parseInt(input, 10);
    if (isNaN(day) || day < 1 || day > 31) {
      throw new RangeError(
        ':day must be an Integer in the range between 1 and 31. ' +
        'Usage: /power/kwh/:year/:month/:day'
      );
    }

    return true;
  }

  handleDateMonth(req, res, redis) {
    const opts = req.params;

    this.assertYear(opts.year);
    this.assertMonth(opts.year, opts.month);

    return redis.lrangeAsync('months', 0, -1)
      .then(values => values.map(JSON.parse))
      .then(values => {
        const month = parseInt(opts.month, 10);
        const year  = parseInt(opts.year, 10);

        return values.find(item => {
          const date = new Date(item.timestamp);
          return (date.getFullYear() === year && (date.getMonth()) === month);
        });
      })
      .then(data => {
        if (typeof data === 'undefined') {
          throw new RangeError(
            'Did not find data for the specified month. ' +
            'This might be outside the period we have data for'
          );
        }
        data.perDay = util.pulsesToKwh(data.perDay);

        return data;
      });
  }

  handleDay(req, res, redis) {
    const opts = req.params;

    this.assertYear(opts.year);
    this.assertMonth(opts.year, opts.month);
    this.assertDay(opts.day);

    return redis.hgetAsync(req.params.year, req.params.month)
      .then(input => {
        if (typeof input === 'string') return JSON.parse(input);

        return this.findDateInDays(opts, redis)
          .then(data => {
            delete data.total;
            data.perHour = util.pulsesToKwh(data.perHour);
            data.description = (
              `kWh usage for ${(new Date(data.timestamp)).toDateString()}`
            );
            return data;
          })
          .then(data => this.saveDateInHash(data, opts, redis));
      })
      .then(result => {
        if (result.hasOwnProperty(opts.day)) return result[opts.day];

        throw new RangeError(
          'Could not find data for given date. It might be outside the data ' +
          'we have stored.'
        );
      })
      .catch(error => {
        throw error;
      });
  }

  /**
   * @param {object} opts input data
   * @return {Promise} data for the date.
   * @private
   */
  findDateInDays(opts, db) {
    return db.lrangeAsync('days', 0, -1)
      .then(values => values.map(JSON.parse))
      .then(values => values.find(item => {
        const date  = util.timestampToDate(item.timestamp);
        const month = parseInt(opts.month, 10) - 1;
        const day   = parseInt(opts.day, 10) - 1;

        if (date.getMonth() !== month) return false;
        if (date.getDate()  !== day)   return false;

        return true;
      }));
  }

  /**
   * @param {object} data objects with data and OPTIONS
   * @param {object} opts parameters from URI
   * @param {RedisClient} redis the redis client
   * @return {Promise} resolved  promise with data
   * @private
   */
  saveDateInHash(data, opts, redis) {
    return redis.hgetAsync(opts.year, opts.month)
      .then(hash => (hash === null) ? {} : JSON.parse(hash))
      .then(hash => {
        hash[opts.day] = data;
        return hash;
      })
      .then(hash => {
        redis.hset(opts.year, opts.month, JSON.stringify(hash));
        return hash;
      });
  }

  /**
   * Handler for fetching the kwh consumption for various intervals.
   * Supports:
   *   kwh/:type/:count
   *   kwh/today
   *   kwh/hour/:count
   *   kwh/day/:count
   *   kwh/week/:count
   *
   * @param {string} type the type
   * @param {intenger} count the number of items to fetch.
   * @param {RedisClient} redis the RedisClient
   * @returns {Promise} a resolved promise with data.
   */
  handleKwh(type, count = 1, redis) {
    const keywords = ['seconds', 'today', 'hour', 'day', 'week', 'month', 'year'];

    if (keywords.indexOf(type) < 0) {
      throw new TypeError(
        'first argument must be a string with one of the keywords: ' +
        keywords.toString()
      );
    }

    switch (type) {
      case 'seconds':
        return this.getKwhSeconds(count, redis);
      case 'today':
        return this.getKwhToday(redis);
      case 'hour':
        return this.getKwhHour(count, redis);
      case 'day':
        return this.getKwhDay(count, redis);
      case 'week':
        return this.getKwhWeek(count, redis);
      default:
        return (count === 'this')
          ? this.getCurrentMonth(redis)
          : this.getKwhMonth(count, redis);
    }
  }

  /**
   * Returns the kwh consumption for today
   *
   * @return {Promise} resolved promise with json
   */
  getKwhToday(db) {
    const now   = new Date();
    const start = this.normalizeDate((new Date()), 'day');
    const diff  = now.getTime() - start.getTime();
    const range = Math.round(diff / (60000));

    const json  = {
      description: 'kWh used today from midnight to now.',
      version: this.version,
      date: now.toJSON(),
      kwh: 0
    }

    if (range === 0) {
      return Promise.resolve(json);
    }

    return this.getRangeFromEnd(range, 'minutes', db)
      .then(values => {
        const sum = values.reduce((a, b) => (a + b.kwh), 0);
        json.kwh = parseFloat(sum.toFixed(4));
        return json;
      });
  }

  /**
   * Adding a controller to fetch out seconds for graphs.
   * Interval is now defined to be 10 seconds.
   *
   * @param {integer} count number of seconds to fetch.
   * @return {Promise} resolved promise with json
   */
  getKwhSeconds(count, db) {
    return this.getSecondsRangeFromEnd(count, db)
      .then(values => {
        const summary = {kwh: {}, watts: {}};

        summary.kwh.total     = parseFloat((values.reduce((a, b) => (a + b.kwh), 0)).toFixed(4));
        summary.kwh.average   = parseFloat((summary.kwh.total / values.length / 10).toFixed(4));
        summary.kwh.max       = parseFloat((values.reduce(
          (a, b) => ((a > b.kwh) ? a : b.kwh), 0
        ) / 10).toFixed(4));
        summary.kwh.min       = parseFloat((values.reduce(
          (a, b) => ((a < b.kwh) ? a : b.kwh), Number.MAX_VALUE
        ) / 10).toFixed(4));
        summary.watts.total   = values.reduce((a, b) => (a + b.watt), 0);
        summary.watts.average = parseInt((summary.watts.total / values.length), 10);
        summary.watts.max     = parseInt((values.reduce(
          (a, b) => ((a > b.watt) ? a : b.watt), 0)
        ), 10);
        summary.watts.min     = parseInt((values.reduce(
          (a, b) => ((a < b.watt) ? a : b.watt), Number.MAX_VALUE)
        ), 10);

        delete summary.watts.total;
        return {summary, values};
      })
      .then(data => {
        data.values = data.values.map(item => {
          item.watt = parseInt(item.watt, 10);
          return item;
        });

        return data;
      })
      .then(data => {
        const date = new Date();
        return {
          description: 'kWh and Watt consumption per second for ' + count + ' seconds',
          count: count,
          time: date.toJSON(),
          timestamp: date.getTime(),
          summary: data.summary,
          list: data.values
        };
      });
  }

  /**
   * Returns the kwh consumed for a number of hours (default 1)
   * @param {integer} count number of hours
   * @return {Promise} resolved promise
   */
  getKwhHour(count = 1, db) {
    return this.getRangeFromEnd(count, 'hours', db)
      .then(values => {
        const summary = this.getCalculatedKwhSummary(values);
        summary.description = 'kWh consumption per hour for ' + count + ' hours.';

        if (summary.list.length !== count) {
          summary.description = summary.description +
            ` ${count} hours was asked for, but only ${summary.list.length}` +
            ' hours was found.';
        }

        summary.count = summary.list.length;
        return summary;
      });
  }

  /**
   * Fetch number of days from database and return api friendly json
   *
   * @param {Integer} count number of days to fetch.
   * @return {Promise} resolved promise with json.
   */
  getKwhDay(count = 1, db) {
    return this.getRangeFromEnd(count, 'days', db)
      .then(values => this.transformCountToKwh(values, 'perHour'))
      .then(values => this.getCalculatedKwhSummary(values))
      .then(summary => {
        summary.description = (
          `kWh consumption per day for ${summary.list.length} days.`
        );
        if (summary.list.length !== count) {
          summary.description = summary.description +
            ` ${count} days was asked for, but only ${summary.list.length}` +
            ' days was found.';
        }
        summary.count = summary.list.length;

        return summary;
      });
  }

  /**
   * Fetch number of weeks from database and return api friendly json
   *
   * @param {Integer} count number of weeks to Fetch
   * @return {Promise} resolved promise with json
   * @private
   */
  getKwhWeek(count = 1, db) {
    return this.getRangeFromEnd(count, 'weeks', db)
      .then(values => this.transformCountToKwh(values, 'perDay'))
      .then(values => this.getCalculatedKwhSummary(values))
      .then(summary => {
        summary.description = `kWh consumption per week for ${summary.list.length} weeks.`;
        if (summary.list.length !== count) {
          summary.description = summary.description +
            ` ${count} weeks was asked for, but only ${summary.list.length}` +
            ' weeks was found.';
        }
        summary.count = summary.list.length;

        return summary;
      });
  }

  /**
   * Fetch number of months from database and return api friendly json
   *
   * @param {Integer} count number of months to Fetch
   * @return {Promise} resolved promise with json
   * @private
   */
  getKwhMonth(count, db) {
    return this.getRangeFromEnd(count, 'months', db)
      .then(values => values.map(item => {
        item.perDay = util.pulsesToKwh(item.perDay);
        return item;
      }))
      .then(values => {
        const summary = this.getCalculatedKwhSummary(values);
        summary.description = 'kWh consumption per month for ' + count + ' months';
        if (summary.list.length !== count) {
          summary.description = summary.description +
            ` ${count} months was asked for, but only ${summary.list.length}` +
            ' months was found.';
        }
        summary.count = summary.list.length;

        return summary;
      });
  }
  /**
   * @return {Promise} data for current month
   * @private
   */
  getCurrentMonth(db) {
    const date = new Date();
    const days = date.getDate();

    return this.getRangeFromEnd(days - 2, 'days', db)
      .then(values => values.reduce((a, b) => (a + b.kwh), 0))
      .then(kwh => (this.getKwhToday(db).then(today => kwh + today.kwh)))
      .then(kwh => parseFloat(kwh.toFixed(4)))
      .then(kwh => {
        return {
          description: (
            'kWh used so far this month, ' +
            util.monthName(date.getMonth()) + ' ' + date.getFullYear()
          ),
          version: this.version,
          date: date.toJSON(),
          kwh: kwh
        };
      });
  }

  /**
   * Helper function to set seconds, minutes and hours to 0 on a date used
   * for lookup in the redis database. Actually a result of factoring out
   * 5 lines of code in the kwh.handler function :/.
   *
   * level: 3 = set minutes to 0, 4 = set hours to 0, 5 = set to first day of week.
   *
   * @param {Date} date Date the date that is now.
   * @param {string} level String to what level we normalise
   * @returns {Date} a formatted date
   * @private
   */
  normalizeDate(date, level = 'millis') {
    const levels = {
      hour: 3,
      day: 4,
      week: 5
    };

    if (!levels.hasOwnProperty(level)) {
      throw new Error(
        'ctrl.normalizeDate: parameter level not set to one of the ' +
        'default values'
      );
    }

    date.setMilliseconds(0);
    date.setSeconds(0);
    date.setMinutes(0);

    if (levels[level] < 4) return date;

    date.setHours(0);

    if (levels[level] < 5) return date;

    // Spool date to first day of the week.
    date.setDate((date.getDate() - date.getDay()));

    return date;
  }

  /**
   * @param {integer} count number of items to fetch
   * @param {integer} list which list to fetch from
   * @param {RedisClient} db the redisclient
   * @return {Promise} json of items from that list.
   * @private
   */
  getRangeFromEnd(count = 1, list = 'hours', db) {
    return db.lrangeAsync(list, count * -1, -1).then(v => v.map(JSON.parse));
  }

  /**
   * Helper function to bootstrap fetching a range of seconds from the end
   * of the list. Assumes the backend stores data in 10 seconds increment
   * (it does).
   *
   * @param {integer} count the length of the range in seconds
   * @param {RedisClient} db the redisclient
   * @return {Promise} a resolved promise with parsed JSON
   */
  getSecondsRangeFromEnd(count = 1, db) {
    return this.getRangeFromEnd(Math.ceil(count / 10), 'seconds', db);
  }

  /**
   * @param {object} values to return
   * @return {object} the formatted json to return
   * @private
   */
  getCalculatedKwhSummary(values) {
    const summary = {
      total: values.reduce((a, b) => (a + b.kwh), 0),
      max: values.reduce((a, b) => (a > b.kwh ? a : b.kwh), 0),
      min: values.reduce((a, b) => (a < b.kwh ? a : b.kwh), Number.MAX_VALUE),
      list: values
    };

    summary.average = parseFloat((summary.total / values.length).toFixed(4));
    summary.total   = parseFloat((summary.total).toFixed(4));

    return summary;
  }

  transformCountToKwh(values, prop) {
    return values.map(item => {
      item[prop] = util.pulsesToKwh(item[prop]);
      return item;
    });
  }
}

module.exports = PowerMeterKwh;