baja/obj/Unit.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Gareth Johnson
 */

/**
 * Defines {@link baja.Unit}.
 * @module baja/obj/Unit
 */
define([ "bajaScript/sys",
        "bajaScript/baja/obj/Dimension",
        "bajaScript/baja/obj/Simple",
        "bajaScript/baja/obj/numberUtil",
        "bajaScript/baja/obj/objUtil",
        "bajaPromises" ], function (
         baja,
         Dimension,
         Simple,
         numberUtil,
         objUtil,
         Promise) {
  
  'use strict';
  
  var SCALE_OFFSET_REGEX = /([*+])([^*+]*)/g,
      SEMICOLON_REGEX = /;/,

      cacheDecode = objUtil.cacheDecode,
      cacheEncode = objUtil.cacheEncode,

      subclass = baja.subclass,
      callSuper = baja.callSuper;

  /**
   * @returns {Promise}
   */
  var getUnitConversion = (function () {
    var UC;

    return function getUnitConversion() {
      if (UC) { return UC; }
      return (UC = baja.importTypes([ 'baja:UnitConversion' ])
        .then(function () {
          return (UC = Promise.resolve(baja.$('baja:UnitConversion')));
        }));
    };
  }());

  /**
   * @inner
   * @param str the scale/offset segment of a string-encoded `BUnit`. Will look
   * like one of four things: *1+1, *1, +1, or empty.
   * @returns {{scale: number, offset: number}} object containing the parsed
   * scale and offset numbers (defaulting to 1 and 0, respectively)
   */
  function parseScaleAndOffset(str) {
    var matches = (str || '').match(SCALE_OFFSET_REGEX),
      obj = { scale: 1, offset: 0 },
      match, numType, numStr, num, i;

    if (!matches) {
      if (str) {
        throw new Error("invalid scale/offset string " + str);
      } else {
        return obj;
      }
    }

    for (i = 0; i < matches.length; i++) {
      match = matches[i];
      numType = match[0] === '*' ? 'scale' : 'offset';
      numStr = match.substr(1);
      num = parseFloat(numStr);

      if (isNaN(num)) {
        throw new Error("invalid " + numType + " " + numStr);
      }

      obj[numType] = num;
    }

    return obj;
  }

  /**
   * Represents a `baja:Unit` in BajaScript.
   *
   * When creating a `Simple`, always use the `make()` method instead of
   * creating a new Object.
   *
   * @class
   * @alias baja.Unit
   * @extends baja.Simple
   */
  var Unit = function Unit(name, symbol, dimension, scale, offset, isPrefix) {
    callSuper(Unit, this, arguments);
    this.$name = name;
    this.$symbol = symbol;
    this.$dimension = dimension;
    this.$scale = scale || 1;
    this.$offset = offset || 0;
    this.$isPrefix = isPrefix || false;
  };
  
  subclass(Unit, Simple);

  /**
   * Default `Unit` instance.
   *
   * @type {baja.Unit}
   */
  Unit.DEFAULT = new Unit("null", "null", Dimension.DEFAULT);

  /**
   * Null `Unit` instance (same as `DEFAULT`).
   *
   * @type {baja.Unit}
   */
  Unit.NULL = Unit.DEFAULT;

  /**
   * Used to maintain an internal cache of `Unit` objects, keyed by name.
   *
   * @private
   * @type {Object}
   */
  Unit.$cache = {
    "null": Unit.NULL
  };


  /**
   * Creates a new instance of `baja.Unit`.
   * 
   * Note that if calling this function directly, multiple calls with the same
   * name but different dimensions will result in an error (no messing with
   * the laws of physics!). Be careful not to manually create `Unit`s with
   * incorrect dimensions that might clash with units specified in the unit
   * database stationside.
   * 
   * @param {String} name the unit name. Must be unique and may not contain a
   * semicolon.
   * @param {String} [symbol=name] the unit symbol. If omitted will default to
   * the unit name. May not contain a semicolon.
   * @param {baja.Dimension} dimension the unit dimension
   * @param {Number} [scale=1] the unit scale
   * @param {Number} [offset=0] the unit offset 
   * @param {Boolean} [isPrefix=false] true if the unit should be prefixed /
   * displayed on the left of the actual value
   * @returns {baja.Unit}
   * @throws if name or symbol contain a semicolon, the dimension is omitted,
   * or if duplicate units are created with the same name but different
   * dimensions
   */
  Unit.make = function (name, symbol, dimension, scale, offset, isPrefix) {
    if (!name || name.match(SEMICOLON_REGEX)) {
      throw new Error("invalid unit name " + name);
    }
    
    if (!symbol) {
      symbol = name;
    } else if (symbol.match(SEMICOLON_REGEX)) {
      throw new Error("invalid symbol " + symbol);
    }
    
    baja.strictArg(dimension, Dimension, 'dimension required');
    
    var unit = Unit.$cache[name];

    if (unit) {
      if (!dimension.equals(unit.$dimension)) {
        throw new Error("Cannot change dimensions of existing unit " + name);
      }

      Unit.call(unit, name, symbol, dimension, scale, offset, isPrefix);

      return unit;
    }
    
    unit = new Unit(name, symbol, dimension, scale, offset, isPrefix);
    Unit.$cache[name] = unit;
    return unit;
  };

  /**
   * @see baja.Unit.make
   * @returns {baja.Unit}
   */
  Unit.prototype.make = function () {
    return Unit.make.apply(this, arguments);
  };

  /**
   * Parse a `baja.Unit` from a `String`.
   * 
   * @method
   * @param {String} str
   * @returns {baja.Unit}
   * @throws if string is malformed or contains invalid unit parameters
   */
  Unit.prototype.decodeFromString = cacheDecode(function (str) {
    if (!str) {
      return Unit.DEFAULT;
    }
    
    var split = str.split(';'),
        name = split[0],
        symbol = split[1],
        dimension = Dimension.DEFAULT.decodeFromString(split[2]),
        scoff = parseScaleAndOffset(split[3]); 
    
    return Unit.make(name, symbol, dimension, scoff.scale, scoff.offset);
  });

  /**
   * Returns the unit symbol.
   *
   * @returns {String}
   */
  Unit.prototype.toString = function () {
    return this.getSymbol();
  };

  /**
   * Encode a `baja.Unit` to a `String`.
   * 
   * @method
   * @returns {String}
   */
  Unit.prototype.encodeToString = cacheEncode(function () {
    var that = this,
        name = that.$name,
        symbol = that.$symbol,
        dimension = that.$dimension,
        scale = that.$scale,
        offset = that.$offset,
        scoff = '';
    
    if (scale !== 1) {
      scoff += '*' + scale;
    }
    
    if (offset !== 0) {
      scoff += '+' + offset;
    }
    
    return [
      name,
      symbol === name ? null : symbol,
      dimension.encodeToString(),
      scoff,
      null
    ].join(';');
  });

  /**
   * Returns the unit name.
   * 
   * @returns {String}
   */
  Unit.prototype.getUnitName = function () {
    return this.$name;
  };

  /**
   * Returns the unit symbol.
   * 
   * @returns {String}
   */
  Unit.prototype.getSymbol = function () {
    return this.$symbol;
  };

  /**
   * Returns the unit dimension.
   * 
   * @returns {baja.Dimension}
   */
  Unit.prototype.getDimension = function () {
    return this.$dimension;
  };

  /**
   * Returns the unit scale.
   * 
   * @returns {Number}
   */
  Unit.prototype.getScale = function () {
    return this.$scale;
  };

  /**
   * Returns the unit offset.
   * 
   * @returns {Number}
   */
  Unit.prototype.getOffset = function () {
    return this.$offset;
  };

  /**
   * Returns true if the unit should be prefixed.
   * 
   * @returns {Boolean}
   */
  Unit.prototype.isPrefix = Unit.prototype.getIsPrefix = function () {
    return this.$isPrefix;
  };

  /**
   * Returns the data type symbol (`u`) for `Facets` encoding.
   * 
   * @returns {String}
   */
  Unit.prototype.getDataTypeSymbol = function () {
    return 'u';
  };

  /**
   * Convert a scalar value from this unit to another.
   *
   * @param {baja.Unit} toUnit
   * @param {number} scalar
   * @returns {number} the converted scalar value
   * @throws {Error} if the two Units are not of the same Dimension
   */
  Unit.prototype.convertTo = function (toUnit, scalar) {
    if (!this.getDimension().equals(toUnit.getDimension())) {
      throw new Error('Not convertible: ' +
        this.getSymbol() + " -> " + toUnit.getSymbol());
    }
    if (this === toUnit) {
      return scalar;
    }
    return ((scalar * this.getScale() + this.getOffset()) - toUnit.getOffset()) / toUnit.getScale();
  };

  /**
   * Friendly API to convert a scalar value to a desired unit.
   *
   * @param {number} scalar a scalar value to convert
   * @param {object} params
   * @param {baja.Unit|string} [params.fromUnits=params.units] the units the scalar is _currently_ in. Can be a unit name, or Units instance. If omitted, considered to be `toUnits` with `unitConversion` applied.
   * @param {string|number|baja.FrozenEnum} [params.unitConversion] used to convert between systems of measure.
   * @param {baja.Unit|string} [params.toUnits] the desired units. If omitted, will be considered to be `fromUnits` with `unitConversion` applied.
   * @param {number} [params.precision=8] specify how many decimal places the resulting scalar should be rounded to.
   * @returns {Promise.<number>} the converted unit.
   *
   * @since Niagara 4.8
   *
   * @example
   * <caption>What is 32 Fahrenheit, as shown in metric units?</caption>
   *
   * return baja.Unit.convert(32, { units: 'fahrenheit', unitConversion: 'metric' })
   *   .then(function (result) {
   *     console.log(result); // 0
   *   });
   *
   * @example
   * <caption>How many centimeters are in 1 inch?</caption>
   *
   * return baja.Unit.convert(1, { fromUnits: 'inch', toUnits: 'centimeter' })
   *   .then(function (result) {
   *     console.log(result); // 2.54
   *   });
   *
   * @example
   * <caption>My data is "12 inches", but the user wants to see metric units.
   * What number should I show them in the UI?</caption>
   *
   * return baja.Unit.convert(12, { fromUnits: 'inch', unitConversion: 'metric' })
   *   .then(function (result) {
   *     console.log(result); // 30.48
   *   });
   *
   * @example
   * <caption>My user entered "100 cm", but my station logic is in inches. What
   * number should I store in the station?</caption>
   *
   * return baja.Unit.convert(100, { unitConversion: 'metric', toUnits: 'inch', precision: 2 })
   *   .then(function (result) {
   *     console.log(result); // 39.37
   *   });
   */
  Unit.convert = function (scalar, params) {
    params = params || {};

    return baja.UnitDatabase.get()
      .then(function (db) {
        var fromUnits = params.fromUnits || params.units;
        var toUnits = params.toUnits;
        var unitConversion = params.unitConversion;
        var precision = params.precision;
        var nullUnit = baja.Unit.NULL;

        if (toUnits) {
          toUnits = db.getUnit(toUnits);
          fromUnits = db.convertUnit(unitConversion, db.getUnit(fromUnits || toUnits));
        } else {
          fromUnits = db.getUnit(fromUnits || nullUnit);
          toUnits = db.convertUnit(unitConversion, fromUnits);
        }

        return toPrecision(fromUnits.convertTo(toUnits, scalar), precision);
      });
  };

  function toPrecision(number, precision) {
    if (typeof precision !== 'number') { precision = 8; }
    var f = Math.pow(10, precision);
    return Math.round(number * f) / f;
  }

  /**
   * Get the desired unit as specified by the input context.
   *
   * @param {Object} [cx]
   * @param {baja.Unit} [cx.units]
   * @param {baja.FrozenEnum|number|string} [cx.unitConversion] accepts a
   * `baja:UnitConversion` enum, ordinal, or tag. If omitted,
   * `baja.getUnitConversion()` (the user-configured unit conversion) will be
   * used.
   * @param {boolean} [cx.showUnits]
   * @returns {Promise.<baja.Unit|null>} resolves the desired display unit as
   * specified. If `showUnits` is false or the units are not specified or
   * `NULL`, resolves `null` to indicate that units are not used for display in
   * this context.
   * @since Niagara 4.8
   * @see baja.Unit#toDesiredUnit
   * @example
   * <caption>Calculate display units based on facets.</caption>
   * var facetsObj = point.getFacets().toObject();
   * return baja.Unit.toDisplayUnits(facetsObj)
   *   .then(function (displayUnits) {
   *     console.log('this point wants to be displayed with units ' + displayUnits);
   *   });
   */
  Unit.toDisplayUnits = function (cx) {
    cx = cx || {};
    var units = cx.units;

    if (cx.showUnits === false || !(units instanceof baja.Unit) || units.equals(Unit.NULL)) {
      return Promise.resolve(null);
    }
    return units.toDesiredUnit(numberUtil.getUnitConversion(cx));
  };

  /**
   * Get the unit that this unit converts to as specified by the given
   * conversion.
   * @param {baja.FrozenEnum|number|string} unitConversion accepts a
   * `baja:UnitConversion` enum, ordinal, or tag.
   * @returns {Promise.<baja.Unit>}
   * @since Niagara 4.8
   * @example
   * return fahrenheitUnit.toDesiredUnit('metric')
   *   .then(function (unit) {
   *     console.log(unit.getUnitName()); // Celsius
   *   });
   */
  Unit.prototype.toDesiredUnit = function (unitConversion) {
    var that = this;

    if (!unitConversion) { return Promise.resolve(that); }

    return baja.UnitDatabase.get()
      .then(function (db) {
        return Promise.resolve(getConversionOrdinal(unitConversion))
          .then(function (ordinal) {
            var tag = baja.$('baja:UnitConversion', ordinal).getTag();
            return db.convertUnit(tag, that);
          });
      });
  };

  /**
   * @param {baja.Enum|Number|String} unitConversion - the `baja:UnitConversion`
   * enum, an ordinal, or tag.
   *
   * @returns {Number|Promise}
   */
  function getConversionOrdinal(unitConversion) {
    if (baja.hasType(unitConversion, 'baja:Enum')) {
      return unitConversion.getOrdinal();
    }

    if (baja.hasType(unitConversion, 'baja:Number')) {
      return unitConversion.valueOf();
    }

    if (typeof unitConversion === 'string') {
      return getUnitConversion()
        .then(function (UC) {
          if (!UC.getRange().isTag(unitConversion)) {
            throw new Error('invalid BUnitConversion tag ' + unitConversion);
          }
          return UC.get(unitConversion).getOrdinal();
        });
    }

    return null;
  }

  return Unit;
});