Merge pull request #77 from OpenSprinkler/eto

Add ETo Support
This commit is contained in:
Matthew Oslan
2019-07-16 17:33:23 -04:00
committed by GitHub
17 changed files with 1113 additions and 13 deletions

View File

@@ -0,0 +1,43 @@
import * as moment from "moment";
import { expect } from "chai";
import { GeoCoordinates } from "../../types";
import { calculateETo, EToData } from "./EToAdjustmentMethod";
const testData: TestData[] = require( "../../test/etoTest.json" );
describe( "ETo AdjustmentMethod", () => {
describe( "Should correctly calculate ETo", async () => {
for ( const locationData of testData ) {
it( "Using data from " + locationData.description, async () => {
let date = moment.unix( locationData.startTimestamp );
for ( const entry of locationData.entries ) {
const etoData: EToData = {
...entry.data,
precip: 0,
periodStartTime: date.unix(),
weatherProvider: "mock"
};
const calculatedETo = calculateETo( etoData, locationData.elevation, locationData.coordinates );
// Allow a small margin of error for rounding, unit conversions, and approximations.
expect( calculatedETo ).approximately( entry.eto, 0.003 );
date = date.add( 1, "days" );
}
} );
}
} );
} );
interface TestData {
description: string;
source: string;
startTimestamp: number;
elevation: number;
coordinates: GeoCoordinates;
entries: {
eto: number,
/** This is not actually full EToData - it is missing `timestamp`, `weatherProvider`, and `precip`. */
data: EToData
}[];
}

View File

@@ -0,0 +1,236 @@
import * as SunCalc from "suncalc";
import * as moment from "moment";
import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod";
import { BaseWateringData, GeoCoordinates, PWS } from "../../types";
import { WeatherProvider } from "../weatherProviders/WeatherProvider";
/**
* Calculates how much watering should be scaled based on weather and adjustment options by comparing the recent
* potential ETo to the baseline potential ETo that the watering program was designed for.
*/
async function calculateEToWateringScale(
adjustmentOptions: EToScalingAdjustmentOptions,
coordinates: GeoCoordinates,
weatherProvider: WeatherProvider,
pws?: PWS
): Promise< AdjustmentMethodResponse > {
if ( pws ) {
throw "ETo adjustment method does not support personal weather stations through WUnderground.";
}
// Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future.
/*
if ( wateringData && wateringData.raining ) {
return {
scale: 0,
rawData: { raining: 1 }
}
}
*/
// This will throw an error message if ETo data cannot be retrieved.
const etoData: EToData = await weatherProvider.getEToData( coordinates );
let baseETo: number;
// Default elevation is based on data from https://www.pnas.org/content/95/24/14009.
let elevation = 600;
if ( adjustmentOptions && "baseETo" in adjustmentOptions ) {
baseETo = adjustmentOptions.baseETo
} else {
throw "A baseline potential ETo must be provided.";
}
if ( adjustmentOptions && "elevation" in adjustmentOptions ) {
elevation = adjustmentOptions.elevation;
}
const eto: number = calculateETo( etoData, elevation, coordinates );
const scale = Math.floor( Math.min( Math.max( 0, ( eto - etoData.precip ) / baseETo * 100 ), 200 ) );
return {
scale: scale,
rawData: {
eto: Math.round( eto * 1000) / 1000,
radiation: Math.round( etoData.solarRadiation * 100) / 100,
minT: Math.round( etoData.minTemp ),
maxT: Math.round( etoData.maxTemp ),
minH: Math.round( etoData.minHumidity ),
maxH: Math.round( etoData.maxHumidity ),
wind: Math.round( etoData.windSpeed * 10 ) / 10,
p: Math.round( etoData.precip * 100 ) / 100
},
wateringData: etoData
}
}
/* The implementation of this algorithm was guided by a step-by-step breakdown
(http://edis.ifas.ufl.edu/pdffiles/ae/ae45900.pdf) */
/**
* Calculates the reference potential evapotranspiration using the Penman-Monteith (FAO-56) method
* (http://www.fao.org/3/X0490E/x0490e07.htm).
*
* @param etoData The data to calculate the ETo with.
* @param elevation The elevation above sea level of the watering site (in feet).
* @param coordinates The coordinates of the watering site.
* @return The reference potential evapotranspiration (in inches per day).
*/
export function calculateETo( etoData: EToData, elevation: number, coordinates: GeoCoordinates ): number {
// Convert to Celsius.
const minTemp = ( etoData.minTemp - 32 ) * 5 / 9;
const maxTemp = ( etoData.maxTemp - 32 ) * 5 / 9;
// Convert to meters.
elevation = elevation / 3.281;
// Convert to meters per second.
const windSpeed = etoData.windSpeed / 2.237;
// Convert to megajoules.
const solarRadiation = etoData.solarRadiation * 3.6;
const avgTemp = ( maxTemp + minTemp ) / 2;
const saturationVaporPressureCurveSlope = 4098 * 0.6108 * Math.exp( 17.27 * avgTemp / ( avgTemp + 237.3 ) ) / Math.pow( avgTemp + 237.3, 2 );
const pressure = 101.3 * Math.pow( ( 293 - 0.0065 * elevation ) / 293, 5.26 );
const psychrometricConstant = 0.000665 * pressure;
const deltaTerm = saturationVaporPressureCurveSlope / ( saturationVaporPressureCurveSlope + psychrometricConstant * ( 1 + 0.34 * windSpeed ) );
const psiTerm = psychrometricConstant / ( saturationVaporPressureCurveSlope + psychrometricConstant * ( 1 + 0.34 * windSpeed ) );
const tempTerm = ( 900 / ( avgTemp + 273 ) ) * windSpeed;
const minSaturationVaporPressure = 0.6108 * Math.exp( 17.27 * minTemp / ( minTemp + 237.3 ) );
const maxSaturationVaporPressure = 0.6108 * Math.exp( 17.27 * maxTemp / ( maxTemp + 237.3 ) );
const avgSaturationVaporPressure = ( minSaturationVaporPressure + maxSaturationVaporPressure ) / 2;
const actualVaporPressure = ( minSaturationVaporPressure * etoData.maxHumidity / 100 + maxSaturationVaporPressure * etoData.minHumidity / 100 ) / 2;
const dayOfYear = moment.unix( etoData.periodStartTime ).dayOfYear();
const inverseRelativeEarthSunDistance = 1 + 0.033 * Math.cos( 2 * Math.PI / 365 * dayOfYear );
const solarDeclination = 0.409 * Math.sin( 2 * Math.PI / 365 * dayOfYear - 1.39 );
const latitudeRads = Math.PI / 180 * coordinates[ 0 ];
const sunsetHourAngle = Math.acos( -Math.tan( latitudeRads ) * Math.tan( solarDeclination ) );
const extraterrestrialRadiation = 24 * 60 / Math.PI * 0.082 * inverseRelativeEarthSunDistance * ( sunsetHourAngle * Math.sin( latitudeRads ) * Math.sin( solarDeclination ) + Math.cos( latitudeRads ) * Math.cos( solarDeclination ) * Math.sin( sunsetHourAngle ) );
const clearSkyRadiation = ( 0.75 + 2e-5 * elevation ) * extraterrestrialRadiation;
const netShortWaveRadiation = ( 1 - 0.23 ) * solarRadiation;
const netOutgoingLongWaveRadiation = 4.903e-9 * ( Math.pow( maxTemp + 273.16, 4 ) + Math.pow( minTemp + 273.16, 4 ) ) / 2 * ( 0.34 - 0.14 * Math.sqrt( actualVaporPressure ) ) * ( 1.35 * solarRadiation / clearSkyRadiation - 0.35);
const netRadiation = netShortWaveRadiation - netOutgoingLongWaveRadiation;
const radiationTerm = deltaTerm * 0.408 * netRadiation;
const windTerm = psiTerm * tempTerm * ( avgSaturationVaporPressure - actualVaporPressure );
return ( windTerm + radiationTerm ) / 25.4;
}
/**
* Approximates the wind speed at 2 meters using the wind speed measured at another height.
* @param speed The wind speed measured at the specified height (in miles per hour).
* @param height The height of the measurement (in feet).
* @returns The approximate wind speed at 2 meters (in miles per hour).
*/
export function standardizeWindSpeed( speed: number, height: number ) {
return speed * 4.87 / Math.log( 67.8 * height / 3.281 - 5.42 );
}
/* For hours where the Sun is too low to emit significant radiation, the formula for clear sky isolation will yield a
* negative value. "radiationStart" marks the times of day when the Sun will rise high for solar isolation formula to
* become positive, and "radiationEnd" marks the time of day when the Sun sets low enough that the equation will yield
* a negative result. For any times outside of these ranges, the formula will yield incorrect results (they should be
* clamped at 0 instead of being negative).
*/
SunCalc.addTime( Math.asin( 30 / 990 ) * 180 / Math.PI, "radiationStart", "radiationEnd" );
/**
* Approximates total solar radiation for a day given cloud coverage information using a formula from
* http://www.shodor.org/os411/courses/_master/tools/calculators/solarrad/
* @param cloudCoverInfo Information about the cloud coverage for several periods that span the entire day.
* @param coordinates The coordinates of the location the data is from.
* @return The total solar radiation for the day (in kilowatt hours per square meter per day).
*/
export function approximateSolarRadiation(cloudCoverInfo: CloudCoverInfo[], coordinates: GeoCoordinates ): number {
return cloudCoverInfo.reduce( ( total, window: CloudCoverInfo ) => {
const radiationStart: moment.Moment = moment( SunCalc.getTimes( window.endTime.toDate(), coordinates[ 0 ], coordinates[ 1 ])[ "radiationStart" ] );
const radiationEnd: moment.Moment = moment( SunCalc.getTimes( window.startTime.toDate(), coordinates[ 0 ], coordinates[ 1 ])[ "radiationEnd" ] );
// Clamp the start and end times of the window within time when the sun was emitting significant radiation.
const startTime: moment.Moment = radiationStart.isAfter( window.startTime ) ? radiationStart : window.startTime;
const endTime: moment.Moment = radiationEnd.isBefore( window.endTime ) ? radiationEnd: window.endTime;
// The length of the window that will actually be used (in hours).
const windowLength = ( endTime.unix() - startTime.unix() ) / 60 / 60;
// Skip the window if there is no significant radiation during the time period.
if ( windowLength <= 0 ) {
return total;
}
const startPosition = SunCalc.getPosition( startTime.toDate(), coordinates[ 0 ], coordinates[ 1 ] );
const endPosition = SunCalc.getPosition( endTime.toDate(), coordinates[ 0 ], coordinates[ 1 ] );
const solarElevationAngle = ( startPosition.altitude + endPosition.altitude ) / 2;
// Calculate radiation and convert from watts to kilowatts.
const clearSkyIsolation = ( 990 * Math.sin( solarElevationAngle ) - 30 ) / 1000 * windowLength;
return total + clearSkyIsolation * ( 1 - 0.75 * Math.pow( window.cloudCover, 3.4 ) );
}, 0 );
}
export interface EToScalingAdjustmentOptions extends AdjustmentOptions {
/** The watering site's height above sea level (in feet). */
elevation?: number;
/** Baseline potential ETo (in inches per day). */
baseETo?: number;
}
/** Data about the cloud coverage for a period of time. */
export interface CloudCoverInfo {
/** The start of this period of time. */
startTime: moment.Moment;
/** The end of this period of time. */
endTime: moment.Moment;
/** The average fraction of the sky covered by clouds during this time period. */
cloudCover: number;
}
/**
* Data used to calculate ETo. This data should be taken from a 24 hour time window.
*/
export interface EToData extends BaseWateringData {
/** The Unix epoch seconds timestamp of the start of this 24 hour time window. */
periodStartTime: number;
/** The minimum temperature over the time period (in Fahrenheit). */
minTemp: number;
/** The maximum temperature over the time period (in Fahrenheit). */
maxTemp: number;
/** The minimum relative humidity over the time period (as a percentage). */
minHumidity: number;
/** The maximum relative humidity over the time period (as a percentage). */
maxHumidity: number;
/** The solar radiation, accounting for cloud coverage (in kilowatt hours per square meter per day). */
solarRadiation: number;
/**
* The average wind speed measured at 2 meters over the time period (in miles per hour). A measurement taken at a
* different height can be standardized to 2m using the `standardizeWindSpeed` function in EToAdjustmentMethod.
*/
windSpeed: number;
}
const EToAdjustmentMethod: AdjustmentMethod = {
calculateWateringScale: calculateEToWateringScale
};
export default EToAdjustmentMethod;

182
routes/baselineETo.ts Normal file
View File

@@ -0,0 +1,182 @@
/* This script requires the file Baseline_ETo_Data.bin file to be created in the baselineEToData directory. More
* information about this is available in /baselineEToData/README.md.
*/
import * as express from "express";
import * as fs from "fs";
import { GeoCoordinates } from "../types";
import { getParameter, resolveCoordinates } from "./weather";
const DATA_FILE = __dirname + "/../../baselineEToData/Baseline_ETo_Data.bin";
let FILE_META: FileMeta;
readFileHeader().then( ( fileMeta ) => {
FILE_META = fileMeta;
console.log( "Loaded baseline ETo data." );
} ).catch( ( err ) => {
console.error( "An error occurred while reading the annual ETo data file header. Baseline ETo endpoint will be unavailable.", err );
} );
export const getBaselineETo = async function( req: express.Request, res: express.Response ) {
const location: string = getParameter( req.query.loc );
// Error if the file meta was not read (either the file is still being read or an error occurred and it could not be read).
if ( !FILE_META ) {
res.status( 503 ).send( "Baseline ETo calculation is currently unavailable." );
return;
}
// Attempt to resolve provided location to GPS coordinates.
let coordinates: GeoCoordinates;
try {
coordinates = await resolveCoordinates( location );
} catch (err) {
res.status( 404 ).send( `Error: Unable to resolve coordinates for location (${ err })` );
return;
}
let eto: number;
try {
eto = await calculateAverageDailyETo( coordinates );
} catch ( err ) {
/* Use a 500 error code if a more appropriate error code is not specified, and prefer the error message over the
full error object if a message is defined. */
res.status( err.code || 500 ).send( err.message || err );
return;
}
res.status( 200 ).json( {
eto: Math.round( eto * 1000 ) / 1000
} );
};
/**
* Retrieves the average daily potential ETo for the specified location.
* @param coordinates The location to retrieve the ETo for.
* @return A Promise that will be resolved with the average potential ETo (in inches per day), or rejected with an error
* (which may include a message and the appropriate HTTP status code to send the user) if the ETo cannot be retrieved.
*/
async function calculateAverageDailyETo( coordinates: GeoCoordinates ): Promise< number > {
// Convert geographic coordinates into image coordinates.
const x = Math.floor( FILE_META.origin.x + FILE_META.width * coordinates[ 1 ] / 360 );
// Account for the 30+10 cropped degrees.
const y = Math.floor( FILE_META.origin.y - FILE_META.height * coordinates[ 0 ] / ( 180 - 30 - 10 ) );
// The offset (from the start of the data block) of the relevant pixel.
const offset = y * FILE_META.width + x;
/* Check if the specified coordinates were invalid or correspond to a part of the map that was cropped. */
if ( offset < 0 || offset > FILE_META.width * FILE_META.height ) {
throw { message: "Specified location is out of bounds.", code: 404 };
}
let byte: number;
try {
// Skip the 32 byte header.
byte = await getByteAtOffset( offset + 32 );
} catch ( err ) {
console.error( `An error occurred while reading the baseline ETo data file for coordinates ${ coordinates }:`, err );
throw { message: "An unexpected error occurred while retrieving the baseline ETo for this location.", code: 500 }
}
// The maximum value indicates that no data is available for this point.
if ( ( byte === ( 1 << FILE_META.bitDepth ) - 1 ) ) {
throw { message: "ETo data is not available for this location.", code: 404 };
}
return ( byte * FILE_META.scalingFactor + FILE_META.minimumETo ) / 365;
}
/**
* Returns the byte at the specified offset in the baseline ETo data file.
* @param offset The offset from the start of the file (the start of the header, not the start of the data block).
* @return A Promise that will be resolved with the unsigned representation of the byte at the specified offset, or
* rejected with an Error if an error occurs.
*/
function getByteAtOffset( offset: number ): Promise< number > {
return new Promise( ( resolve, reject ) => {
const stream = fs.createReadStream( DATA_FILE, { start: offset, end: offset } );
stream.on( "error", ( err ) => {
reject( err );
} );
// There's no need to wait for the "end" event since the "data" event will contain the single byte being read.
stream.on( "data", ( data ) => {
resolve( data[ 0 ] );
} );
} );
}
/**
* Parses information from the baseline ETo data file from the file header. The header format is documented in the README.
* @return A Promise that will be resolved with the parsed header information, or rejected with an error if the header
* is invalid or cannot be read.
*/
function readFileHeader(): Promise< FileMeta > {
return new Promise( ( resolve, reject) => {
const stream = fs.createReadStream( DATA_FILE, { start: 0, end: 32 } );
const headerArray: number[] = [];
stream.on( "error", ( err ) => {
reject( err );
} );
stream.on( "data", ( data: number[] ) => {
headerArray.push( ...data );
} );
stream.on( "end", () => {
const buffer = Buffer.from( headerArray );
const version = buffer.readUInt8( 0 );
if ( version !== 1 ) {
reject( `Unsupported data file version ${ version }. The maximum supported version is 1.` );
return;
}
const width = buffer.readUInt32BE( 1 );
const height = buffer.readUInt32BE( 5 );
const fileMeta: FileMeta = {
version: version,
width: width,
height: height,
bitDepth: buffer.readUInt8( 9 ),
minimumETo: buffer.readFloatBE( 10 ),
scalingFactor: buffer.readFloatBE( 14 ),
origin: {
x: Math.floor( width / 2 ),
// Account for the 30+10 cropped degrees.
y: Math.floor( height / ( 180 - 10 - 30) * ( 90 - 10 ) )
}
};
if ( fileMeta.bitDepth === 8 ) {
resolve( fileMeta );
} else {
reject( "Bit depths other than 8 are not currently supported." );
}
} );
} );
}
/** Information about the data file parsed from the file header. */
interface FileMeta {
version: number;
/** The width of the image (in pixels). */
width: number;
/** The height of the image (in pixels). */
height: number;
/** The number of bits used for each pixel. */
bitDepth: number;
/** The ETo that a pixel value of 0 represents (in inches/year). */
minimumETo: number;
/** The ratio of an increase in pixel value to an increase in ETo (in inches/year). */
scalingFactor: number;
/**
* The pixel coordinates of the geographic coordinates origin. These coordinates are off-center because the original
* image excludes the northernmost 10 degrees and the southernmost 30 degrees.
*/
origin: {
x: number;
y: number;
};
}

View File

@@ -9,8 +9,9 @@ process.env.WEATHER_PROVIDER = "OWM";
process.env.OWM_API_KEY = "NO_KEY";
import { getWateringData } from './weather';
import { GeoCoordinates, ZimmermanWateringData, WeatherData } from "../types";
import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../types";
import { WeatherProvider } from "./weatherProviders/WeatherProvider";
import { EToData } from "./adjustmentMethods/EToAdjustmentMethod";
const expected = require( '../test/expected.json' );
const replies = require( '../test/replies.json' );
@@ -82,16 +83,19 @@ export class MockWeatherProvider extends WeatherProvider {
}
public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > {
const data = this.mockData.wateringData;
if ( !data.weatherProvider ) {
data.weatherProvider = "mock";
}
return data;
return await this.getData( "wateringData" ) as ZimmermanWateringData;
}
public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
const data = this.mockData.weatherData;
return await this.getData( "weatherData" ) as WeatherData;
}
public async getEToData( coordinates: GeoCoordinates ): Promise< EToData > {
return await this.getData( "etoData" ) as EToData;
}
private async getData( type: "wateringData" | "weatherData" | "etoData" ) {
const data = this.mockData[ type ];
if ( !data.weatherProvider ) {
data.weatherProvider = "mock";
}
@@ -102,5 +106,6 @@ export class MockWeatherProvider extends WeatherProvider {
interface MockWeatherData {
wateringData?: ZimmermanWateringData,
weatherData?: WeatherData
weatherData?: WeatherData,
etoData?: EToData
}

View File

@@ -8,10 +8,11 @@ import * as geoTZ from "geo-tz";
import { BaseWateringData, GeoCoordinates, PWS, TimeData, WeatherData } from "../types";
import { WeatherProvider } from "./weatherProviders/WeatherProvider";
import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./adjustmentMethods/AdjustmentMethod";
import WateringScaleCache, { CachedScale } from "../WateringScaleCache";
import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod";
import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod";
import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod";
import WateringScaleCache, { CachedScale } from "../WateringScaleCache";
import EToAdjustmentMethod from "./adjustmentMethods/EToAdjustmentMethod";
const WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )();
const PWS_WEATHER_PROVIDER: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.PWS_WEATHER_PROVIDER || "WUnderground" ) ).default )();
@@ -28,7 +29,8 @@ const filters = {
const ADJUSTMENT_METHOD: { [ key: number ] : AdjustmentMethod } = {
0: ManualAdjustmentMethod,
1: ZimmermanAdjustmentMethod,
2: RainDelayAdjustmentMethod
2: RainDelayAdjustmentMethod,
3: EToAdjustmentMethod
};
const cache = new WateringScaleCache();
@@ -39,7 +41,7 @@ const cache = new WateringScaleCache();
* @return A promise that will be resolved with the coordinates of the best match for the specified location, or
* rejected with an error message if unable to resolve the location.
*/
async function resolveCoordinates( location: string ): Promise< GeoCoordinates > {
export async function resolveCoordinates( location: string ): Promise< GeoCoordinates > {
if ( !location ) {
throw "No location specified";
@@ -191,6 +193,11 @@ export const getWateringData = async function( req: express.Request, res: expres
// the string is split against a comma and the first value is selected
remoteAddress = remoteAddress.split( "," )[ 0 ];
if ( !adjustmentMethod ) {
res.send( "Error: Unknown AdjustmentMethod ID" );
return;
}
// Parse weather adjustment options
try {
@@ -476,7 +483,7 @@ function ipToInt( ip: string ): number {
* @param parameter An array of parameters or a single parameter value.
* @return The first element in the array of parameter or the single parameter provided.
*/
function getParameter( parameter: string | string[] ): string {
export function getParameter( parameter: string | string[] ): string {
if ( Array.isArray( parameter ) ) {
parameter = parameter[0];
}

View File

@@ -3,6 +3,7 @@ import * as moment from "moment-timezone";
import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types";
import { httpJSONRequest } from "../weather";
import { WeatherProvider } from "./WeatherProvider";
import { approximateSolarRadiation, CloudCoverInfo, EToData } from "../adjustmentMethods/EToAdjustmentMethod";
export default class DarkSkyWeatherProvider extends WeatherProvider {
@@ -112,6 +113,54 @@ export default class DarkSkyWeatherProvider extends WeatherProvider {
return weather;
}
public async getEToData( coordinates: GeoCoordinates ): Promise< EToData > {
// The Unix epoch seconds timestamp of 24 hours ago.
const timestamp: number = moment().subtract( 1, "day" ).unix();
const DARKSKY_API_KEY = process.env.DARKSKY_API_KEY,
historicUrl = `https://api.darksky.net/forecast/${DARKSKY_API_KEY}/${coordinates[0]},${coordinates[1]},${timestamp}`;
let historicData;
try {
historicData = await httpJSONRequest( historicUrl );
} catch (err) {
throw "An error occurred while retrieving weather information from Dark Sky."
}
const cloudCoverInfo: CloudCoverInfo[] = historicData.hourly.data.map( ( hour ): CloudCoverInfo => {
return {
startTime: moment.unix( hour.time ),
endTime: moment.unix( hour.time ).add( 1, "hours" ),
cloudCover: hour.cloudCover
};
} );
let minHumidity: number = undefined, maxHumidity: number = undefined;
for ( const hour of historicData.hourly.data ) {
// Skip hours where humidity measurement does not exist to prevent result from being NaN.
if ( hour.humidity === undefined ) {
continue;
}
// If minHumidity or maxHumidity is undefined, these comparisons will yield false.
minHumidity = minHumidity < hour.humidity ? minHumidity : hour.humidity;
maxHumidity = maxHumidity > hour.humidity ? maxHumidity : hour.humidity;
}
return {
weatherProvider: "DarkSky",
periodStartTime: historicData.hourly.data[ 0 ].time,
minTemp: historicData.daily.data[ 0 ].temperatureMin,
maxTemp: historicData.daily.data[ 0 ].temperatureMax,
minHumidity: minHumidity * 100,
maxHumidity: maxHumidity * 100,
solarRadiation: approximateSolarRadiation( cloudCoverInfo, coordinates ),
// Assume wind speed measurements are taken at 2 meters.
windSpeed: historicData.daily.data[ 0 ].windSpeed,
precip: ( historicData.daily.data[ 0 ].precipIntensity || 0 ) * 24
};
}
public shouldCacheWateringScale(): boolean {
return true;
}

View File

@@ -1,6 +1,8 @@
import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types";
import { httpJSONRequest } from "../weather";
import { WeatherProvider } from "./WeatherProvider";
import { approximateSolarRadiation, CloudCoverInfo, EToData } from "../adjustmentMethods/EToAdjustmentMethod";
import * as moment from "moment";
export default class OWMWeatherProvider extends WeatherProvider {
@@ -97,4 +99,68 @@ export default class OWMWeatherProvider extends WeatherProvider {
return weather;
}
// Uses a rolling window since forecast data from further in the future (i.e. the next full day) would be less accurate.
async getEToData( coordinates: GeoCoordinates ): Promise< EToData > {
const OWM_API_KEY = process.env.OWM_API_KEY,
forecastUrl = "http://api.openweathermap.org/data/2.5/forecast?appid=" + OWM_API_KEY + "&units=imperial&lat=" + coordinates[ 0 ] + "&lon=" + coordinates[ 1 ];
// Perform the HTTP request to retrieve the weather data
let forecast;
try {
forecast = await httpJSONRequest( forecastUrl );
} catch (err) {
console.error( "Error retrieving ETo information from OWM:", err );
throw "An error occurred while retrieving ETo information from OWM."
}
// Indicate ETo data could not be retrieved if the forecast data is incomplete.
if ( !forecast || !forecast.list || forecast.list.length < 8 ) {
throw "Insufficient data available from OWM."
}
// Take a sample over 24 hours.
const samples = forecast.list.slice( 0, 8 );
const cloudCoverInfo: CloudCoverInfo[] = samples.map( ( window ): CloudCoverInfo => {
return {
startTime: moment.unix( window.dt ),
endTime: moment.unix( window.dt ).add( 3, "hours" ),
cloudCover: window.clouds.all / 100
};
} );
let minTemp: number = undefined, maxTemp: number = undefined;
let minHumidity: number = undefined, maxHumidity: number = undefined;
// Skip hours where measurements don't exist to prevent result from being NaN.
for ( const sample of samples ) {
const temp: number = sample.main.temp;
if ( temp !== undefined ) {
// If minTemp or maxTemp is undefined, these comparisons will yield false.
minTemp = minTemp < temp ? minTemp : temp;
maxTemp = maxTemp > temp ? maxTemp : temp;
}
const humidity: number = sample.main.humidity;
if ( humidity !== undefined ) {
// If minHumidity or maxHumidity is undefined, these comparisons will yield false.
minHumidity = minHumidity < humidity ? minHumidity : humidity;
maxHumidity = maxHumidity > humidity ? maxHumidity : humidity;
}
}
return {
weatherProvider: "OWM",
periodStartTime: samples[ 0 ].dt,
minTemp: minTemp,
maxTemp: maxTemp,
minHumidity: minHumidity,
maxHumidity: maxHumidity,
solarRadiation: approximateSolarRadiation( cloudCoverInfo, coordinates ),
// Assume wind speed measurements are taken at 2 meters.
windSpeed: samples.reduce( ( sum, window ) => sum + ( window.wind.speed || 0 ), 0) / samples.length,
// OWM always returns precip in mm, so it must be converted.
precip: samples.reduce( ( sum, window ) => sum + ( window.rain ? window.rain[ "3h" ] || 0 : 0 ), 0) / 25.4
};
}
}

View File

@@ -1,4 +1,5 @@
import { GeoCoordinates, PWS, WeatherData, ZimmermanWateringData } from "../../types";
import { EToData } from "../adjustmentMethods/EToAdjustmentMethod";
export class WeatherProvider {
/**
@@ -25,6 +26,17 @@ export class WeatherProvider {
throw "Selected WeatherProvider does not support getWeatherData";
}
/**
* Retrieves the data necessary for calculating potential ETo.
* @param coordinates The coordinates to retrieve the data for.
* @return A Promise that will be resolved with the EToData if it is successfully retrieved,
* or rejected with an error message if an error occurs while retrieving the EToData or the WeatherProvider does
* not support this method.
*/
getEToData( coordinates: GeoCoordinates ): Promise< EToData > {
throw "Selected WeatherProvider does not support getEToData";
};
/**
* Returns a boolean indicating if watering scales calculated using data from this WeatherProvider should be cached
* until the end of the day in timezone the data was for.