Refactor adjustment method selection

This commit is contained in:
Matthew Oslan
2019-06-09 11:25:21 -04:00
parent b6776f0e90
commit 0281a835e2
6 changed files with 233 additions and 117 deletions

View File

@@ -0,0 +1,58 @@
import { GeoCoordinates, WateringData } from "../../types";
import { WeatherProvider } from "../weatherProviders/WeatherProvider";
export interface AdjustmentMethod {
/**
* Calculates the percentage that should be used to scale watering time.
* @param adjustmentOptions The user-specified options for the calculation, or undefined/null if no custom values
* are to be used. No checks will be made to ensure the AdjustmentOptions are the correct type that the function
* is expecting or to ensure that any of its fields are valid.
* @param wateringData The basic weather information of the watering site. This may be undefined if an error occurred
* while retrieving the data.
* @param coordinates The coordinates of the watering site.
* @param weatherProvider The WeatherProvider that should be used if the adjustment method needs to obtain any more
* weather data.
* @return A Promise that will be resolved with the result of the calculation, or rejected with an error message if
* the watering scale cannot be calculated.
* @throws An error message can be thrown if an error occurs while calculating the watering scale.
*/
calculateWateringScale(
adjustmentOptions: AdjustmentOptions,
wateringData: WateringData | undefined,
coordinates: GeoCoordinates,
weatherProvider: WeatherProvider
): Promise< AdjustmentMethodResponse >;
}
export interface AdjustmentMethodResponse {
/**
* The percentage that should be used to scale the watering level. This should be an integer between 0-200 (inclusive),
* or undefined if the watering level should not be changed.
*/
scale: number | undefined;
/**
* The raw data that was used to calculate the watering scale. This will be sent directly to the OS controller, so
* each field should be formatted in a way that the controller understands and numbers should be rounded
* appropriately to remove excessive figures. If no data was used (e.g. an error occurred), this should be undefined.
*/
rawData?: object;
/**
* How long watering should be delayed for (in hours) due to rain, or undefined if watering should not be delayed
* for a specific amount of time (either it should be delayed indefinitely or it should not be delayed at all). This
* property will not stop watering on its own, and the `scale` property should be set to 0 to actually prevent
* watering.
*/
rainDelay?: number;
// TODO consider removing this field and breaking backwards compatibility to handle all errors consistently.
/**
* An message to send to the OS firmware to indicate that an error occurred while calculating the watering
* scale and the returned scale either defaulted to some reasonable value or was calculated with incomplete data.
* Older firmware versions will ignore this field (they will silently swallow the error and use the returned scale),
* but newer firmware versions may be able to alert the user that an error occurred and/or default to a
* user-configured watering scale instead of using the one returned by the AdjustmentMethod.
*/
errorMessage?: string;
}
export interface AdjustmentOptions {}

View File

@@ -0,0 +1,17 @@
import { AdjustmentMethod, AdjustmentMethodResponse } from "./AdjustmentMethod";
/**
* Does not change the watering scale (only time data will be returned).
*/
async function calculateManualWateringScale( ): Promise< AdjustmentMethodResponse > {
return {
scale: undefined
}
}
const ManualAdjustmentMethod: AdjustmentMethod = {
calculateWateringScale: calculateManualWateringScale
};
export default ManualAdjustmentMethod;

View File

@@ -0,0 +1,27 @@
import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod";
import { WateringData } from "../../types";
/**
* Only delays watering if it is currently raining and does not adjust the watering scale.
*/
async function calculateRainDelayWateringScale( adjustmentOptions: RainDelayAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > {
const raining = wateringData && wateringData.raining;
const d = adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ? adjustmentOptions.d : 24;
return {
scale: undefined,
rawData: { raining: raining ? 1 : 0 },
rainDelay: raining ? d : undefined
}
}
export interface RainDelayAdjustmentOptions extends AdjustmentOptions {
/** The rain delay to use (in hours). */
d?: number;
}
const RainDelayAdjustmentMethod: AdjustmentMethod = {
calculateWateringScale: calculateRainDelayWateringScale
};
export default RainDelayAdjustmentMethod;

View File

@@ -0,0 +1,94 @@
import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod";
import { WateringData } from "../../types";
import { validateValues } from "../weather";
/**
* Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method.
* (https://github.com/rszimm/sprinklers_pi/wiki/Weather-adjustments#formula-for-setting-the-scale)
*/
async function calculateZimmermanWateringScale( adjustmentOptions: ZimmermanAdjustmentOptions, wateringData: WateringData | undefined ): Promise< AdjustmentMethodResponse > {
// Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future.
/*
// Don't water if it is currently raining.
if ( wateringData && wateringData.raining ) {
return {
scale: 0,
rawData: { raining: 1 }
}
}
*/
const rawData = {
h: wateringData ? Math.round( wateringData.humidity * 100) / 100 : null,
p: wateringData ? Math.round( wateringData.precip * 100 ) / 100 : null,
t: wateringData ? Math.round( wateringData.temp * 10 ) / 10 : null,
raining: wateringData ? ( wateringData.raining ? 1 : 0 ) : null
};
// Check to make sure valid data exists for all factors
if ( !validateValues( [ "temp", "humidity", "precip" ], wateringData ) ) {
// Default to a scale of 100% if fields are missing.
return {
scale: 100,
rawData: rawData,
errorMessage: "Necessary field(s) were missing from WateringData."
};
}
let humidityBase = 30, tempBase = 70, precipBase = 0;
// Get baseline conditions for 100% water level, if provided
if ( adjustmentOptions ) {
humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase;
tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase;
precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase;
}
let humidityFactor = ( humidityBase - wateringData.humidity ),
tempFactor = ( ( wateringData.temp - tempBase ) * 4 ),
precipFactor = ( ( precipBase - wateringData.precip ) * 200 );
// Apply adjustment options, if provided, by multiplying the percentage against the factor
if ( adjustmentOptions ) {
if ( adjustmentOptions.hasOwnProperty( "h" ) ) {
humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 );
}
if ( adjustmentOptions.hasOwnProperty( "t" ) ) {
tempFactor = tempFactor * ( adjustmentOptions.t / 100 );
}
if ( adjustmentOptions.hasOwnProperty( "r" ) ) {
precipFactor = precipFactor * ( adjustmentOptions.r / 100 );
}
}
return {
// Apply all of the weather modifying factors and clamp the result between 0 and 200%.
scale: Math.floor( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) ),
rawData: rawData
}
}
export interface ZimmermanAdjustmentOptions extends AdjustmentOptions {
/** Base humidity (as a percentage). */
bh?: number;
/** Base temperature (in Fahrenheit). */
bt?: number;
/** Base precipitation (in inches). */
br?: number;
/** The percentage to weight the humidity factor by. */
h?: number;
/** The percentage to weight the temperature factor by. */
t?: number;
/** The percentage to weight the precipitation factor by. */
r?: number;
}
const ZimmermanAdjustmentMethod: AdjustmentMethod = {
calculateWateringScale: calculateZimmermanWateringScale
};
export default ZimmermanAdjustmentMethod;

View File

@@ -5,8 +5,12 @@ import * as SunCalc from "suncalc";
import * as moment from "moment-timezone";
import * as geoTZ from "geo-tz";
import { AdjustmentOptions, GeoCoordinates, TimeData, WateringData, WeatherData } from "../types";
import { GeoCoordinates, TimeData, WateringData, WeatherData } from "../types";
import { WeatherProvider } from "./weatherProviders/WeatherProvider";
import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./adjustmentMethods/AdjustmentMethod";
import ManualAdjustmentMethod from "./adjustmentMethods/ManualAdjustmentMethod";
import ZimmermanAdjustmentMethod from "./adjustmentMethods/ZimmermanAdjustmentMethod";
import RainDelayAdjustmentMethod from "./adjustmentMethods/RainDelayAdjustmentMethod";
const weatherProvider: WeatherProvider = new ( require("./weatherProviders/" + ( process.env.WEATHER_PROVIDER || "OWM" ) ).default )();
// Define regex filters to match against location
@@ -18,11 +22,11 @@ const filters = {
timezone: /^()()()()()()([+-])(\d{2})(\d{2})/
};
// Enum of available watering scale adjustment methods.
const ADJUSTMENT_METHOD = {
MANUAL: 0,
ZIMMERMAN: 1,
RAIN_DELAY: 2
/** AdjustmentMethods mapped to their numeric IDs. */
const ADJUSTMENT_METHOD: { [ key: number ] : AdjustmentMethod } = {
0: ManualAdjustmentMethod,
1: ZimmermanAdjustmentMethod,
2: RainDelayAdjustmentMethod
};
/**
@@ -107,52 +111,6 @@ function getTimeData( coordinates: GeoCoordinates ): TimeData {
};
}
/**
* Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method.
* @param adjustmentOptions Options to tweak the calculation, or undefined/null if no custom values are to be used.
* @param wateringData The weather to use to calculate watering percentage.
* @return The percentage that watering should be scaled by.
* @throws An error message will be thrown if the watering scale cannot be calculated.
*/
function calculateZimmermanWateringScale( adjustmentOptions: AdjustmentOptions, wateringData: WateringData ): number {
let humidityBase = 30, tempBase = 70, precipBase = 0;
// Check to make sure valid data exists for all factors
if ( !validateValues( [ "temp", "humidity", "precip" ], wateringData ) ) {
throw "Necessary field(s) were missing from WateringData.";
}
// Get baseline conditions for 100% water level, if provided
if ( adjustmentOptions ) {
humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase;
tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase;
precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase;
}
let humidityFactor = ( humidityBase - wateringData.humidity ),
tempFactor = ( ( wateringData.temp - tempBase ) * 4 ),
precipFactor = ( ( precipBase - wateringData.precip ) * 200 );
// Apply adjustment options, if provided, by multiplying the percentage against the factor
if ( adjustmentOptions ) {
if ( adjustmentOptions.hasOwnProperty( "h" ) ) {
humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 );
}
if ( adjustmentOptions.hasOwnProperty( "t" ) ) {
tempFactor = tempFactor * ( adjustmentOptions.t / 100 );
}
if ( adjustmentOptions.hasOwnProperty( "r" ) ) {
precipFactor = precipFactor * ( adjustmentOptions.r / 100 );
}
}
// Apply all of the weather modifying factors and clamp the result between 0 and 200%.
return Math.floor( Math.min( Math.max( 0, 100 + humidityFactor + tempFactor + precipFactor ), 200 ) );
}
/**
* Checks if the weather data meets any of the restrictions set by OpenSprinkler. Restrictions prevent any watering
* from occurring and are similar to 0% watering level. Known restrictions are:
@@ -217,7 +175,7 @@ export const getWateringData = async function( req: express.Request, res: expres
// The adjustment method is encoded by the OpenSprinkler firmware and must be
// parsed. This allows the adjustment method and the restriction type to both
// be saved in the same byte.
let adjustmentMethod: number = req.params[ 0 ] & ~( 1 << 7 ),
let adjustmentMethod: AdjustmentMethod = ADJUSTMENT_METHOD[ req.params[ 0 ] & ~( 1 << 7 ) ],
checkRestrictions: boolean = ( ( req.params[ 0 ] >> 7 ) & 1 ) > 0,
adjustmentOptionsString: string = getParameter(req.query.wto),
location: string | GeoCoordinates = getParameter(req.query.loc),
@@ -225,13 +183,6 @@ export const getWateringData = async function( req: express.Request, res: expres
remoteAddress: string = getParameter(req.headers[ "x-forwarded-for" ]) || req.connection.remoteAddress,
adjustmentOptions: AdjustmentOptions;
/* A message to include in the response to indicate that the watering scale was not calculated correctly and
defaulted to 100%. This approach is used for backwards compatibility because older OS firmware versions were
hardcoded to keep the previous watering scale if the response did not include a watering scale, but newer versions
might allow for different behaviors. */
let errorMessage: string = undefined;
// X-Forwarded-For header may contain more than one IP address and therefore
// the string is split against a comma and the first value is selected
remoteAddress = remoteAddress.split( "," )[ 0 ];
@@ -263,7 +214,7 @@ export const getWateringData = async function( req: express.Request, res: expres
// Continue with the weather request
let timeData: TimeData = getTimeData( coordinates );
let wateringData: WateringData;
if ( adjustmentMethod !== ADJUSTMENT_METHOD.MANUAL || checkRestrictions ) {
if ( adjustmentMethod !== ManualAdjustmentMethod || checkRestrictions ) {
try {
wateringData = await weatherProvider.getWateringData( coordinates );
} catch ( err ) {
@@ -272,59 +223,45 @@ export const getWateringData = async function( req: express.Request, res: expres
}
}
let scale = -1, rainDelay = -1;
if ( adjustmentMethod === ADJUSTMENT_METHOD.ZIMMERMAN ) {
try {
scale = calculateZimmermanWateringScale( adjustmentOptions, wateringData );
} catch ( err ) {
// Default to a scale of 100% if the scale can't be calculated.
scale = 100;
errorMessage = err;
let adjustmentMethodResponse: AdjustmentMethodResponse;
try {
adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale(
adjustmentOptions, wateringData, coordinates, weatherProvider
);
} catch ( err ) {
if ( typeof err != "string" ) {
/* If an error occurs under expected circumstances (e.g. required optional fields from a weather API are
missing), an AdjustmentOption must throw a string. If a non-string error is caught, it is likely an Error
thrown by the JS engine due to unexpected circumstances. The user should not be shown the error message
since it may contain sensitive information. */
res.send( "Error: an unexpected error occurred." );
console.error( `An unexpected error occurred for ${ req.url }: `, err );
} else {
res.send( "Error: " + err );
}
return;
}
if (wateringData) {
let scale = adjustmentMethodResponse.scale;
if ( wateringData ) {
// Check for any user-set restrictions and change the scale to 0 if the criteria is met
if (checkWeatherRestriction(req.params[0], wateringData)) {
if ( checkWeatherRestriction( req.params[ 0 ], wateringData ) ) {
scale = 0;
}
// If any weather adjustment is being used, check the rain status
if ( adjustmentMethod > ADJUSTMENT_METHOD.MANUAL && wateringData.raining ) {
// If it is raining and the user has weather-based rain delay as the adjustment method then apply the specified delay
if ( adjustmentMethod === ADJUSTMENT_METHOD.RAIN_DELAY ) {
rainDelay = ( adjustmentOptions && adjustmentOptions.hasOwnProperty( "d" ) ) ? adjustmentOptions.d : 24;
} else {
// Temporarily disabled since OWM forecast data is checking if rain is forecasted for 3 hours in the future.
// For any other adjustment method, apply a scale of 0 (as the scale will revert when the rain stops)
// scale = 0;
}
}
}
const data = {
scale: scale,
rd: rainDelay,
rd: adjustmentMethodResponse.rainDelay,
tz: getTimezone( timeData.timezone, undefined ),
sunrise: timeData.sunrise,
sunset: timeData.sunset,
eip: ipToInt( remoteAddress ),
rawData: undefined,
error: errorMessage
rawData: adjustmentMethodResponse.rawData,
error: adjustmentMethodResponse.errorMessage
};
if ( adjustmentMethod > ADJUSTMENT_METHOD.MANUAL ) {
data.rawData = {
h: wateringData ? Math.round( wateringData.humidity * 100) / 100 : null,
p: wateringData ? Math.round( wateringData.precip * 100 ) / 100 : null,
t: wateringData ? Math.round( wateringData.temp * 10 ) / 10 : null,
raining: wateringData ? ( wateringData.raining ? 1 : 0 ) : null
};
}
// Return the response to the client in the requested format
if ( outputFormat === "json" ) {
res.json( data );
@@ -336,7 +273,7 @@ export const getWateringData = async function( req: express.Request, res: expres
"&sunset=" + data.sunset +
"&eip=" + data.eip +
( data.rawData ? "&rawData=" + JSON.stringify( data.rawData ) : "" ) +
( errorMessage ? "&error=" + encodeURIComponent( errorMessage ) : "" )
( data.error ? "&error=" + encodeURIComponent( data.error ) : "" )
);
}
@@ -392,7 +329,7 @@ async function httpRequest( url: string ): Promise< string > {
* @param obj The object to check.
* @return A boolean indicating if the object has numeric values for all of the specified keys.
*/
function validateValues( keys: string[], obj: object ): boolean {
export function validateValues( keys: string[], obj: object ): boolean {
let key: string;
// Return false if the object is null/undefined.

View File

@@ -68,21 +68,4 @@ export interface WateringData {
raining: boolean;
}
export interface AdjustmentOptions {
/** Base humidity (as a percentage). */
bh?: number;
/** Base temperature (in Fahrenheit). */
bt?: number;
/** Base precipitation (in inches). */
br?: number;
/** The percentage to weight the humidity factor by. */
h?: number;
/** The percentage to weight the temperature factor by. */
t?: number;
/** The percentage to weight the precipitation factor by. */
r?: number;
/** The rain delay to use (in hours). */
d?: number;
}
export type WeatherProviderId = "OWM" | "DarkSky" | "local";