agmission/Development/satloc/satloc-util.js

497 lines
16 KiB
JavaScript

const fs = require('fs'),
fsPromise = fs.promises;
const path = require('path');
const turf = require('@turf/turf');
const debug = require('debug')('agm:satloc');
const DataType = {
LATITUDE: 'Latitude',
LONGITUDE: 'Longitude',
ALTITUDE: 'Altitude',
SPEED: 'Speed',
TRACK: 'Track',
X_TRACK_DEVIATION: 'X-Track Deviation',
DIFFERENTIAL_AGE: 'Differential Age',
FLAGS: 'Flags'
};
/**
* Parse log file from binary buffer
* @param {*} buffer
* @returns { records, numberOfRecords , numByteMisMatch, numUnknkownRecordType, numChecksumMismatch }
*/
function parseLogData(buffer) {
const records = [];
let offset = 0;
let currRecord = 0;
let numByteMisMatch = 0;
let numUnknkownRecordType = 0;
let numChecksumMismatch = 0;
const readAndValidateBytes = (buffer, start, length, expectedLength) => {
const bytes = buffer.slice(start, start + length);
if (bytes.length !== expectedLength) {
numByteMisMatch++;
return null;
}
return bytes;
};
while (offset < buffer.length) {
const startFlag = buffer.readUInt8(offset);
if (startFlag === 0xA5) {
const length = buffer.readUInt8(offset + 1);
const type = buffer.readUInt8(offset + 2);
const checksum = buffer.readUInt8(offset + 3);
const data = buffer.slice(offset + 4, offset + length);
let calculatedChecksum = startFlag ^ length ^ type;
for (let i = 0; i < data.length; i++) {
calculatedChecksum ^= data[i];
}
if (calculatedChecksum !== checksum) {
numChecksumMismatch++;
} else {
if (type == 0x01) {
const timestampBytes = data.slice(0, 5);
const firstByte = timestampBytes[0];
const year = (firstByte >> 4) + 1993;
const month = firstByte & 0x0F;
const secondPart = timestampBytes.readUInt32BE(1);
const day = (secondPart >> 24) & 0x1F;
const hour = (secondPart >> 19) & 0x1F;
const minute = (secondPart >> 13) & 0x3F;
const seconds = (secondPart >> 7) & 0x3F;
const hundredths = secondPart & 0x7F;
const PAD = '0';
const timestamp = `${year}-${String(month).padStart(2, PAD)}-${String(day).padStart(2, PAD)} ` +
`${String(hour).padStart(2, PAD)}:${String(minute).padStart(2, PAD)}:` +
`${String(seconds).padStart(2, PAD)}.${String(hundredths).padStart(2, PAD)}`;
const latitudeBytes = readAndValidateBytes(data, 5, 8, 8, DataType.LATITUDE);
const latitude = latitudeBytes ? latitudeBytes.readDoubleLE(0) : 0;
const longitudeBytes = readAndValidateBytes(data, 13, 8, 8, DataType.LONGITUDE);
const longitude = longitudeBytes ? longitudeBytes.readDoubleLE(0) : 0;
const altitudeBytes = readAndValidateBytes(data, 21, 4, 4, DataType.ALTITUDE);
const altitude = altitudeBytes ? altitudeBytes.readFloatLE(0) : 0;
const speedBytes = readAndValidateBytes(data, 25, 4, 4, DataType.SPEED);
const speed = speedBytes ? speedBytes.readFloatLE(0) : 0;
const trackBytes = readAndValidateBytes(data, 29, 4, 4, DataType.TRACK);
const track = trackBytes ? trackBytes.readFloatLE(0) : 0;
const xTrackDeviationBytes = readAndValidateBytes(data, 33, 4, 4, DataType.X_TRACK_DEVIATION);
const xTrackDeviation = xTrackDeviationBytes ? xTrackDeviationBytes.readFloatLE(0) : 0;
const differentialAgeBytes = readAndValidateBytes(data, 37, 1, 1, DataType.DIFFERENTIAL_AGE);
const differentialAge = differentialAgeBytes ? differentialAgeBytes.readUInt8(0) : 0;
const flagsBytes = readAndValidateBytes(data, 38, 1, 1, DataType.FLAGS);
const flags = flagsBytes ? flagsBytes.readUInt8(0) : 0;
records.push({ number: currRecord, length, type, checksum, timestamp, latitude, longitude, altitude, speed, track, xTrackDeviation, differentialAge, flags });
currRecord++;
} else {
numUnknkownRecordType++;
}
}
}
offset++;
}
return { records, numberOfRecords: currRecord, numByteMisMatch, numUnknkownRecordType, numChecksumMismatch };
}
/**
* Parse satloc job file
* @param {*} fileContent
* @returns { inclusion_zones, exclusion_zones }
*/
function parseJobFile(fileContent) {
const polPattern = /\.POL (\d+)(?:\s\d+)*/g;
const parsedData = {
inclusion_zones: [],
exclusion_zones: []
};
let match;
while ((match = polPattern.exec(fileContent)) !== null) {
const incPattern = /INC\s+((?:-?\d+\.\d+\s+-?\d+\.\d+\s*)+)/g;
const excPattern = /EXC\s+((?:-?\d+\.\d+\s+-?\d+\.\d+\s*)+)/g;
const polId = match[1];
const polContent = fileContent.slice(polPattern.lastIndex);
const incMatch = incPattern.exec(polContent);
const excMatch = excPattern.exec(polContent);
if (incMatch && excMatch) {
const incStart = incMatch.index;
const excStart = excMatch.index;
if (incStart < excStart) {
const coordinates = incMatch[1].trim().split('\r\n\t');
parsedData.inclusion_zones.push({
pol_id: polId,
coordinates: coordinates.map(coord => coord.split(' ').map(Number).reverse())
});
} else {
const coordinates = excMatch[1].trim().split('\r\n\t');
parsedData.exclusion_zones.push({
pol_id: polId,
coordinates: coordinates.map(coord => coord.split(' ').map(Number).reverse())
});
}
} else if (incMatch) {
const coordinates = incMatch[1].trim().split('\r\n\t');
parsedData.inclusion_zones.push({
pol_id: polId,
coordinates: coordinates.map(coord => coord.split(' ').map(Number).reverse())
});
} else if (excMatch) {
const coordinates = excMatch[1].trim().split('\r\n\t');
parsedData.exclusion_zones.push({
pol_id: polId,
coordinates: coordinates.map(coord => coord.split(' ').map(Number).reverse())
});
}
}
return parsedData;
}
/**
* Converts agmission spray areas and excluded areas to satloc job file content
* @param {*} sprayAreas
* @param {*} excludedAreas
* @returns Content of the satloc job file
*/
function convertToJobFile(name, sprayAreas, excludedAreas) {
if (isEmptyArray(sprayAreas) && isEmptyArray(excludedAreas)) {
return null;
}
let jobFileContent = `.JOB ${name}\n.VERSION 2\n`;
function extractPolId(area) {
const match = area.properties.name.match(/_(\d+)$/);
return match ? parseInt(match[1], 10) : null;
}
function processAreas(areas, type) {
areas.sort((a, b) => extractPolId(a) - extractPolId(b));
areas.forEach(area => {
let polId = extractPolId(area);
let coordinates = area.geometry.coordinates[0];
if (coordinates.length > 1 && coordinates[0][0] === coordinates[coordinates.length - 1][0] && coordinates[0][1] === coordinates[coordinates.length - 1][1]) {
coordinates.pop();
}
if (type === 'EXC') {
coordinates = reorderCoords(coordinates);
}
coordinates = roundCoords(coordinates);
jobFileContent += `.POL ${polId} ${polId}\n\t${type}\n`;
coordinates.forEach(coord => {
jobFileContent += `\t${coord[0]} ${coord[1]}\n`;
});
});
}
processAreas(sprayAreas, 'INC');
processAreas(excludedAreas, 'EXC');
return jobFileContent;
}
/**
* Convert a satloc job file to agm spray areas and excluded areas
* @param {*} jobPath
* @returns { sprayAreas, excludedAreas }
*/
function convertJobFileToArea(jobPath) {
const parsedJob = parseJobFile(fs.readFileSync(jobPath));
let sprayAreas = [], excludedAreas = [];
for (const zone of parsedJob.inclusion_zones) {
sprayAreas = [...sprayAreas, ...createAreas(0, zone.coordinates, `Spray_Area_${zone.pol_id}`)];
}
for (const zone of parsedJob.exclusion_zones) {
excludedAreas = [...excludedAreas, ...createAreas(1, zone.coordinates, `Xcl_Area_${zone.pol_id}`)];
}
return { sprayAreas, excludedAreas };
}
function roundCoords(coords) {
return coords.map(coord => {
lon = coord[0].toFixed(6);
lat = coord[1].toFixed(6);
// revese the order of lat and lon from [lon, lat] to [lat, lon]
return [lat, lon];
});
}
function reorderCoords(coords) {
if (coords.length <= 1) return coords;
let reordered = [coords[0]]; // Start with the first point
for (let i = coords.length - 1; i > 0; i--) {
reordered.push(coords[i]);
}
return reordered;
}
/**
* @brief Check whether a set of poly coordinates is clockwise or counterclockwise
* @param {*} coors coordinate arrays i.e.: [[0,0],[1,1],[1,0],[0,0]] => true, [[0,0],[1,0],[1,1],[0,0]] => false
*/
function isClockwise(coordinates) {
if (!Array.isArray(coordinates)) {
return false;
}
let sum = 0;
for (let i = 0; i < coordinates.length; i++) {
const current = coordinates[i];
const next = coordinates[(i + 1) % coordinates.length];
// Calculate the sum of the cross products
sum += (next[0] - current[0]) * (next[1] + current[1]);
}
// If the sum is positive, the polygon is clockwise
return sum > 0;
}
function createPoly(coordinates) {
if (!coordinates || coordinates.length < 3) {
return null;
}
// If there are exactly 3 points, duplicate the last point to form a closed polygon
if (coordinates.length === 3) {
coordinates.push(coordinates[coordinates.length - 1]);
}
// Ensure the polygon is closed by adding the first point to the end if it's not already there
const firstPoint = coordinates[0];
const lastPoint = coordinates[coordinates.length - 1];
if (firstPoint[0] !== lastPoint[0] || firstPoint[1] !== lastPoint[1]) {
coordinates.push(firstPoint);
}
try {
return turf.polygon([coordinates]);
} catch (error) {
return null;
}
}
function isSameLatLn(p1, p2, offset = 1e-6) {
return (Math.abs(p1[0] - p2[0]) < offset && Math.abs(p1[1] - p2[1]) < offset);
}
function removeSameLatLns(coors) {
for (let i = 0; coors.length > 2 && i < coors.length - 1; i++) {
for (let j = i + 1; j < coors.length;) {
if (isSameLatLn(coors[i], coors[j]) && j != coors.length - 1) {
coors.splice(j, 1);
}
else j++;
}
}
return coors;
}
function isEmptyArray(theArray) {
return (!theArray || !Array.isArray(theArray) || !theArray.length);
}
function truncatePoly(coors) {
const processPoly = createPoly(coors);
if (!processPoly) return null;
try {
turf.truncate(processPoly, { precision: 6, mutate: true });
} catch (error) {
return null;
}
try {
removeSameLatLns(processPoly.geometry.coordinates[0]);
} catch (error) {
return null;
}
if (processPoly.geometry.coordinates[0].length <= 3)
return null;
return processPoly.geometry.coordinates[0];
}
/**
* Create a new GeoJson Area feature
* @param { number } type 0 : Spray area, 1: XCL area
* @param { [number][number] } coors array of corners [lat, lon]
* @param { string } name area name (be normalized)
* @param { string } color color name for the boundary
* @param { number } appRate application rate value
* @param { object } crop the crop object
*/
function createArea(type, coors, name, color, appRate = 0, crop = null) {
const isCW = isClockwise(coors);
if ((type === 0 && isCW === false) || (type === 1 && isCW === true))
coors = coors.reverse();
const area = {
properties: {
name,
type: type,
appRate: (type === 0 && appRate) ? appRate : 0,
color: color ? color : type === 0 ? 'blue' : 'red'
},
geometry: {
type: 'Polygon',
coordinates: [coors]
}
};
if (crop) {
if (crop.color)
area.properties.color = crop.color;
if (crop._id)
area.properties.crop = crop._id;
}
return area;
}
/**
* Create normalized areas. Input as one area might be splitted into multiple ones if it is kinked.
* @param {*} type area type. 0: Spray, 1: Xcl
* @param {*} coors coordinate list
* @param {*} name area name
* @param {*} color prefered color string
* @param {*} appRate application rate
* @param {*} crop crop entity instance
* @returns list of areas
*/
function createAreas(type, coors, name, color = null, appRate = 0, crop = null) {
const areas = [];
const truncatedCoordinates = truncatePoly(coors);
const polygon = createPoly(truncatedCoordinates);
if (!polygon) return areas;
try {
const kinks = turf.kinks(polygon).features;
if (!isEmptyArray(kinks)) {
const unKinkedPolygons = turf.unkinkPolygon(polygon);
unKinkedPolygons.features.forEach(feature => {
const area = turf.area(feature);
if (area >= 200) { // Skip areas smaller than 200 square meters
const coordinates = truncatePoly(feature.geometry.coordinates[0]);
const newArea = createArea(type, coordinates, name, color, appRate, crop);
areas.push(newArea);
}
});
} else {
const newArea = createArea(type, truncatedCoordinates, name, color, appRate, crop);
areas.push(newArea);
}
} catch (error) {
return areas;
}
return areas;
}
function convertToHumanReadable(records) {
return records?.map(record => `
Number: ${record.number}\n
Length: ${record.length}\n
Type: ${record.type}\n
Checksum: ${record.checksum}\n
Timestamp: ${record.timestamp}\n
Latitude: ${record.latitude} degrees\n
Longitude: ${record.longitude} degrees\n
Altitude: ${record.altitude} meters\n
Speed: ${record.speed} m/sec\n
Track: ${record.track} degrees\n
X-Track Deviation: ${record.xTrackDeviation} meters\n
Differential Age: ${record.differentialAge} seconds\n
Flags: ${record.flags == 0 ? 'Spray OFF' : record.flags == 2 ? 'Spray ON' : 'Not Used'}\n
---------------------------------------------------------------------------`).join('\n');
}
async function writeSatlocLogData(logId, logData) {
const IN_DIR_NAME = 'log_data';
const OUT_DIR_NAME = 'output_data';
const FILE_PREFIX = 'satlog-';
// Write log data to file
const logDataDir = path.join(__dirname, IN_DIR_NAME);
await fsPromise.mkdir(logDataDir, { recursive: true });
const inputFilePath = `${logDataDir}/${FILE_PREFIX}${logId}`;
await fsPromise.writeFile(`${inputFilePath}`, logData);
// Parse log data from base64 to human-readable format
const base64Data = await fsPromise.readFile(inputFilePath, 'utf8');
const binaryData = Buffer.from(base64Data, 'base64');
const binaryFilePath = `${inputFilePath}.bin`;
await fsPromise.writeFile(`${binaryFilePath}`, binaryData);
const parsedData = parseLogData(await fsPromise.readFile(`${inputFilePath}.bin`));
const humanReadableOutput = convertToHumanReadable(parsedData.records);
// Write human-readable data to file
const outputDataDir = path.join(__dirname, OUT_DIR_NAME);
await fsPromise.mkdir(outputDataDir, { recursive: true });
const outputFilePath = `${outputDataDir}/${FILE_PREFIX}${logId}.txt`;
await fsPromise.writeFile(`${outputFilePath}`, humanReadableOutput);
debug(`
Base64 data written to ${inputFilePath}
Binary data written to ${binaryFilePath}
Human-readable records written to ${outputFilePath}
Number of records: ${parsedData.numberOfRecords}
Number of unknown record types: ${parsedData.numUnknkownRecordType}
Number of byte mismatches: ${parsedData.numByteMisMatch}
Number of checksum mismatches: ${parsedData.numChecksumMismatch}
`);
}
function errorToString(error) {
if (!(error instanceof Error)) {
throw new TypeError('Expected an Error object');
}
return `Error: ${error.name}\nMessage: ${error.message}\nStack: ${error.stack}`;
}
function writeFile(filePath, data) {
try {
if (data) {
if (data instanceof Error) {
const now = new Date();
const timestamp = now.toISOString();
const errorString = `[${timestamp}] ${errorToString(data)}\n`;
fs.appendFileSync(filePath, errorString, 'utf8');
} else {
fs.writeFileSync(filePath, data, 'utf8');
}
debug(`Data written to ${filePath}`);
}
} catch (error) {
throw new Error(error);
}
}
module.exports = {
writeSatlocLogData,
parseLogData,
convertToHumanReadable,
writeFile,
parseJobFile,
createAreas,
createArea,
convertToJobFile,
convertJobFileToArea
}