API Docs for: 0.0.1
Show:

File: lib/parsers/pro.js

/*
* niViz -- snow profiles visualization
* Copyright (C) 2015 WSL/SLF - Fluelastrasse 11 - 7260 Davos Dorf - Switzerland.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

(function (niviz, moment) {
  'use strict';

  // --- Module Dependencies ---
  var properties = Object.defineProperties;

  var Station = niviz.Station;
  var Profile = niviz.Profile;
  var Config  = niviz.Config;
  var Parser  = niviz.Parser;
  var Feature = niviz.Feature;

  var util       = niviz.util;
  var alias      = util.alias;
  var assert     = util.assert;
  var literal    = util.literal;
  var underscore = util.underscore;
  var zip        = util.zip;

  /** @module niviz */

  /**
   * Snowpack PRO file parser. A PRO file is parsed line
   * by line in a single pass (supports streamed data).
   *
   * @class ProParser
   * @constructor
   *
   * @extends StringParser
   */
  function ProParser(data) {
    ProParser.uber.call(this, data);

    /**
     * When true (default), the parser will only accept
     * lines in their respective sections.
     *
     * @property strict
     * @type Boolean
     */
    this.strict = true;

    /**
     * The current parsing mode. The mode corresponds to
     * the different sections in a PRO file:
     *
     * - 'station_parameters'
     * - 'header'
     * - 'data'
     *
     * @property mode
     * @type String
     */
    this.mode;

    /**
     * The current PRO version used. Niviz supports 1.0 and 1.4
     *
     * @property version
     * @type Number
     */
    this.version = 1;

    /**
     * Contains the parsed property headers.
     *
     * @property properties
     * @type Object
     */
    this.properties = {};

    /**
     * The snow station instance; the instance should
     * be populated by the parsing methods and represents
     * the parse result.
     *
     * @property station
     * @type Station
     */
    this.station = new Station();
    alias(this, 'result', 'station');
  }

  Parser.implement(ProParser, 'pro', 'string');

  Parser.format['apro'] = ProParser;

  /**
   * Default conversion table for PRO property codes
   * to names used by Niviz. Editable by the user.
   *
   * @property codes
   * @type Config
   * @static
   */
  ProParser.codes = new Config('Pro Parser', [
    { name: 'pro',   type: 'pro',
      default: [
        { name: 'Legacy version', version: 1.0, codes: [
          { code: '0502', name: 'density', display: 'Snow density', unit: 'kg/m\u00b3',
            editable: false},
          { code: '0503', name: 'temperature', display: 'Snow temperature', unit: '°C',
            editable: false},
          { code: '0506', name: 'wetness', display: 'Liquid water content', unit: '%',
            editable: false},
          { code: '0508', name: 'dendricity', display: 'Dendricity', editable: false},
          { code: '0509', name: 'sphericity', display: 'Sphericity', editable: false},
          { code: '0510', name: 'cn', display: 'Coordination number', editable: false},
          { code: '0511', name: 'bondsize', display: 'Bondsize', editable: false},
          { code: '0512', name: 'grainsize', display: 'Grain size', unit: 'mm',
            editable: false},
          { code: '0513', name: 'grainshape', display: 'Grain shape', editable: false},
          { code: '0530', name: 'properties', editable: false},
          { code: '0534', name: 'hardness', display: 'Snow hardness', unit: 'N',
            editable: false}
        ]},
        { name: 'Version 1.4', version: 1.4, codes: [
          { code: '0503', name: 'density', display: 'Snow density', unit: 'kg/m\u00b3',
            editable: false},
          { code: '0506', name: 'wetness', display: 'Liquid water content', unit: '%',
            editable: false},
          { code: '0510', name: 'temperature', display: 'Snow temperature', unit: '°C',
            editable: false},
          { code: '0513', name: 'grainshape', display: 'Grain shape', editable: false},
          { code: '0516', name: 'grainsize', display: 'Grain size', unit: 'mm',
            editable: false},
          { code: '0517', name: 'bondsize', display: 'Bondsize', editable: false},
          { code: '0518', name: 'dendricity', display: 'Dendricity', editable: false},
          { code: '0519', name: 'sphericity', display: 'Sphericity', editable: false},
          { code: '0520', name: 'cn', display: 'Coordination number', editable: false},
          { code: '0530', name: 'properties', editable: false},
          { code: '0540', name: 'hardness', display: 'Snow hardness', unit: 'N',
            editable: false},
          { code: '0910', name: 'tn', display: 'Snow node temperature', unit: '°C',
            editable: false}
        ]}
      ]
    }
  ]);

  ProParser.codes.load();

  /**
   * Get the conversion table which is a key/value object,
   * key being the PRO file code and the value being the property
   * name used within niViz.
   *
   * @method getcodes
   * @static
   * @return {Object}
   */
  ProParser.getcodes = function (version) {
    version = version || 1;

    var pro = ProParser.codes.values.pro, i, ii = pro.length, result = {};
    for (i = 0; i < ii; ++i) {
      if (pro[i].version === version) {
        pro = pro[i].codes;
        ii = pro.length;
        break;
      }
    }

    for (i = 0; i < ii; ++i) {
      result[pro[i].code] = pro[i].name;
      if (!Feature.type[pro[i].name] && pro[i].code !== '0530')
        Feature.register(pro[i].name, {
          name: pro[i].display,
          unit: pro[i].unit
        });
    }

    result['0500'] = 'date';
    result['0501'] = 'height';
    result['0530'] = 'properties';

    return result;
  };

  /**
   * A list of date/time formats
   * recognized by the parser.
   *
   * @property dates
   * @type Array<String>
   * @static
   */
  ProParser.dates = [
    'DD.MM.YYYY HH:mm',
    'DD.MM.YYYY HH:mm:ss',
    moment.ISO_8601
  ];

  /**
   * The conversion table for PRO property codes
   * to names used by Niviz.
   *
   * @property code
   * @type Object
   * @static
   */
  ProParser.code = ProParser.getcodes();

  ProParser.codelist = {
    '0500': 'date',
    '0501': 'height',
    '0502': 'density',
    '0503': 'temperature',
    '0506': 'wetness',
    '0508': 'dendricity',
    '0509': 'sphericity',
    '0510': 'cn',
    '0511': 'bondsize',
    '0512': 'grainsize',
    '0513': 'grainshape',
    //'0515': 'ice volume fraction (%)',
    //'0516': 'air volume fraction (%)',
    //'0517': 'stress',
    //'0518': 'viscosity',
    //'0519': 'soil volume fraction (%)',
    //'0520': 'temperature gradient',
    //'0521': 'thermal conductivity',
    //'0522': 'absorbed shortwave radiation (W m-2)',
    //'0523': 'viscous deformation rate (1.e-6 s-1)',
    '0530': 'properties',
    //'0531': 'deformation rate stability index Sdef',
    //'0532': 'natural stability index Sn38',
    //'0533': 'stability index Sk38',
    '0534': 'hardness'
    //'0535': 'inverse texture index ITI (Mg m-4)',
    //'0601': 'snow shear strength (kPa)',
    //'0602': 'grain size difference (mm)',
    //'0603': 'hardness difference (1)'
  };

  ProParser.reserved = [];
  for (var key in ProParser.codelist) {
    ProParser.reserved.push(ProParser.codelist[key]);
  }

  ProParser.propertyNames = ['profile_type', 'stability_class',
                             'z_sdef', 'sdef', 'z_sn38', 'sn38', 'z_sk38', 'sk38'];

  properties(ProParser, {
    /**
     * A list of all known data value types.
     * For each type, there is a corresponding
     * parser/converter in `.converters`.
     *
     * @property types
     * @type Array<String>
     * @static
     */
    types: {
      get: function () { return Object.keys(this.converters); }
    }
  });

  /**
   * Type converter functions for PRO data values.
   *
   * @property converter
   * @type Object
   * @static
   */
  ProParser.converter = {

    'Date': function pro$date(value) {
      return moment.utc(value, ProParser.dates, true);
    },

    'nElems': function pro$numbers(value) {
      var numbers = value.split(/\s*,\s*/);
      var max   = parseInt(numbers.shift(), 10);

      if (numbers.length > max)
        throw new Error('possible buffer overflow detected');

      return numbers.map(literal);
    },

    'nNodes': function pro$numbers(value) {
      var numbers = value.split(/\s*,\s*/);
      var max   = parseInt(numbers.shift(), 10);

      if (numbers.length > max)
        throw new Error('possible buffer overflow detected');

      return numbers.map(literal);
    },

    'properties': function pro$properties(value) {
      var numbers = value.split(/\s*,\s*/).map(literal);
      var max   = parseInt(numbers.shift(), 10);
      var object = {};

      if (numbers.length !== 8)
        throw new Error('0530: wrong number of propeties');

      ProParser.propertyNames.forEach(function (item, i) {
        object[item] = numbers[i];
      });

      return object;
    }

  };

  // --- Lexical Line Patterns ---

  var VERSION = /\#\s+PRO\s+(\d{1}.\d{1,3})\s+ASCII/i;
  var BLANK   = /^\s*([#";]|$)/;
  var SECTION = /^\[(\w+)\]/;
  var PARAM   = /^([\w:\.-]+)\s*=\s*(.+)$/;
  //var HEADER  = /^(\d{4}),([a-z0-9\-]+)(,(.+))?$/i;
  //var HEADER  = /^(\d{4}),([a-z0-9]+)(\+\d){0,1}/i;
  //var HEADER = /^(\d{4}),([a-z0-9]+)(\+\d){0,1}(,(([\w\s\/\%\<\>\:]))+)*/i;
  var HEADER = /^(\d{4}),([a-z0-9]+)(\+\d){0,1}((,[\w\s\/\%\<\>\:=\(\)\-\+.]*)*)/i;
  var DATA    = /^(\d{4}),(.+)$/i;

  //var LABELS  = /:\s+([\w\s,]+)$/i;

  // --- Parse Methods ---

  ProParser.prototype.meta = function () {
    ProParser.codes.load();
    ProParser.code = ProParser.getcodes(this.version);

    this.station.meta = {};
    for (var property in ProParser.code) {
      if (ProParser.code.hasOwnProperty(property)) {
        this.station.meta[ProParser.code[property]] = this.properties[property];
      }
    }
  };

  /**
   * Lexically analyses a line and parses it accordingly.
   *
   * Note: When strict parsing is active (default), this
   * method will reject out-of-section lines even if they
   * are syntactically correct (e.g., header definitions
   * outside of a header section).
   *
   * @method parse_line
   * @private
   *
   * @param {String} line
   */
  ProParser.prototype.parse_line = function (line) {
    var m;

    switch (true) {

    case !!(m = VERSION.exec(line)):
      if (m.length) this.version = parseFloat(m[1]);
      break;

    case BLANK.test(line):
      break;

    case !!(m = SECTION.exec(line)):
      this.mode = underscore(m[1]);

      if (this.mode === 'data') {
        this.meta();

        this.parse_line = function (line) {
          var m;

          switch (true) {

          case BLANK.test(line):
            break;

          case !!(m = DATA.exec(line)):
            this.parse_data(m[1], m[2]);
            break;

          default:
            throw this.error('unmatched line: ' + line);
          }
        };
      }

      break;

    case !!(m = PARAM.exec(line)):
      this.parse_parameter(m[1], m[2]);
      break;

    case !!(m = HEADER.exec(line)):
      this.parse_header(m[1], m[2], m[4] || '');
      break;

    case !!(m = DATA.exec(line)):
      this.parse_data(m[1], m[2]);
      break;

    default:
      throw this.error('unmatched line');
    }
  };

  /**
   * Take all the layer tops and create three arrays: one with all the layer bottoms,
   * one with all the tops above ground, and one with all the bottoms above ground.
   *
   * @method sanitize
   * @private
   *
   * @param {Array<Number>} tops The layer tops
   * @return {Object} Object with three arrays: bottoms, above0 and bottoms0
   */
  ProParser.prototype.sanitize = function (tops) {
    var below0 = tops.length && tops[0] < 0, ii = tops.length, bottoms, above0, bottoms0,
      nodes, nodes0;

    above0 = tops.filter(function (top) {
      return top > 0;
    });

    if (ii) bottoms = tops.slice(0, ii - 1);
    if (above0.length) {
      bottoms0 = above0.slice(0, above0.length - 1);
      bottoms0.unshift(0);
    }

    if (!below0 && ii) bottoms.unshift(0);
    if (below0 && ii) tops.shift();

    nodes = tops.slice(0, ii);
    if (bottoms && bottoms.length) nodes.unshift(bottoms[0]);

    nodes0 = above0.slice(0, above0.length);
    if (bottoms0 && bottoms0.length) nodes0.unshift(bottoms0[0]);

    return {
      bottoms: bottoms,
      above0: above0,
      bottoms0: bottoms0,
      nodes: nodes,
      nodes0: nodes0
    };
  };

  /**
   * Parses profile data and adds it to the station.
   *
   * This method consumes lines until a property id
   * repeats or the following line does not start
   * with an id.
   *
   * @method parse_data
   * @private
   *
   * @chainable
   */
  ProParser.prototype.parse_data = function (id, data) {
    ProParser.codes.load();
    ProParser.code = ProParser.getcodes(this.version);

    this.restrict('data');
    assert(id === '0500', 'DATA 0500 expected');

    var next = this.skip('0500'), type, props, values, m, hoar;
    if (!next) return this; // v1.4 allows to skip 0501

    if (!(m = DATA.exec(next)) || m[1] !== '0501')
      throw this.error('DATA 0501 expected');

    var tops = this.convert(m[1], m[2]);
    var heights = this.sanitize(tops);
    var profile = new Profile(this.convert(id, data));

    profile.top = tops[tops.length - 1] || 0;
    profile.bottom = heights.bottoms ? heights.bottoms[0] : 0;

    // Add comments profile property
    add(profile, 'comments', 'nElems', heights,
        Array.apply(null, {length: tops.length}).map(function() { return ''; }),
        tops);

    while ((next = this.skip('0500'))) {
      if (!(m = DATA.exec(next)))
        throw this.error('DATA expected');

      type = ProParser.code[m[1]];
      props = this.properties[m[1]];
      if (!type) continue;

      values = this.convert(m[1], m[2]);
      if (type !== 'properties' && !values.length) continue;

      switch (type) {

      case 'properties':
        if (values.sk38 === undefined || values.z_sk38 === undefined) break;
        profile.add('sk38', zip([values.z_sk38],
                                [{ classnum: values.stability_class,
                                   sk38: values.sk38 }]));
        break;

      case 'hardness':
        if (tops.length === values.length) {
          profile.add(type, zip(tops, values.map(Math.abs), heights.bottoms));
        } else if (heights.above0.length === values.length) {
          profile.add(type, zip(heights.above0, values.map(Math.abs), heights.bottoms0));
        }

        break;

      case 'grainshape':
        hoar = false;
        values[values.length - 1] === 660 ? hoar = true : hoar = false;
        values = values.slice(0, values.length - 1);
        if (!values.length) continue;

        if (hoar) profile.sh = true; // SH present, legacy version

        if (tops.length === values.length) {
          profile.add(type, zip(tops, values, heights.bottoms));
        } else if (heights.above0.length === values.length) {
          profile.add(type, zip(heights.above0, values, heights.bottoms0));
        }

        break;

      default:
        add(profile, type, props, heights, values, tops);
      }
    }

    this.station.push(profile);
    this.emit('profile', profile);

    return this;
  };

  function add (profile, type, props, heights, values, tops) {
    if (!tops.length) return;

    if (props.type === 'nNodes') {
      if (heights.nodes.length === values.length) {
        profile.add(type, zip(heights.nodes, values, heights.nodes));
      } else if (heights.nodes0.length === values.length) {
        profile.add(type, zip(heights.nodes0, values, heights.nodes0));
      } else {
        //throw this.error('expected value for each layer!');
      }
    } else if (tops.length === values.length) {
      profile.add(type, zip(tops, values, heights.bottoms));
    } else if (heights.above0.length === values.length) {
      profile.add(type, zip(heights.above0, values, heights.bottoms0));
    } else {
      //throw this.error('expected value for each layer!');
    }
  }

  /**
   * Returns the next line that is not a blank line
   * or comment; if given, the next line that starts
   * with `until` is treated like EOD.
   *
   * @method skip
   *
   * @param {String} [until]
   * @returns {String|undefined} The next line.
   */
  ProParser.prototype.skip = function (until) {
    return this.constructor.uber.prototype.skip.call(this, BLANK, until);
  };

  /**
   * @method parse_parameter
   * @private
   *
   * @chainable
   */
  ProParser.prototype.parse_parameter = function (name, value) {
    this.restrict('station_parameters');

    name = name.toLowerCase();
    value = literal(value);

    switch (name) {

    case 'stationname':
      this.station.name = value;
      break;

    case 'latitude':
    case 'longitude':
    case 'altitude':
      this.station.position[name] = value;
      break;

    case 'slopeangle':
      this.station.position.angle = value;
      break;

    case 'slopeazi':
      this.station.position.azimuth = value;
      break;

    default:
      // skip parameter
    }

    return this;
  };


  /**
   * Parses a profile header line and registers the
   * property and the corresponding value converter
   * function in `properties`.
   *
   * Note: Since header definitions can span multiple
   * lines, this method peeks ahead and consumes
   * subsequent indented lines!
   *
   * @method parse_header
   * @private
   *
   * @param {String} id
   * @param {String} type
   * @param {String} description
   *
   * @chainable
   */
  ProParser.prototype.parse_header = function (id, type, description) {
    var labels = [];
    this.restrict('header');

    // Support multi-line headers!
    while ((/\s|[a-z]/i).test(this.peek()))
      description += ' ' + this.next(); // HACK: Deal with linebreaks properly

    // Extract labels (if present)!
    // if ((m = LABELS.exec(description)))
    //   labels = m[1].split(/\s*,\s*/).map(underscore);
    labels = description.split(',');

    if (type.match(/^\d+$/)) type = 'nElems'; // type consists of digits only
    if (type.match(/^nNodes$/i)) type = 'nNodes'; // type consists of digits only

    this.properties[id] = { id: id, type: type };

    if (labels.length) {
      this.properties[id].display  = labels.length > 1 ? labels[1] : null;
      this.properties[id].name     = labels.length > 2 ? labels[2] : null;
      this.properties[id].unit     = labels.length > 3 ? labels[3] : null;
      this.properties[id].left     = labels.length > 4 ? labels[4] : null;
      this.properties[id].right    = labels.length > 5 ? labels[5] : null;
      this.properties[id].log      = labels.length > 6 ? labels[6] : null;
      this.properties[id].gradient = labels.length > 7 ? labels[7] : null;
    }

    return this;
  };

  /**
   * Looks up the converter for type of `id` and converts
   * the value; throws an error if there is no
   * suitable converter available.
   *
   * @method convert
   * @private
   *
   * @param {String} id
   * @param {String} value
   *
   * @return {Moment|Array|Object} The converted value.
   */
  ProParser.prototype.convert = function (id, value) {
    var property = this.properties[id];

    if (id === '0530') property.type = 'properties';

    if (!property)
      throw this.error('no type definition for "%s"', id);

    var convert = ProParser.converter[property.type];

    if (!convert)
      throw this.error('no converter for "%s"', property.type);

    value = convert(value);

    if (property.labels)
      value = label(property.labels, value);

    return value;
  };

  /**
   * Ensure that the current mode is `mode`; throws
   * an Error otherwise.
   *
   * Note: Does not throw if `.strict` is set to false.
   *
   * @method restrict
   * @private
   *
   * @param {String} mode
   *
   * @returns {Boolean} Whether or not `mode` is current.
   */
  ProParser.prototype.restrict = function (mode) {
    var matches;

    if (!(matches = (this.mode === mode)) && this.strict)
      throw this.error('bad section: %s expected, was %s', mode, this.mode);

    return matches;
  };

  // --- Helpers ---

  function label(labels, values) {
    if (labels.length !== values.length) return values;
    assert(labels.length === values.length);
    var i, ii, obj = {};

    for (i = 0, ii = labels.length; i < ii; ++i)
      obj[labels[i]] = values[i];

    return obj;
  }

  // --- Module Exports ---
  niviz.ProParser = ProParser;

}(niviz, moment));