Add baseline ETo endpoint
This commit is contained in:
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;
|
||||
};
|
||||
}
|
||||
@@ -37,7 +37,7 @@ const ADJUSTMENT_METHOD: { [ key: number ] : AdjustmentMethod } = {
|
||||
* @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";
|
||||
@@ -428,7 +428,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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user