497 lines
16 KiB
JavaScript
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
|
|
}
|