183 lines
6.5 KiB
TypeScript
183 lines
6.5 KiB
TypeScript
/* 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;
|
|
};
|
|
}
|