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 }