43
routes/adjustmentMethods/EToAdjustmentMethod.spec.ts
Normal file
43
routes/adjustmentMethods/EToAdjustmentMethod.spec.ts
Normal 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
|
||||
}[];
|
||||
}
|
||||
236
routes/adjustmentMethods/EToAdjustmentMethod.ts
Normal file
236
routes/adjustmentMethods/EToAdjustmentMethod.ts
Normal 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
182
routes/baselineETo.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user