yr.no-forecast
Version:
retrieve a weather forecast for a given time and location from met.no
322 lines (257 loc) • 8.35 kB
JavaScript
const log = require('debug')(require('./package.json').name);
const moment = require('moment');
const XML = require('pixl-xml');
const VError = require('verror');
const each = require('lodash.foreach');
const Promise = require('bluebird');
/**
* "simple" nodes are those with very basic detail. 1 to 4 of these follow a
* node with more details
* @param {Object} node
* @return {Boolean}
*/
function isSimpleNode (node) {
return node.location.symbol !== undefined;
}
/**
* Check if a node has a min and max temp range
* @param {Object} node
* @return {Boolean}
*/
function hasTemperatureRange (node) {
return node.location.minTemperature && node.location.maxTemperature;
}
/**
* Convert a momentjs date object into an ISO string compatible with our XML
* @param {Date} date
* @return {String}
*/
function dateToForecastISO (date) {
return date.utc().format('YYYY-MM-DDTHH:mm:ss[Z]');
}
module.exports = (config) => {
// Make a default config, but extend it with the passed config
config = Object.assign({
version: 1.9
}, config);
// Create a yrno instance with any overrides required
const yrno = require('yr.no-interface')({
request: config.request
});
return {
/**
* Public call to get a LocationForecast instance.
* Version is optional.
* @param {Object} params
* @param {String} [version]
*/
getWeather: (params, version) => {
version = version || config.version;
log('requesting a locationforecast using API version %s', version);
return Promise.fromCallback(function (callback) {
// Make a standard call to the API
yrno.locationforecast({
query: params,
version: version
}, function(err, body) {
if (err) {
log('failed to get locationforecast from yr.no API. Error:', err);
return callback(err, null);
}
log('successfully retrieved locationforecast report from yr.no');
// Wrap the response from API
callback(null, new LocationForecast(body));
});
});
}
};
};
/**
* @constructor
* @param {String} xml
*/
function LocationForecast(xml) {
this.xml = xml;
// Map containing weather info for given utc times
this.times = {
// e.g '2017-04-29T01:00:00Z': { DATA HERE }
};
log('building LocationForecast object by parsing xml to JSON');
var startDt = Date.now();
// Parse to JSON and return this object on success
try {
this.json = XML.parse(xml, {preserveDocumentNode: true});
log('parsing xml to json took %dms', Date.now() - startDt);
} catch (e) {
throw new VError(e, 'failed to parse returned xml string to JSON');
}
this._init();
log('LocationForecast init complete in %sms', Date.now() - startDt);
return this;
}
LocationForecast.prototype = {
_init: function () {
var self = this;
each(this.json.weatherdata.product.time, function (node) {
const simple = isSimpleNode(node);
const temps = hasTemperatureRange(node);
if (!simple) {
self.times[node.to] = node;
} else {
// node is a small/simple node with format
// <time datatype="forecast" from="2017-04-28T22:00:00Z" to="2017-04-28T23:00:00Z">
// <location altitude="17" latitude="34.0522" longitude="118.2437">
// <precipitation unit="mm" value="0.0"/>
// <symbol id="Sun" number="1"/>
// </location>
// </time>
const parent = self.times[node.to];
parent.icon = node.location.symbol.id;
parent.rain = node.location.precipitation.value + ' ' + node.location.precipitation.unit;
parent.rainDetails = {
minRain: 'minvalue' in node.location.precipitation ? node.location.precipitation.minvalue : null,
maxRain: 'minvalue' in node.location.precipitation ? node.location.precipitation.maxvalue : null,
rain: node.location.precipitation.value,
unit: node.location.precipitation.unit
};
/* istanbul ignore else */
if (temps) {
parent.minTemperature = node.location.minTemperature;
parent.maxTemperature = node.location.maxTemperature;
}
}
});
},
/**
* Returns the JSON representation of the parsed XML`
* @return {Object}
*/
getJson: function() {
return this.json;
},
/**
* Return the XML string that the met.no api returned
* @return {String}
*/
getXml: function() {
return this.xml;
},
/**
* Returns the earliest ISO timestring available in the weather data
* @return {String}
*/
getFirstDateInPayload: function () {
return this.json.weatherdata.product.time[0].from;
},
/**
* Returns the latest ISO timestring available in the weather data
* @return {String}
*/
getLastDateInPayload: function () {
return this.json.weatherdata.product.time[this.json.weatherdata.product.time.length - 1].from;
},
/**
* Returns an array of all times that we have weather data for
* @return {Array<String>}
*/
getValidTimestamps: function () {
return Object.keys(this.times);
},
/**
* Get five day weather.
* @param {Function} callback
*/
getFiveDaySummary: function() {
const startDate = moment.utc(this.getFirstDateInPayload());
const baseDate = startDate.clone().set('hour', 12).startOf('hour');
let firstDate = baseDate.clone();
log(`five day summary is using ${baseDate.toString()} as a starting point`);
/* istanbul ignore else */
if (firstDate.isBefore(startDate)) {
// first date is unique since we may not have data back to midday so instead we
// go with the earliest available
firstDate = startDate.clone();
}
log(`getting five day summary starting with ${firstDate.toISOString()}`);
return Promise.all([
this.getForecastForTime(firstDate),
this.getForecastForTime(baseDate.clone().add(1, 'days')),
this.getForecastForTime(baseDate.clone().add(2, 'days')),
this.getForecastForTime(baseDate.clone().add(3, 'days')),
this.getForecastForTime(baseDate.clone().add(4, 'days'))
])
.then(function (results) {
// Return a single array of objects
return Array.prototype.concat.apply([], results);
});
},
/**
* Verifies if the pased timestamp is a within range for the weather data
* @param {String|Number|Date} time
* @return {Boolean}
*/
isInRange: function (time) {
return moment.utc(time)
.isBetween(
moment(this.getFirstDateInPayload()),
moment(this.getLastDateInPayload())
);
},
/**
* Returns a forecast for a given time.
* @param {String|Date} time
* @param {Function} callback
*/
getForecastForTime: function (time) {
time = moment.utc(time);
if (time.isValid() === false) {
return Promise.reject(
new Error('Invalid date provided for weather lookup')
);
}
if (time.minute() > 30) {
time.add('hours', 1).startOf('hour');
} else {
time.startOf('hour');
}
log('getForecastForTime', dateToForecastISO(time));
let data = this.times[dateToForecastISO(time)] || null;
/* istanbul ignore else */
if (!data && this.isInRange(time)) {
data = this.fallbackSelector(time);
}
/* istanbul ignore else */
if (data) {
data = Object.assign({}, data, data.location);
delete data.location;
}
return Promise.resolve(data);
},
fallbackSelector: function (date) {
log('using fallbackSelector for date', date);
const datetimes = Object.keys(this.times);
let closest = null;
let curnode, curTo;
let len = datetimes.length - 1;
while (len) {
curnode = this.times[datetimes[len]];
curTo = moment(curnode.to);
if (date.isSame(curTo, 'day')) {
if (!closest) {
closest = curnode;
} else {
/* istanbul ignore else */
if (Math.abs(date.diff(curTo)) < Math.abs(date.diff(moment(closest.to)))) {
closest = curnode;
}
}
} else if (closest) {
// we found a node, and no more nodes exist for the day we need...BAIL
break;
}
len--;
}
return closest;
}
};
;