agmission/Development/server/helpers/satloc_log_parser.js

2503 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SatLoc Binary Log Parser
* High-performance async parser for SatLoc/Transland V.2 Log Files (Format Version 3.76)
*
* Parses binary log files according to LOGFileFormat_Air_3_76.md specification
* Maps data to AgMission ApplicationDetail and WorkRecord structures
*
* Enhanced with Application Processor integration for proper log grouping and file management
*/
const fs = require('fs').promises;
const moment = require('moment');
const path = require('path');
const logger = require('./logger');
const ApplicationDetail = require('../model/application_detail');
const { fixedTo } = require('../helpers/utils');
const { extractJobIdFromFileName } = require('./satloc_util');
const { FCTypes } = require('./constants');
// Record types from LOGFileFormat_Air_3_76.md specification
const RECORD_TYPES = {
// Specific numeric record types from binary format
POSITION_1: 1, // Both Short (43 bytes) and Enhanced (78 bytes) use type 1
GPS_10: 10,
GPS_STATUS_EXTENDED_11: 11, // Not used at this time May/2020
SWATH_NUMBER_20: 20,
FLOW_MONITOR_30: 30,
DUAL_FLOW_MONITOR_31: 31, // Deprecated
TARGET_APPLICATION_RATES_32: 32,
DUAL_FLOW_TARGET_RATES_33: 33,
APPLIED_RATES_36: 36,
FIRE_DRY_GATE_STATUS_37: 37,
IF2_DRY_GATE_38: 38,
TLEG_DRY_GATE_39: 39,
LASER_ALTIMETER_42: 42,
AGDISP_DATA_43: 43,
TACH_TIMES_45: 45,
CONTROLLER_TYPE_BY_NAME_46: 46,
IF2_LIQUID_BOOM_PRESSURE_47: 47,
WIND_50: 50,
MICRO_RPM_52: 52,
SBC_TEMPS_56: 56,
METERATE_57: 57,
MARKER_ASCII_60: 60,
MARKER_UNICODE_61: 61,
SYSTEM_SETUP_100: 100,
ENVIRONMENTAL_110: 110,
SWATHING_SETUP_120: 120,
FLOW_SETUP_140: 140,
BOOM_SECTIONS_142: 142,
JOB_INFO_STRING_151: 151,
JOB_INFO_NAME_STRING_152: 152
};
// Record type name resolution for debugging
const RECORD_TYPE_NAMES = {
1: 'POSITION',
10: 'GPS',
11: 'GPS_STATUS_EXTENDED',
20: 'SWATH_NUMBER',
30: 'FLOW_MONITOR',
31: 'DUAL_FLOW_MONITOR',
32: 'TARGET_APPLICATION_RATES',
33: 'DUAL_FLOW_TARGET_RATES',
36: 'APPLIED_RATES',
37: 'FIRE_DRY_GATE_STATUS',
38: 'IF2_DRY_GATE',
39: 'TLEG_DRY_GATE',
42: 'LASER_ALTIMETER',
43: 'AGDISP_DATA',
45: 'TACH_TIMES',
46: 'CONTROLLER_TYPE_BY_NAME',
47: 'IF2_LIQUID_BOOM_PRESSURE',
50: 'WIND',
52: 'MICRO_RPM',
56: 'SBC_TEMPS',
57: 'METERATE',
60: 'MARKER_ASCII',
61: 'MARKER_UNICODE',
100: 'SYSTEM_SETUP',
110: 'ENVIRONMENTAL',
120: 'SWATHING_SETUP',
140: 'FLOW_SETUP',
142: 'BOOM_SECTIONS',
151: 'JOB_INFO_STRING',
152: 'JOB_INFO_NAME_STRING'
};
// Record start flag from specification
const RECORD_START_FLAG = 0xA5;
class SatLocLogParser {
constructor(options = {}) {
this.options = {
batchSize: options.batchSize || 1000,
skipUnknownRecords: options.skipUnknownRecords !== false,
validateChecksums: options.validateChecksums !== false,
debugRecordTypes: options.debugRecordTypes || [], // Array of record types to debug with full details
verbose: options.verbose || false, // Enable verbose logging
maxPositionsPerJob: options.maxPositionsPerJob, // Only limit if explicitly set (no default)
trackSequence: options.trackSequence || false, // Only track sequence for debugging
...options
};
// Initialize Pino logger
this.logger = logger.child('satloc_parser');
this.statistics = {
totalRecords: 0,
validRecords: 0,
invalidRecords: 0,
recordTypes: {},
parseErrors: 0,
positionsSkipped: 0 // Track positions skipped due to limits
};
// Track actual sequence for pattern analysis
this.recordSequence = [];
}
/**
* Get record type name for debugging
*/
getRecordTypeName(recordType) {
return RECORD_TYPE_NAMES[recordType] || `UNKNOWN_${recordType}`;
}
/**
* Check if record type should be debugged with full details
*/
shouldDebugRecord(recordType) {
return this.options.debugRecordTypes.includes(recordType) ||
this.options.debugRecordTypes.includes('ALL');
}
/**
* Format timestamp object as a single line string
*/
formatTimestamp(timestamp) {
if (!timestamp || typeof timestamp !== 'object') return timestamp;
if (timestamp.year && timestamp.month && timestamp.day) {
return `${timestamp.year}-${String(timestamp.month).padStart(2, '0')}-${String(timestamp.day).padStart(2, '0')} ${String(timestamp.hour).padStart(2, '0')}:${String(timestamp.minute).padStart(2, '0')}:${String(timestamp.seconds).padStart(2, '0')}.${String(timestamp.milliseconds).padStart(3, '0')}`;
}
return timestamp;
}
/**
* Format data object for logging, converting timestamps to single line
*/
formatDataForLogging(data) {
if (!data || typeof data !== 'object') return data;
const formatted = { ...data };
if (formatted.timestamp) {
formatted.timestamp = this.formatTimestamp(formatted.timestamp);
}
return formatted;
}
/**
* Log debug information with record type name
*/
debugRecord(recordType, message, data = null) {
const recordName = this.getRecordTypeName(recordType);
if (this.options.verbose || this.shouldDebugRecord(recordType)) {
if (data) {
const formattedData = this.formatDataForLogging(data);
this.logger.debug({
module: 'satloc_parser',
recordType,
recordName,
message,
data: formattedData
}, `[${recordName}_${recordType}] ${message}`);
} else {
this.logger.debug({
module: 'satloc_parser',
recordType,
recordName,
message
}, `[${recordName}_${recordType}] ${message}`);
}
}
}
/**
* Parse a SatLoc binary log file
* @param {string} filePath - Path to the .log file
* @param {Object} fileContext - Context information (fileId, jobId, etc.)
* @returns {Promise<Object>} Parse results with statistics and data
*/
async parseFile(filePath, fileContext = {}) {
try {
// Extract job ID from filename using utility
const fileName = path.basename(filePath);
const filenameJobId = extractJobIdFromFileName(fileName);
// Merge filename job ID into file context
const enhancedFileContext = {
...fileContext,
fileName,
filenameJobId
};
// Read file directly as binary buffer
const binaryBuffer = await fs.readFile(filePath);
// Parse header from buffer
const headerInfo = await this.readHeaderFromBuffer(binaryBuffer);
// Parse binary records from buffer
const parseResults = await this.parseRecordsFromBuffer(binaryBuffer, headerInfo, enhancedFileContext);
return {
success: true,
headerInfo,
fileName,
filenameJobId,
statistics: this.statistics,
...parseResults
};
} catch (error) {
this.logger.error({ error: error.message, filePath }, `Parse error: ${error.message}`);
return {
success: false,
error: error.message,
statistics: this.statistics
};
}
}
/**
* Read and validate file header from buffer (ASCII "AS" + version string)
* Note: Some SatLoc formats don't use null terminators - they use the 0xA5 record start flag
* to mark the end of the header. We check for both null byte and 0xA5 record start.
*/
async readHeaderFromBuffer(buffer) {
try {
if (buffer.length < 3) {
throw new Error('Buffer too short for valid header');
}
// Check for ASCII "AS"
if (buffer[0] !== 0x41 || buffer[1] !== 0x53) { // 'A', 'S'
throw new Error('Invalid file header - missing AS signature');
}
// Find version string end - check for BOTH null byte (0x00) AND record start flag (0xA5)
// Some formats use null terminator, others use 0xA5 to mark first record
let versionEnd = 2;
while (versionEnd < buffer.length && buffer[versionEnd] !== 0 && buffer[versionEnd] !== RECORD_START_FLAG) {
versionEnd++;
}
if (versionEnd >= buffer.length) {
throw new Error('Invalid file header - no terminator found (null byte or 0xA5 record start)');
}
// Extract version, trimming any trailing spaces
const version = buffer.slice(2, versionEnd).toString('ascii').trim();
// Header length is up to (but not including) the terminator
// If terminator is 0xA5 (record start), headerLength is versionEnd (records start there)
// If terminator is 0x00 (null byte), headerLength is versionEnd + 1 (skip null)
const headerLength = buffer[versionEnd] === RECORD_START_FLAG ? versionEnd : versionEnd + 1;
this.logger.debug({
version,
headerLength,
terminatorType: buffer[versionEnd] === RECORD_START_FLAG ? '0xA5 (record start)' : '0x00 (null byte)',
terminatorPosition: versionEnd
}, `Parsed header: version="${version}", headerLength=${headerLength}`);
return {
version,
headerLength
};
} catch (error) {
this.logger.error({ error: error.message }, `Header read error: ${error.message}`);
throw error;
}
}
/**
* Extract null-terminated string from buffer, handling padding characters
*/
extractNullTerminatedString(buffer) {
if (!buffer || buffer.length === 0) return '';
// Find first null byte
const nullIndex = buffer.indexOf(0);
if (nullIndex === -1) {
// No null byte found, return entire buffer as string
return buffer.toString('ascii').trim();
}
// Extract string up to null byte
return buffer.subarray(0, nullIndex).toString('ascii').trim();
}
/**
* Read and validate file header (ASCII "AS" + version + null byte)
*/
async readFileHeader(filePath) {
const handle = await fs.open(filePath, 'r');
try {
// Read first 32 bytes to find header
const buffer = Buffer.allocUnsafe(32);
const { bytesRead } = await handle.read(buffer, 0, 32, 0);
if (bytesRead < 3) {
throw new Error('File too short for valid header');
}
// Check for ASCII "AS"
if (buffer[0] !== 0x41 || buffer[1] !== 0x53) { // 'A', 'S'
throw new Error('Invalid file header - missing "AS" signature');
}
// Find null terminator for version
let versionEnd = 2;
while (versionEnd < bytesRead && buffer[versionEnd] !== 0) {
versionEnd++;
}
if (versionEnd >= bytesRead) {
throw new Error('Invalid file header - no null terminator found');
}
const version = buffer.slice(2, versionEnd).toString('ascii');
const dataStartOffset = versionEnd + 1;
return {
version,
dataStartOffset,
fileSize: (await handle.stat()).size
};
} finally {
await handle.close();
}
}
/**
* Parse binary records from the binary buffer
* Record format: 0xA5 (start flag) + Length + Type + Checksum + Data
* Total record length = Length field (includes 4 header bytes)
* Checksum = XOR of all bytes from start flag to end of data (inclusive)
*
* @param {*} buffer the binary buffer
* @param {*} headerInfo parsed file header
* @param {*} fileContext the context for the file being processed
* @returns {Promise<Object>} the parsed records
*/
async parseRecordsFromBuffer(buffer, headerInfo, fileContext) {
// MEMORY OPTIMIZATION: Don't accumulate all records - only keep essential metadata
// const records = []; // REMOVED - causes memory leak
let recordCount = 0; // Track count instead
let currentGPS = null; // GPS (10)
let currentFlow = null; // Flow Monitor (130)
let currentFlowSetup = null; // Flow Setup (140)
let currentWind = null; // Wind (50)
let currentSwath = null; // Swath Number (20)
let currentEnvironmental = null; // Environmental (70)
let currentLaser = null; // Laser (80)
let currentAppliedRate = null; // Applied Rate (36)
let currentTargetRate = null; // Target Rate (32)
let currentPressure = null; // Boom Pressure (47)
let currentGPSExtent = null; // GPS 11, N/A yet since 2020
let currentSwathing = null; // Swathing (120)
let currentControllerType = null; // Controller Type (130)
let currentTach = null; // Tach Times (45)
let currentAgdisp = null; // AgDisp Data (43)
let currentSystemSetup = null; // System Setup (100)
// Bounding box calculation [minX, minY, maxX, maxY] (like geo_util.updateAreasBBoxLL)
let boundingBox = [Number.MAX_VALUE, Number.MAX_VALUE, (-1 * Number.MAX_VALUE), (-1 * Number.MAX_VALUE)];
let utmZone = null;
// Job detection and grouping variables
const jobGroups = {};
let detectedJobIds = {
jobLongLabelName: null, // From SWATHING_SETUP_120
satlocJobId: null, // From JOB_INFO_STRING_151 or JOB_INFO_NAME_STRING_152
filenameJobId: fileContext.filenameJobId || null
};
let currentJobId = null; // Current effective job ID for grouping
// Metadata extraction variables (moved from application processor)
const metadata = {
jobId: null,
satlocJobId: null, // Will be set at end of parsing with priority: filename -> jobLongLabelName -> satlocJobId (151/152)
aircraftId: null,
pilotName: null,
fcType: null, // Flow Controller Type: Liquid, Dry
fcName: null, // Flow Controller Name
};
let position = headerInfo.headerLength || 0; // Start after header
const bufferSize = buffer.length;
this.logger.debug({ position, bufferSize }, `Starting record parsing from position ${position}, buffer size: ${bufferSize}`);
this.logger.debug({ firstBytes: buffer.slice(position, position + 20).toString('hex') }, `First 20 bytes after header`);
while (position < bufferSize - 4) { // Need at least 4 bytes for record header
// Look for record start flag (0xA5)
if (buffer[position] !== RECORD_START_FLAG) {
position++;
continue;
}
if (this.options.verbose) {
this.logger.debug({ position }, `Found potential record start at position ${position}`);
}
// Record structure: Start Flag (1) + Length (1) + Type (1) + Checksum (1) + Data (Length-4)
const recordLength = buffer[position + 1];
const recordType = buffer[position + 2];
const recordChecksum = buffer[position + 3];
if (this.options.verbose) {
this.logger.debug({ recordLength, recordType, recordChecksum }, `Record: length=${recordLength}, type=${recordType}, checksum=${recordChecksum}`);
}
// Validate record length (minimum 4 for header, maximum 255)
if (recordLength < 4 || recordLength > 255) {
if (this.options.verbose) {
this.logger.debug({ recordLength }, `Invalid record length: ${recordLength}`);
}
position++;
continue;
}
// Check if we have complete record in buffer
if (position + recordLength > bufferSize) {
this.logger.debug({ position, recordLength, bufferSize }, `Incomplete record at end of buffer: position ${position}, length ${recordLength}, buffer size ${bufferSize}`);
break;
}
// Validate checksum if enabled (XOR of all bytes from start flag to end of data)
if (this.options.validateChecksums) {
const calculatedChecksum = this.calculateChecksum(buffer, position, recordLength);
if (calculatedChecksum !== recordChecksum) {
this.logger.debug({
recordType,
position,
expectedChecksum: recordChecksum,
calculatedChecksum
}, `Checksum mismatch for record type ${recordType} at position ${position}: expected ${recordChecksum}, calculated ${calculatedChecksum}`);
this.statistics.invalidRecords++;
position++;
continue;
}
}
// Extract record data (everything after the 4-byte header)
const dataLength = recordLength - 4;
const recordData = buffer.slice(position + 4, position + 4 + dataLength);
// Parse record based on type first to get enhanced info
let parsedRecord;
try {
parsedRecord = this.parseRecord(recordType, recordData, {
currentGPS,
currentGPSExtent,
currentFlow,
currentFlowSetup,
currentWind,
currentSwath,
currentSwathing,
currentEnvironmental,
currentLaser,
currentAppliedRate,
currentTargetRate,
currentControllerType,
currentTach,
currentAgdisp,
currentSystemSetup,
currentPressure
});
if (parsedRecord) {
this.statistics.validRecords++;
this.statistics.recordTypes[recordType] = (this.statistics.recordTypes[recordType] || 0) + 1;
// Update context based on record type
if (parsedRecord.recordType === RECORD_TYPES.POSITION_1) {
// Create application detail record with accumulated context
if (parsedRecord.lat && parsedRecord.lon) {
const context = {
currentGPS,
currentGPSExtent,
currentFlow,
currentFlowSetup,
currentWind,
currentSwath,
currentSwathing,
currentEnvironmental,
currentLaser,
currentAppliedRate,
currentTargetRate,
currentControllerType,
currentTach,
currentAgdisp,
currentSystemSetup,
currentPressure
};
const appDetail = this.createApplicationDetail(parsedRecord, fileContext, context);
// Update bounding box calculation incrementally
if (appDetail.lat !== null && appDetail.lon !== null) {
if (appDetail.lon < boundingBox[0]) boundingBox[0] = appDetail.lon; // minX (lon)
if (appDetail.lat < boundingBox[1]) boundingBox[1] = appDetail.lat; // minY (lat)
if (appDetail.lon > boundingBox[2]) boundingBox[2] = appDetail.lon; // maxX (lon)
if (appDetail.lat > boundingBox[3]) boundingBox[3] = appDetail.lat; // maxY (lat)
}
// Determine current effective job ID with priority: filename -> jobLongLabelName -> satlocJobId -> 'unknown'
currentJobId = detectedJobIds.filenameJobId && detectedJobIds.filenameJobId !== 'null' && detectedJobIds.filenameJobId.trim() !== ''
? detectedJobIds.filenameJobId
: (detectedJobIds.jobLongLabelName || detectedJobIds.satlocJobId || 'unknown');
// Group application detail by job ID with safety limit
if (!jobGroups[currentJobId]) {
jobGroups[currentJobId] = [];
}
// MEMORY OPTIMIZATION: Limit positions per job to prevent OOM (only if maxPositionsPerJob is set)
if (this.options.maxPositionsPerJob !== undefined && jobGroups[currentJobId].length >= this.options.maxPositionsPerJob) {
// Skip this position but increment counter for logging
this.statistics.positionsSkipped++;
if (this.statistics.positionsSkipped === 1) {
this.logger.warn(`Job ${currentJobId} exceeded max positions limit (${this.options.maxPositionsPerJob}), skipping additional positions`);
}
} else {
jobGroups[currentJobId].push(appDetail);
}
}
} else if (parsedRecord.recordType === RECORD_TYPES.GPS_10 || parsedRecord.recordType === RECORD_TYPES.GPS_STATUS_EXTENDED_11) {
currentGPS = parsedRecord;
currentGPSExtent = parsedRecord; // N/A 2020, Is it available now?
} else if (parsedRecord.recordType === RECORD_TYPES.SWATH_NUMBER_20) {
currentSwath = parsedRecord;
} else if (parsedRecord.recordType === RECORD_TYPES.FLOW_MONITOR_30) {
currentFlow = parsedRecord;
} else if (parsedRecord.recordType === RECORD_TYPES.TARGET_APPLICATION_RATES_32) {
currentTargetRate = parsedRecord;
} else if (parsedRecord.recordType === RECORD_TYPES.APPLIED_RATES_36) {
currentAppliedRate = parsedRecord;
} else if (parsedRecord.recordType === RECORD_TYPES.LASER_ALTIMETER_42) {
currentLaser = parsedRecord;
} else if (parsedRecord.recordType === RECORD_TYPES.AGDISP_DATA_43) {
currentAgdisp = parsedRecord;
} else if (parsedRecord.recordType === RECORD_TYPES.TACH_TIMES_45) {
currentTach = parsedRecord;
} else if (parsedRecord.recordType === RECORD_TYPES.CONTROLLER_TYPE_BY_NAME_46) {
currentControllerType = parsedRecord;
// Metadata extraction
if (parsedRecord.controllerType && !metadata.fcName) {
metadata.fcName = parsedRecord.controllerType;
}
} else if (parsedRecord.recordType === RECORD_TYPES.WIND_50) {
currentWind = parsedRecord;
} else if (parsedRecord.recordType === RECORD_TYPES.SYSTEM_SETUP_100) {
currentSystemSetup = parsedRecord;
// Metadata extraction
if (parsedRecord.aircraftId && !metadata.aircraftId) {
metadata.aircraftId = parsedRecord.aircraftId;
}
if (parsedRecord.pilotName && !metadata.pilotName) {
metadata.pilotName = parsedRecord.pilotName;
}
} else if (parsedRecord.recordType === RECORD_TYPES.ENVIRONMENTAL_110) {
currentEnvironmental = parsedRecord;
} else if (parsedRecord.recordType === RECORD_TYPES.SWATHING_SETUP_120) {
// Note: JobId,JobLongLabelName could be used later for matching if Satloc can assure the matching jobName later
currentSwathing = parsedRecord;
// Job ID detection: Extract jobLongLabelName, fallback, for job grouping
if (parsedRecord.jobLongLabelName) {
detectedJobIds.jobLongLabelName = parsedRecord.jobLongLabelName;
this.logger.debug(`Detected job ID from SWATHING_SETUP_120: ${parsedRecord.jobLongLabelName}`);
}
// Metadata extraction
if (parsedRecord.jobId && !metadata.jobId) {
metadata.jobId = parsedRecord.jobId;
}
} else if (parsedRecord.recordType === RECORD_TYPES.FLOW_SETUP_140) {
currentFlowSetup = parsedRecord;
metadata.fcType = parsedRecord.flowControlStatus.dry ? FCTypes.DRY : FCTypes.LIQUID;
// Flow Setup (140) is used as fallback for target rate info in getFlowRates()
} else if (parsedRecord.recordType === RECORD_TYPES.JOB_INFO_STRING_151) {
// JOB_INFO_STRING_151 processed - extract job info
if (parsedRecord.jobInfo) {
detectedJobIds.satlocJobId = parsedRecord.jobInfo;
this.logger.debug(`Detected job ID from JOB_INFO_STRING_151: ${parsedRecord.jobInfo}`);
}
} else if (parsedRecord.recordType === RECORD_TYPES.JOB_INFO_NAME_STRING_152) {
// JOB_INFO_NAME_STRING_152 processed - extract job name (jobFileName field from parser)
if (parsedRecord.jobFileName) {
detectedJobIds.satlocJobId = parsedRecord.jobFileName;
this.logger.debug(`Detected job ID from JOB_INFO_NAME_STRING_152: ${parsedRecord.jobFileName}`);
}
}
// Track this record in the actual sequence (after parsing to get enhanced info)
this.recordSequence.push({
recordType,
position: this.statistics.totalRecords,
bytePosition: position,
length: recordLength,
isEnhanced: parsedRecord.isEnhanced || false,
recordSubtype: parsedRecord.recordSubtype || null
});
// MEMORY OPTIMIZATION: Don't push to records array - causes memory leak
// records.push(parsedRecord); // REMOVED
recordCount++; // Just increment counter
}
} catch (parseError) {
this.logger.error({ recordType, error: parseError.message }, `Error parsing record type ${recordType}: ${parseError.message}`);
this.statistics.parseErrors++;
}
this.statistics.totalRecords++;
position += recordLength; // Move to next record
}
this.logger.info({ recordCount }, `Completed parsing: found ${recordCount} records`);
// Calculate UTM zone from bounding box if we have valid coordinates
if (boundingBox[0] !== Number.MAX_VALUE) {
const geoUtil = require('./geo_util');
utmZone = geoUtil.calcRefZonebyBbox(boundingBox);
this.logger.debug(`Calculated UTM zone: ${utmZone.zone}${utmZone.hemisphere} from bbox [${boundingBox.join(', ')}]`);
}
// Finalize metadata with satlocJobId from jobGroups keys
// Extract all job IDs from jobGroups and join as comma-separated string
const jobGroupKeys = Object.keys(jobGroups);
if (jobGroupKeys.length > 0) {
metadata.jobId = jobGroupKeys.join(',');
}
// Finalize satlocJobId with priority: filename -> jobLongLabelName -> satlocJobId (151/152)
metadata.satlocJobId = detectedJobIds.filenameJobId
|| detectedJobIds.jobLongLabelName
|| detectedJobIds.satlocJobId
|| null;
// MEMORY OPTIMIZATION: Log job group sizes for monitoring
const jobGroupSizes = {};
let totalPositions = 0;
for (const [jobId, details] of Object.entries(jobGroups)) {
jobGroupSizes[jobId] = details.length;
totalPositions += details.length;
}
this.logger.info({
jobGroupSizes,
totalPositions,
positionsSkipped: this.statistics.positionsSkipped,
maxPositionsPerJob: this.options.maxPositionsPerJob
}, `Job groups: ${totalPositions} positions across ${jobGroupKeys.length} jobs`);
if (this.statistics.positionsSkipped > 0) {
this.logger.warn(`Skipped ${this.statistics.positionsSkipped} positions to prevent memory overflow`);
}
return {
// MEMORY OPTIMIZATION: Don't return full records array
// records, // REMOVED
recordCount,
// New integrated calculations
boundingBox: boundingBox[0] !== Number.MAX_VALUE ? boundingBox : null,
utmZone: utmZone ? {
zoneNumber: utmZone.zone,
hemisphere: utmZone.hemisphere,
// Legacy format for backward compatibility
toString: () => `${utmZone.zone}${utmZone.hemisphere}`
} : null,
jobGroups,
detectedJobIds,
metadata
};
}
/**
* Calculate XOR checksum for record validation
* Checksum = XOR of all bytes from Record Start Flag to end of data (inclusive)
* This excludes the checksum byte itself
*/
calculateChecksum(buffer, startPos, length) {
let checksum = 0;
// XOR all bytes from start flag to end of data, excluding the checksum byte at position startPos + 3
for (let i = startPos; i < startPos + length; i++) {
if (i !== startPos + 3) { // Skip the checksum byte itself
checksum ^= buffer[i];
}
}
return checksum;
}
/**
* Parse individual record based on type using RECORD_TYPES constants
*/
parseRecord(recordType, data, context) {
let result = null;
switch (recordType) {
case RECORD_TYPES.POSITION_1:
result = this.parsePosition_1(data, context);
break;
case RECORD_TYPES.GPS_10:
result = this.parseGPS_10(data, context);
break;
case RECORD_TYPES.GPS_STATUS_EXTENDED_11:
result = this.parseGPSStatusExtended_11(data, context);
break;
case RECORD_TYPES.SWATH_NUMBER_20:
result = this.parseSwathNumber_20(data, context);
break;
case RECORD_TYPES.FLOW_MONITOR_30:
result = this.parseFlowMonitor_30(data, context);
break;
case RECORD_TYPES.DUAL_FLOW_MONITOR_31:
result = this.parseDualFlowMonitor_31(data, context);
break;
case RECORD_TYPES.TARGET_APPLICATION_RATES_32:
result = this.parseTargetApplicationRates_32(data, context);
break;
case RECORD_TYPES.DUAL_FLOW_TARGET_RATES_33:
result = this.parseDualFlowTargetRates_33(data, context);
break;
case RECORD_TYPES.APPLIED_RATES_36:
result = this.parseAppliedRates_36(data, context);
break;
case RECORD_TYPES.FIRE_DRY_GATE_STATUS_37:
result = this.parseFireDryGateStatus_37(data, context);
break;
case RECORD_TYPES.IF2_DRY_GATE_38:
result = this.parseIF2DryGate_38(data, context);
break;
case RECORD_TYPES.TLEG_DRY_GATE_39:
result = this.parseTLEGDryGate_39(data, context);
break;
case RECORD_TYPES.LASER_ALTIMETER_42:
result = this.parseLaserAltimeter_42(data, context);
break;
case RECORD_TYPES.AGDISP_DATA_43:
result = this.parseAgdispData_43(data, context);
break;
case RECORD_TYPES.TACH_TIMES_45:
result = this.parseTachTimes_45(data, context);
break;
case RECORD_TYPES.CONTROLLER_TYPE_BY_NAME_46:
result = this.parseControllerTypeByName_46(data, context);
break;
case RECORD_TYPES.IF2_LIQUID_BOOM_PRESSURE_47:
result = this.parseIF2LiquidBoomPressure_47(data, context);
if (result) {
context.currentPressure = {
primaryPressure: result.if2LiqPriBoomPressure,
dualPressure: result.if2LiqDualBoomPressure
};
}
break;
case RECORD_TYPES.WIND_50:
result = this.parseWind_50(data, context);
break;
case RECORD_TYPES.MICRO_RPM_52:
result = this.parseMicroRPM_52(data, context);
break;
case RECORD_TYPES.SBC_TEMPS_56:
result = this.parseSBCTemps_56(data, context);
break;
case RECORD_TYPES.METERATE_57:
result = this.parseMeterate_57(data, context);
break;
case RECORD_TYPES.MARKER_ASCII_60:
result = this.parseMarkerASCII_60(data, context);
break;
case RECORD_TYPES.MARKER_UNICODE_61:
result = this.parseMarkerUnicode_61(data, context);
break;
case RECORD_TYPES.SYSTEM_SETUP_100:
result = this.parseSystemSetup_100(data, context);
break;
case RECORD_TYPES.ENVIRONMENTAL_110:
result = this.parseEnvironmental_110(data, context);
break;
case RECORD_TYPES.SWATHING_SETUP_120:
result = this.parseSwathingSetup_120(data, context);
break;
case RECORD_TYPES.FLOW_SETUP_140:
result = this.parseFlowSetup_140(data, context);
break;
case RECORD_TYPES.BOOM_SECTIONS_142:
result = this.parseBoomSections_142(data, context);
break;
case RECORD_TYPES.JOB_INFO_STRING_151:
result = this.parseJobInfoString_151(data, context);
break;
case RECORD_TYPES.JOB_INFO_NAME_STRING_152:
result = this.parseJobInfoNameString_152(data, context);
break;
default:
if (!this.options.skipUnknownRecords) {
this.debugRecord(recordType, `Unknown record type encountered`);
result = {
recordType: recordType,
rawData: data
};
}
break;
}
// Log parsed result for debugging if recordType is in debugRecordTypes or verbose is enabled
if (result && (this.options.verbose || this.shouldDebugRecord(recordType))) {
this.debugRecord(recordType, 'Parsed successfully', result);
}
return result;
}
/**
* Parse Position Record (Type 1) - handles both Short and Enhanced
* Short: 43 bytes total (39 data + 4 header)
* Enhanced: 78 bytes total (74 data + 4 header)
*/
parsePosition_1(data, context) {
if (data.length < 39) return null; // Minimum size for Position Short
let offset = 0;
const timestamp = this.parseTimestamp(data, offset);
offset += 5;
// Common fields for both Short and Enhanced
const lat = data.readDoubleLE(offset); // degrees
offset += 8;
const lon = data.readDoubleLE(offset); // degrees
offset += 8;
const altitude = data.readFloatLE(offset); // meters
offset += 4;
const speed = data.readFloatLE(offset); // m/sec
offset += 4;
const track = data.readFloatLE(offset); // degrees
offset += 4;
const xTrack = data.readFloatLE(offset); // meters
offset += 4;
const differentialAge = data.readUInt8(offset); // seconds
offset += 1;
const flags = data.readUInt8(offset); // position flags
offset += 1;
const baseRecord = {
recordType: RECORD_TYPES.POSITION_1,
timestamp,
lat,
lon,
altitude,
speed,
track,
xTrack,
differentialAge,
flags, // 0 = Spray off, 2: Spray on
};
// Check if this is Enhanced record (78 bytes total = 74 data bytes)
if (data.length >= 74) {
const recordTypeField = data.readUInt8(offset); // 1 = Enhanced, 2 = Enhanced/LPC boom on
offset += 1;
const boomControlStatus = data.readUInt8(offset);
offset += 1;
const targetFlowRateLha = data.readFloatLE(offset); // L/ha
offset += 4;
const targetFlowRateLmin = data.readFloatLE(offset); // L/min
offset += 4;
const flowRateLha = data.readFloatLE(offset); // L/ha
offset += 4;
const flowRateLmin = data.readFloatLE(offset); // L/min
offset += 4;
const valvePosition = data.readInt16LE(offset); // shaft position
offset += 2;
const statusBitFields = data.readUInt8(offset); // bit fields byte 64
offset += 1;
const primaryFlowTurbineStdev = data.readUInt8(offset); // 0-255%
offset += 1;
const dualFlowTurbineStdev = data.readUInt8(offset); // 0-255%
offset += 1;
const gpsVelNorth = data.readFloatLE(offset); // Raw GPS fVNorth
offset += 4;
const gpsVelEast = data.readFloatLE(offset); // Raw GPS fVEast
offset += 4;
const gpsVelUp = data.readFloatLE(offset); // Raw GPS fVUp
offset += 4;
return {
...baseRecord,
isEnhanced: true,
recordTypeField,
boomControlStatus,
targetFlowRateLha,
targetFlowRateLmin,
flowRateLha,
flowRateLmin,
valvePosition,
statusBitFields,
aircraftPumpOn: (statusBitFields & 0x01) ? 1 : 0,
insidePolygon: (statusBitFields & 0x02) ? 1 : 0,
constantOrVrRate: (statusBitFields & 0x04) ? 1 : 0,
autoBoomOn: (statusBitFields & 0x08) ? 1 : 0,
primaryFlowTurbineStdev,
dualFlowTurbineStdev,
gpsVelNorth,
gpsVelEast,
gpsVelUp
};
}
// Position Short record
return {
...baseRecord,
isEnhanced: false
};
}
/**
* Parse additional record types (placeholders for extended functionality)
*/
parseDualFlowTargetRates_33(data, context) {
if (data.length < 6) return null; // Updated: no timestamp, minimum 6 bytes for dual flow rates
let offset = 0;
// No timestamp in this record according to spec
return {
recordType: RECORD_TYPES.DUAL_FLOW_TARGET_RATES_33,
targetRate1: data.readUInt16LE(offset) * 0.01,
targetRate2: data.readUInt16LE(offset + 2) * 0.01,
units: data.readUInt8(offset + 4)
};
}
/**
* Parse Fire/Dry Gate Status Record (Type 37)
* 30 bytes total (26 data + 4 header)
*/
parseFireDryGateStatus_37(data, context) {
if (data.length < 26) return null;
let offset = 0;
const applicationMode = data.readUInt8(offset); // Mode 1 to 7
offset += 1;
const unitsChar = String.fromCharCode(data.readUInt8(offset)); // E=English, M=Metric
offset += 1;
const appliedResolution = data.readUInt8(offset); // 0=1/16", 1=1mm, 2=1/32"
offset += 1;
const activeLevels = data.readUInt8(offset); // 1 to 7 levels
offset += 1;
const loggedTargetSpread = data.readFloatLE(offset); // Kg per min (Not used)
offset += 4;
const appliedSpreadRate = data.readFloatLE(offset); // Kg/Ha
offset += 4;
const appliedSpreadPerMin = data.readFloatLE(offset); // Kg per min (Not used)
offset += 4;
const appliedGateLevel = data.readInt16LE(offset); // Resolution Units
offset += 2;
const encoderPosition = data.readInt16LE(offset); // 1 to 2048
offset += 2;
const targetEncoderPosition = data.readInt16LE(offset); // 1 to 2048
offset += 2;
const gpsTrim = data.readInt16LE(offset); // Steps +/- 1 to n
offset += 2;
const manualTrim = data.readInt16LE(offset); // Steps +/- 1 to n
offset += 2;
return {
recordType: RECORD_TYPES.FIRE_DRY_GATE_STATUS_37,
applicationMode,
units: unitsChar,
appliedResolution,
activeLevels,
loggedTargetSpread,
appliedSpreadRate,
appliedSpreadPerMin,
appliedGateLevel,
encoderPosition,
targetEncoderPosition,
gpsTrim,
manualTrim
};
}
/**
* Parse IF2 Dry Gate Record (Type 38)
* 47 bytes total (43 data + 4 header)
*/
parseIF2DryGateRecord(data, context) {
if (data.length < 43) return null;
let offset = 0;
const applicationMode = data.readUInt8(offset); // Mode 2
offset += 1;
const taskMode = data.readUInt8(offset); // 0 to 3 (Local FDG/No PMap = 3)
offset += 1;
const appliedResolution = data.readUInt8(offset); // 0=1/32", 1=1/16"
offset += 1;
const machineState = data.readUInt8(offset); // 0 to 14 at this time
offset += 1;
const switchState = data.readUInt8(offset); // bit field
offset += 1;
const gateStatus = data.readUInt8(offset); // bit field
offset += 1;
const gateSoftState = data.readUInt8(offset); // 0=Go to Gate index 0, 1=User selected
offset += 1;
const targetSpreadRate = data.readFloatLE(offset); // Kg/Ha
offset += 4;
const targetSpreadPerMin = data.readFloatLE(offset); // Kg per min (Not used)
offset += 4;
const appliedSpreadRate = data.readFloatLE(offset); // Kg/Ha
offset += 4;
const appliedSpreadPerMin = data.readFloatLE(offset); // Kg per min (Not used)
offset += 4;
const gpsTrim = data.readInt16LE(offset); // +/- GPS Trimmed Speed Up/Down
offset += 2;
const manualTrim = data.readInt16LE(offset); // +/- Manually Trimmed Up/Down
offset += 2;
const miscStates = data.readUInt16LE(offset); // bit field
offset += 2;
const gateLevelSteps = data.readUInt16LE(offset); // 0 to 272 in steps of 1/32"
offset += 2;
const encoderPosition = data.readUInt16LE(offset); // Absolute Encoder position 0 to 10,000
offset += 2;
const cumulativeUptimeCpu = data.readUInt16LE(offset); // Total hours Uptime
offset += 2;
const softLevelTarget = data.readUInt16LE(offset); // 12 to 2000
offset += 2;
const pgtPGain = data.readUInt16LE(offset); // 0 to 65535
offset += 2;
const pgtGGain = data.readUInt16LE(offset); // 0 to 8000
offset += 2;
const pgtTolerance = data.readUInt16LE(offset); // 0 to 65535
offset += 2;
return {
recordType: RECORD_TYPES.IF2_DRY_GATE_38,
applicationMode,
taskMode,
appliedResolution,
machineState,
switchState: {
arm: (switchState & 0x01) ? 1 : 0,
fuselage: (switchState & 0x02) ? 1 : 0,
trigger: (switchState & 0x04) ? 1 : 0,
trim: (switchState & 0x08) ? 1 : 0
},
gateStatus: {
gateClosed: (gateStatus & 0x01) ? 1 : 0,
gateState: (gateStatus >> 1) & 0x03, // bits 1-2: 0=Fully Closed, 1=Open, 2=Soft Level
},
gateSoftState,
targetSpreadRate,
targetSpreadPerMin,
appliedSpreadRate,
appliedSpreadPerMin,
gpsTrim,
manualTrim,
miscStates: {
encoderMoved: (miscStates & 0x01) ? 1 : 0,
encoderOk: (miscStates & 0x02) ? 1 : 0,
hydroPumpOn: (miscStates & 0x04) ? 1 : 0,
hydroOpenSolenoidOn: (miscStates & 0x08) ? 1 : 0,
hydroCloseSolenoidOn: (miscStates & 0x10) ? 1 : 0
},
gateLevelSteps,
encoderPosition,
cumulativeUptimeCpu,
softLevelTarget,
pgtPGain,
pgtGGain,
pgtTolerance
};
}
/**
* Parse IF2 Dry Gate Record (Type 38)
* 47 bytes total (43 data + 4 header)
*/
parseIF2DryGate_38(data, context) {
if (data.length < 43) return null;
let offset = 0;
const applicationMode = data.readUInt8(offset); // Application MODE
offset += 1;
const taskMode = data.readUInt8(offset); // TASK Mode
offset += 1;
const appliedResolution = data.readUInt8(offset); // Applied Resolution
offset += 1;
const machineState = data.readUInt8(offset); // Machine State
offset += 1;
const switchState = data.readUInt8(offset); // Switch State
offset += 1;
const gateStatus = data.readUInt8(offset); // Gate Status
offset += 1;
const gateSoftState = data.readUInt8(offset); // Gate SOFT State
offset += 1;
const targetSpreadRate = data.readFloatLE(offset); // Target Spread Rate (Kg/Ha)
offset += 4;
const targetSpreadPerMin = data.readFloatLE(offset); // Target Spread per min (not used)
offset += 4;
const appliedSpreadRate = data.readFloatLE(offset); // Applied Spread Rate (Kg/Ha)
offset += 4;
const appliedSpreadPerMin = data.readFloatLE(offset); // Applied Spread per min (not used)
offset += 4;
const gpsTrim = data.readInt16LE(offset); // GPS TRIM
offset += 2;
const manualTrim = data.readInt16LE(offset); // Manual TRIM
offset += 2;
// Part B
const miscStates = data.readUInt16LE(offset); // Misc States
offset += 2;
const gateLevelSteps = data.readUInt16LE(offset); // Gate Level Steps
offset += 2;
const encoderPosition = data.readUInt16LE(offset); // Encoder Position
offset += 2;
const cumulativeUptimeCPU = data.readUInt16LE(offset); // Cumulative Uptime CPU
offset += 2;
const softLevelTarget = data.readUInt16LE(offset); // SOFT Level Target
offset += 2;
const pgtPGain = data.readUInt16LE(offset); // PGT P Gain
offset += 2;
const pgtGGain = data.readUInt16LE(offset); // PGT G Gain
offset += 2;
const pgtTolerance = data.readUInt16LE(offset); // PGT Tolerance
offset += 2;
return {
recordType: RECORD_TYPES.IF2_DRY_GATE_38,
applicationMode,
taskMode,
appliedResolution,
machineState,
switchState,
gateStatus,
gateSoftState,
targetSpreadRate,
targetSpreadPerMin,
appliedSpreadRate,
appliedSpreadPerMin,
gpsTrim,
manualTrim,
miscStates,
gateLevelSteps,
encoderPosition,
cumulativeUptimeCPU,
softLevelTarget,
pgtPGain,
pgtGGain,
pgtTolerance
};
}
/**
* Parse TLEG Dry Gate Record (Type 39)
* 44 bytes total (40 data + 4 header)
*/
parseTLEGDryGate_39(data, context) {
if (data.length < 40) return null;
let offset = 0;
const applicationOpMode = data.readUInt8(offset); // Application OP Mode
offset += 1;
const taskMode = data.readUInt8(offset); // 0=Single/Product Profiles, 1=Levels/FDG
offset += 1;
const appliedResolution = data.readUInt8(offset); // 0=1/32", 1=1/16"
offset += 1;
const machineState = data.readUInt8(offset); // 0 to 15
offset += 1;
const switchState = data.readUInt8(offset); // bit field
offset += 1;
const gateState = data.readUInt8(offset); // bit field
offset += 1;
const userSelectedGateClosedState = data.readUInt8(offset); // 0=Latched, 1=User SOFT
offset += 1;
const tlegInternalTemp = data.readUInt8(offset); // Internal temperature C
offset += 1;
const targetSpreadRate = data.readFloatLE(offset); // Kg/Ha
offset += 4;
const targetSpreadPerMin = data.readFloatLE(offset); // Kg per min (Not used)
offset += 4;
const appliedSpreadRate = data.readFloatLE(offset); // Kg/Ha
offset += 4;
const appliedSpreadPerMin = data.readFloatLE(offset); // Kg per min (Not used)
offset += 4;
const gpsTrim = data.readInt16LE(offset); // +/- GPS Trimmed Speed Up/Down
offset += 2;
const manualTrim = data.readInt16LE(offset); // +/- Manually Trimmed Up/Down
offset += 2;
const preGateLevelSteps = data.readUInt16LE(offset); // 0 to 158 in steps of 1/32"
offset += 2;
const gateLevelSteps = data.readUInt16LE(offset); // 0 to 158 in steps of 1/32"
offset += 2;
const encoderPosition = data.readUInt16LE(offset); // Internal TLEG Encoder 0.0° to 360.0° * 10
offset += 2;
const cumulativeUptimeCpu = data.readUInt16LE(offset); // Total hours Uptime
offset += 2;
const latchedTargetDegrees = data.readUInt16LE(offset); // 0.0° to 360.0° * 10
offset += 2;
const softTargetDegrees = data.readUInt16LE(offset); // 0.0° to 360.0° * 10
offset += 2;
return {
recordType: RECORD_TYPES.TLEG_DRY_GATE_39,
applicationOpMode,
taskMode,
appliedResolution,
machineState,
switchState: {
arm: (switchState & 0x01) ? 1 : 0,
trigger: (switchState & 0x02) ? 1 : 0,
fuselage: (switchState & 0x04) ? 1 : 0,
motor: (switchState & 0x08) ? 1 : 0,
sprayOn: (switchState & 0x10) ? 1 : 0,
gateMoving: (switchState & 0x20) ? 1 : 0,
encoderStatus: (switchState & 0x40) ? 'OK' : 'Error'
},
gateState: {
gateClosedState: (gateState & 0x01) ? 'Soft' : 'Latched',
gateOpen: (gateState & 0x02) ? 1 : 0,
gateJam: (gateState & 0x10) ? 1 : 0
},
userSelectedGateClosedState,
tlegInternalTemp,
targetSpreadRate,
targetSpreadPerMin,
appliedSpreadRate,
appliedSpreadPerMin,
gpsTrim,
manualTrim,
preGateLevelSteps,
gateLevelSteps,
encoderPosition: encoderPosition / 10.0, // Convert back to degrees
cumulativeUptimeCpu,
latchedTargetDegrees: latchedTargetDegrees / 10.0, // Convert back to degrees
softTargetDegrees: softTargetDegrees / 10.0 // Convert back to degrees
};
}
/**
* Parse AgDisp Data Record (Type 43)
* 12 bytes total (8 data + 4 header)
*/
parseAgdispData_43(data, context) {
if (data.length < 8) return null;
let offset = 0;
const windOffsetDirection = data.readFloatLE(offset); // Degrees
offset += 4;
const appliedOffsetInMeters = data.readFloatLE(offset); // Meters
offset += 4;
return {
recordType: RECORD_TYPES.AGDISP_DATA_43,
windOffsetDirection,
appliedOffsetInMeters
};
}
/**
* Parse Micro-RPM Record (Type 52)
* 25 bytes total (21 data + 4 header)
*/
parseMicroRPM_52(data, context) {
if (data.length < 21) return null;
let offset = 0;
const opMode = data.readUInt8(offset); // 0 or 1 (On/Off)
offset += 1;
const microAtomiserLeft1 = data.readInt16LE(offset); // RPM
offset += 2;
const microAtomiserLeft2 = data.readInt16LE(offset); // RPM
offset += 2;
const microAtomiserLeft3 = data.readInt16LE(offset); // RPM
offset += 2;
const microAtomiserLeft4 = data.readInt16LE(offset); // RPM
offset += 2;
const microAtomiserLeft5 = data.readInt16LE(offset); // RPM
offset += 2;
const microAtomiserRight1 = data.readInt16LE(offset); // RPM
offset += 2;
const microAtomiserRight2 = data.readInt16LE(offset); // RPM
offset += 2;
const microAtomiserRight3 = data.readInt16LE(offset); // RPM
offset += 2;
const microAtomiserRight4 = data.readInt16LE(offset); // RPM
offset += 2;
const microAtomiserRight5 = data.readInt16LE(offset); // RPM
offset += 2;
return {
recordType: RECORD_TYPES.MICRO_RPM_52,
opMode,
leftAtomisers: [
microAtomiserLeft1,
microAtomiserLeft2,
microAtomiserLeft3,
microAtomiserLeft4,
microAtomiserLeft5
],
rightAtomisers: [
microAtomiserRight1,
microAtomiserRight2,
microAtomiserRight3,
microAtomiserRight4,
microAtomiserRight5
]
};
}
/**
* Parse SBC (CPU Temps) Record (Type 56)
* 20 bytes total (16 data + 4 header)
*/
parseSBCTemps_56(data, context) {
if (data.length < 16) return null;
let offset = 0;
const cpuTemp1 = data.readFloatLE(offset); // Degrees Celsius
offset += 4;
const cpuTemp2 = data.readFloatLE(offset); // Degrees Celsius
offset += 4;
const cpuTemp3 = data.readFloatLE(offset); // Degrees Celsius
offset += 4;
const cpuTemp4 = data.readFloatLE(offset); // Degrees Celsius
offset += 4;
return {
recordType: RECORD_TYPES.SBC_TEMPS_56,
cpuTemperatures: [cpuTemp1, cpuTemp2, cpuTemp3, cpuTemp4]
};
}
/**
* Parse Meterate Record (Type 57)
* 25 bytes total (21 data + 4 header)
*/
parseMeterate_57(data, context) {
if (data.length < 21) return null;
let offset = 0;
const autoManual = data.readUInt8(offset); // Auto or Manual state
offset += 1;
const baseSpeed = data.readUInt8(offset); // MPH
offset += 1;
const everySpeed = data.readUInt16LE(offset); // MPH (* 100)
offset += 2;
const controlVoltage = data.readUInt16LE(offset); // Vdc (* 100)
offset += 2;
const tachRpm = data.readUInt16LE(offset); // RPM
offset += 2;
const stepsESpeed = data.readUInt8(offset); // RPM steps per E-Speed
offset += 1;
const targetSpreadRate = data.readUInt16LE(offset); // Kg/Ha (* 100)
offset += 2;
const targetSpreadPerMin = data.readUInt32LE(offset); // Kg per min (* 1000)
offset += 4;
const appliedSpreadRate = data.readUInt16LE(offset); // Kg/Ha (* 100)
offset += 2;
const appliedSpreadPerMin = data.readUInt32LE(offset); // Kg per min (* 1000)
offset += 4;
return {
recordType: RECORD_TYPES.METERATE_57,
autoManual,
baseSpeed,
everySpeed: everySpeed / 100.0, // Convert back from (* 100)
controlVoltage: controlVoltage / 100.0, // Convert back from (* 100)
tachRpm,
stepsESpeed,
targetSpreadRate: targetSpreadRate / 100.0, // Convert back from (* 100)
targetSpreadPerMin: targetSpreadPerMin / 1000.0, // Convert back from (* 1000)
appliedSpreadRate: appliedSpreadRate / 100.0, // Convert back from (* 100)
appliedSpreadPerMin: appliedSpreadPerMin / 1000.0 // Convert back from (* 1000)
};
}
/**
* Parse Swathing Setup Record (Type 120)
* Variable length: 21 or 52 bytes (17 or 48 data + 4 header)
*/
parseSwathingSetup_120(data, context) {
if (data.length < 17) return null;
let offset = 0;
const jobId = this.extractNullTerminatedString(data.slice(offset, offset + 11));
offset += 11;
const patternType = data.readUInt8(offset); // see Table 2
offset += 1;
const patternLR = String.fromCharCode(data.readUInt8(offset)); // 'L' | 'R'
offset += 1;
const swathWidth = data.readFloatLE(offset); // meters
offset += 4;
const result = {
recordType: RECORD_TYPES.SWATHING_SETUP_120,
jobId,
patternType,
patternLR,
swathWidth
};
// Check for Job Long Label Name (optional 31 bytes)
if (data.length >= 48) {
const jobLongLabelName = this.extractNullTerminatedString(data.slice(offset, offset + 31));
result.jobLongLabelName = jobLongLabelName;
}
return result;
}
/**
* Parse Flow Setup Record (Type 140)
* 23 bytes total (19 data + 4 header)
*/
parseFlowSetup_140(data, context) {
if (data.length < 19) return null;
let offset = 0;
const flowControlStatus = data.readUInt8(offset);
offset += 1;
const totalSprayLiters = data.readFloatLE(offset); // liters
offset += 4;
const valveCalibration = data.readInt16LE(offset);
offset += 2;
const meterCalibration = data.readFloatLE(offset); // counts/liter
offset += 4;
const applicationPerArea = data.readFloatLE(offset); // liters/hectare
offset += 4;
const applicationRate = data.readFloatLE(offset); // liters/minute
offset += 4;
return {
recordType: RECORD_TYPES.FLOW_SETUP_140,
flowControlStatus: {
mode: flowControlStatus & 0x03, // 0=OFF, 1=Control ON, 2=Monitor Only
variable: (flowControlStatus & 0x40) ? true : false, // +0x40 = Variable, else Constant
dry: (flowControlStatus & 0x80) ? true : false // +0x80 = DRY, else WET
},
totalSprayLiters,
valveCalibration,
meterCalibration,
applicationPerArea,
applicationRate
};
}
/**
* Parse Boom Sections Record (Type 142)
* Total Length: 31 bytes (27 data + 4 header)
* Per spec: NO timestamp in this record type
*/
parseBoomSections_142(data, context) {
if (data.length < 27) return null;
let offset = 0;
const boomState = data.readUInt8(offset); // 0=Manual, 1=Automatic
offset += 1;
const boomSections = data.readUInt8(offset); // 1, 3, 4, or 5
offset += 1;
const boomValveStates = data.readUInt8(offset); // bit field: 2 or 3 valve states O/C
offset += 1;
const farLeftSection = data.readUInt32LE(offset); // meters ×1000 m
offset += 4;
const leftCenterSection = data.readUInt32LE(offset); // meters ×1000 m
offset += 4;
const leftSection = data.readUInt32LE(offset); // meters ×1000 m
offset += 4;
const centerSection = data.readUInt32LE(offset); // meters ×1000 m
offset += 4;
const rightSection = data.readUInt32LE(offset); // meters ×1000 m
offset += 4;
const farRightSection = data.readUInt32LE(offset); // meters ×1000 m
offset += 4;
return {
recordType: RECORD_TYPES.BOOM_SECTIONS_142,
boomState,
boomSections,
boomValveStates,
farLeftSection,
leftCenterSection,
leftSection,
centerSection,
rightSection,
farRightSection
};
}
/**
* Parse Job Info NAME String Record (Type 152)
* Used ONLY with Falcon and G4 logs
* Total Length: 42 bytes (38 data + 4 header)
* Per spec: NO timestamp in this record type
*/
parseJobInfoNameString_152(data, context) {
if (data.length < 38) return null;
let offset = 0;
const jobVersionId = data.readInt16LE(offset); // Job Version ID (2 bytes)
offset += 2;
const jobFileName = this.extractNullTerminatedString(data.slice(offset, offset + 32)); // Job File Long Name (32 bytes ASCIIZ)
offset += 32;
const numberOfPolygons = data.readInt16LE(offset); // Number of Polygons (2 bytes)
offset += 2;
const numberOfPatterns = data.readInt16LE(offset); // Number of Patterns (2 bytes)
offset += 2;
return {
recordType: RECORD_TYPES.JOB_INFO_NAME_STRING_152,
jobVersionId,
jobFileName,
numberOfPolygons,
numberOfPatterns
};
}
/**
* Parse System Setup Record (Type 100)
* 43 bytes total (39 data + 4 header)
*/
parseSystemSetup_100(data, context) {
if (data.length < 39) return null;
let offset = 0;
const timestamp = this.parseTimestamp(data, offset);
offset += 5;
const pilotName = this.extractNullTerminatedString(data.slice(offset, offset + 11));
offset += 11;
const aircraftId = this.extractNullTerminatedString(data.slice(offset, offset + 11));
offset += 11;
const loggingInterval = data.readUInt8(offset); // seconds*10
offset += 1;
const loggingMinSpeed = data.readFloatLE(offset); // m/sec
offset += 4;
const gpsMaskAngle = data.readUInt8(offset); // degrees
offset += 1;
const gmtOffset = data.readInt16LE(offset); // minutes
offset += 2;
const compassVariation = data.readFloatLE(offset); // degrees
offset += 4;
return {
recordType: RECORD_TYPES.SYSTEM_SETUP_100,
timestamp,
pilotName,
aircraftId,
loggingInterval: loggingInterval / 10.0, // Convert back to seconds
loggingMinSpeed,
gpsMaskAngle,
gmtOffset,
compassVariation
};
}
/**
* Parse GPS Record (Type 10)
* Contains: GDOP, Satellite count, DGPS station info, AIMMS data
*/
parseGPS_10(data, context) {
if (data.length < 10) return null; // Minimum 10 bytes for basic GPS record
let offset = 0;
const gdop = data.readFloatLE(offset);
offset += 4;
const satellitesByte = data.readUInt8(offset); // Packed: (# tracked << 4) + # used
offset += 1;
const dgpsStationId = data.readInt16LE(offset);
offset += 2;
// Decode satellites byte: upper 4 bits = tracked, lower 4 bits = used
const satellitesTracked = (satellitesByte >> 4) & 0x0F;
const satellitesUsed = satellitesByte & 0x0F;
const result = {
recordType: RECORD_TYPES.GPS_10,
gdop,
satellitesTracked, // Number of satellites tracked
satellitesUsed, // Number of satellites used in solution
dgpsStationId
};
if (data.length >= 10) {
result.aimmsNavSource = data.readUInt8(offset); // 0 = IMU, 1 = GPS
offset += 1;
result.aimmsSvInGpsSolution = data.readUInt8(offset);
offset += 1;
result.aimmsGpsPosType = data.readUInt8(offset); // 16=SPS, 18=WAAS, 19=Extrapolated, 0=None
offset += 1;
}
return result;
}
/**
* Parse Swath Number Record (Type 20)
*/
/**
* Parse Swath Number Record (Type 20)
* 6 bytes total (2 data + 4 header)
*/
parseSwathNumber_20(data, context) {
if (data.length < 2) return null;
return {
recordType: RECORD_TYPES.SWATH_NUMBER_20,
swathNumber: data.readInt16LE(0) // A-B=1, right: 2,3,4..., left: -2,-3,-4...
};
}
/**
* Parse Flow Monitor/Control Record (Type 30)
* 10 bytes total (6 data + 4 header) - valve position may be optional
*/
parseFlowMonitor_30(data, context) {
if (data.length < 4) return null; // Minimum for flow rate
let offset = 0;
const flowRate = data.readFloatLE(offset); // liters/minute
offset += 4;
const result = {
recordType: RECORD_TYPES.FLOW_MONITOR_30,
flowRate
};
// Valve position may or may not exist (legacy support)
if (data.length >= 6) {
result.valvePosition = data.readInt16LE(offset);
}
return result;
}
/**
* Parse Target Application Rates Record (Type 32)
* Total Length: 9 bytes (4 header + 5 data)
* Per spec: NO timestamp in this record type
*/
parseTargetApplicationRates_32(data, context) {
if (data.length < 5) return null;
let offset = 0;
const targetRate = data.readFloatLE(offset); // Target Rate LPM (L/min)
offset += 4;
const flags = data.readUInt8(offset); // BOOM 0 = Off, 1 = ON (Flow)
return {
recordType: RECORD_TYPES.TARGET_APPLICATION_RATES_32,
targetRate, // Always in L/min according to specification
flags
};
}
/**
* Parse Applied Rates Record (Type 36)
* Variable length: 2 + 6 × number_of_channels
* Per spec: NO timestamp in this record type
*/
parseAppliedRates_36(data, context) {
if (data.length < 2) return null;
let offset = 0;
const numberOfChannels = data.readUInt16LE(offset); // 2 bytes as per spec
offset += 2;
if (numberOfChannels < 0 || numberOfChannels > 41) return null;
if (data.length < 2 + (6 * numberOfChannels)) return null; // 2 bytes units + 4 bytes rate = 6 per channel
const channels = [];
for (let i = 0; i < numberOfChannels; i++) {
const units = data.readUInt16LE(offset); // Application units ID (2 bytes per spec)
offset += 2;
const rate = data.readFloatLE(offset); // Actual application rate (4 bytes)
offset += 4;
channels.push({
channelIndex: i + 1,
units,
appliedRate: rate
});
}
return {
recordType: RECORD_TYPES.APPLIED_RATES_36,
numberOfChannels,
channels
};
}
/**
* Parse Wind Record (Type 50)
* 10 bytes total (6 data + 4 header)
*/
parseWind_50(data, context) {
if (data.length < 6) return null;
let offset = 0;
const windDirection = data.readInt16LE(offset); // degrees
offset += 2;
const windVelocity = data.readFloatLE(offset); // m/sec
offset += 4;
return {
recordType: RECORD_TYPES.WIND_50,
windDirection,
windSpeed: windVelocity // Alias for compatibility
};
}
/**
* Parse Marker ASCII Record (Type 60)
* Variable length: 26 + label length
*/
parseMarkerASCII_60(data, context) {
if (data.length < 22) return null; // Updated: no timestamp, minimum 22 bytes
let offset = 0;
// No timestamp in this record according to spec
const markerType = data.readUInt8(offset);
offset += 1;
const latitude = data.readDoubleLE(offset);
offset += 8;
const longitude = data.readDoubleLE(offset);
offset += 8;
const altitude = data.readFloatLE(offset);
offset += 4;
if (offset >= data.length) {
// No label
return {
recordType: RECORD_TYPES.MARKER_ASCII_60,
markerType,
latitude,
longitude,
altitude,
labelLength: 0,
text: ''
};
}
const labelLength = data.readUInt8(offset);
offset += 1;
let labelText = '';
if (labelLength > 0 && offset < data.length) {
const labelBytes = data.slice(offset, offset + labelLength);
labelText = this.extractNullTerminatedString(labelBytes);
}
return {
recordType: RECORD_TYPES.MARKER_ASCII_60,
markerType,
latitude,
longitude,
altitude,
labelLength,
text: labelText
};
}
/**
* Parse Marker Unicode Record (Type 61)
* Variable length: 26 + label length
*/
parseMarkerUnicode_61(data, context) {
if (data.length < 22) return null; // Updated: no timestamp, minimum 22 bytes
let offset = 0;
// No timestamp in this record according to spec
const markerType = data.readUInt8(offset);
offset += 1;
const latitude = data.readDoubleLE(offset);
offset += 8;
const longitude = data.readDoubleLE(offset);
offset += 8;
const altitude = data.readFloatLE(offset);
offset += 4;
if (offset >= data.length) {
// No label
return {
recordType: RECORD_TYPES.MARKER_UNICODE_61,
markerType,
latitude,
longitude,
altitude,
labelLength: 0,
text: ''
};
}
const labelLength = data.readUInt8(offset);
offset += 1;
let labelText = '';
if (labelLength > 0 && offset < data.length) {
const labelBytes = data.slice(offset, offset + labelLength);
labelText = labelBytes.toString('utf16le');
// Remove null termination
labelText = labelText.replace(/\0.*$/, '');
}
return {
recordType: RECORD_TYPES.MARKER_UNICODE_61,
markerType,
latitude,
longitude,
altitude,
labelLength,
text: labelText
};
}
/**
* Parse GPS Status Extended Record (Type 11)
* 27 bytes total (23 data + 4 header) - Not used at this time May/2020
*/
parseGPSStatusExtended_11(data, context) {
if (data.length < 23) return null;
let offset = 0;
const navMode = data.readUInt16LE(offset);
offset += 2;
const ageOfDifferential = data.readUInt16LE(offset);
offset += 2;
const reserved1 = data.readUInt32LE(offset);
offset += 4;
const reserved2 = data.readUInt32LE(offset);
offset += 4;
const gdop = data.readFloatLE(offset);
offset += 4;
const hdop = data.readFloatLE(offset);
offset += 4;
const satellitesByte = data.readUInt8(offset); // Packed: (# tracked << 4) + # used
offset += 1;
const dgpsStationId = data.readUInt16LE(offset);
offset += 2;
// Decode satellites byte: upper 4 bits = tracked, lower 4 bits = used
const satellitesTracked = (satellitesByte >> 4) & 0x0F;
const satellitesUsed = satellitesByte & 0x0F;
return {
recordType: RECORD_TYPES.GPS_STATUS_EXTENDED_11,
navMode,
ageOfDifferential,
reserved1,
gdop,
hdop,
satellitesTracked, // Number of satellites tracked
satellitesUsed, // Number of satellites used in solution
dgpsStationId
};
}
/**
* Parse Dual Flow Monitor/Control Record (Type 31) - Deprecated
* 16 bytes total (12 data + 4 header)
*/
parseDualFlowMonitor_31(data, context) {
if (data.length < 12) return null;
let offset = 0;
const primaryFlowRate = data.readFloatLE(offset);
offset += 4;
const secondaryFlowRate = data.readFloatLE(offset);
offset += 4;
const primaryValvePosition = data.readInt16LE(offset);
offset += 2;
const secondaryValvePosition = data.readInt16LE(offset);
offset += 2;
return {
recordType: RECORD_TYPES.DUAL_FLOW_MONITOR_31,
primaryFlowRate,
secondaryFlowRate,
primaryValvePosition,
secondaryValvePosition
};
}
/**
* Parse TLEG Dry Gate Record (Type 39)
* 44 bytes total (40 data + 4 header)
*/
parseTLEGDryGateRecord(data, context) {
if (data.length < 40) return null;
let offset = 0;
const applicationOpMode = data.readUInt8(offset);
offset += 1;
const taskMode = data.readUInt8(offset);
offset += 1;
const appliedResolution = data.readUInt8(offset);
offset += 1;
const machineState = data.readUInt8(offset);
offset += 1;
const switchState = data.readUInt8(offset);
offset += 1;
const gateState = data.readUInt8(offset);
offset += 1;
const userSelectedGateClosedState = data.readUInt8(offset);
offset += 1;
const tlegInternalTemp = data.readUInt8(offset);
offset += 1;
const targetSpreadRate = data.readFloatLE(offset);
offset += 4;
const targetSpreadPerMin = data.readFloatLE(offset);
offset += 4;
const appliedSpreadRate = data.readFloatLE(offset);
offset += 4;
const appliedSpreadPerMin = data.readFloatLE(offset);
offset += 4;
const gpsTrim = data.readInt16LE(offset);
offset += 2;
const manualTrim = data.readInt16LE(offset);
offset += 2;
const preGateLevelSteps = data.readUInt16LE(offset);
offset += 2;
const gateLevelSteps = data.readUInt16LE(offset);
offset += 2;
const encoderPosition = data.readUInt16LE(offset);
offset += 2;
const cumulativeUptimeCpu = data.readUInt16LE(offset);
offset += 2;
const latchedTargetDegrees = data.readUInt16LE(offset);
offset += 2;
const softTargetDegrees = data.readUInt16LE(offset);
offset += 2;
return {
recordType: RECORD_TYPES.TLEG_DRY_GATE_39,
applicationOpMode,
taskMode,
appliedResolution,
machineState,
switchState,
gateState,
userSelectedGateClosedState,
tlegInternalTemp,
targetSpreadRate,
targetSpreadPerMin,
appliedSpreadRate,
appliedSpreadPerMin,
gpsTrim,
manualTrim,
preGateLevelSteps,
gateLevelSteps,
encoderPosition,
cumulativeUptimeCpu,
latchedTargetDegrees,
softTargetDegrees
};
}
/**
* Parse TACH Times Record (Type 45)
* 12 bytes total (8 data + 4 header)
*/
parseTachTimes_45(data, context) {
if (data.length < 8) return null;
let offset = 0;
const totalTachCurrentTime = data.readUInt32LE(offset);
offset += 4;
const totalTachTotalTime = data.readUInt32LE(offset);
offset += 4;
return {
recordType: RECORD_TYPES.TACH_TIMES_45,
totalTachCurrentTime,
totalTachTotalTime
};
}
/**
* Parse Controller TYPE by Name Record (Type 46)
* 25 bytes total (21 data + 4 header)
*/
parseControllerTypeByName_46(data, context) {
if (data.length < 21) return null;
const controllerType = this.extractNullTerminatedString(data.slice(0, 21));
return {
recordType: RECORD_TYPES.CONTROLLER_TYPE_BY_NAME_46,
controllerType
};
}
/**
* Parse IF2 Liquid BOOM Pressure Record (Type 47)
* 12 bytes total (8 data + 4 header)
*/
parseIF2LiquidBoomPressure_47(data, context) {
if (data.length < 8) return null;
let offset = 0;
const if2LiqPriBoomPressure = data.readFloatLE(offset); // Lbs pressure
offset += 4;
const if2LiqDualBoomPressure = data.readFloatLE(offset); // Lbs pressure
offset += 4;
return {
recordType: RECORD_TYPES.IF2_LIQUID_BOOM_PRESSURE_47,
if2LiqPriBoomPressure,
if2LiqDualBoomPressure
};
}
/**
* Parse Laser Altimeter Record (Type 42)
* 8 bytes total (4 data + 4 header)
*/
parseLaserAltimeter_42(data, context) {
if (data.length < 4) return null;
let offset = 0;
const heightAgl = data.readFloatLE(offset); // meters
return {
recordType: RECORD_TYPES.LASER_ALTIMETER_42,
heightAgl
};
}
/**
* Parse Environmental Record (Type 110)
* 13 bytes total (9 data + 4 header)
*/
parseEnvironmental_110(data, context) {
if (data.length < 9) return null;
let offset = 0;
const temperature = data.readFloatLE(offset); // °C
offset += 4;
const relativeHumidity = data.readUInt8(offset); // % humidity
offset += 1;
const barometricPressure = data.readFloatLE(offset); // kPsc
offset += 4;
return {
recordType: RECORD_TYPES.ENVIRONMENTAL_110,
temperature,
relativeHumidity,
barometricPressure
};
}
/**
* Parse Job Info Record (Type 151)
*/
parseJobInfoString_151(data, context) {
if (data.length < 39) return null;
let offset = 0;
const jobId = data.readUInt32LE(offset);
offset += 4;
// Job title is 30 characters, null-terminated
const jobTitle = this.extractNullTerminatedString(data.slice(offset, offset + 30));
offset += 30;
const numberOfPolygons = data.readUInt16LE(offset);
offset += 2;
const numberOfPatterns = data.readUInt16LE(offset);
offset += 2;
return {
recordType: RECORD_TYPES.JOB_INFO_STRING_151,
jobId,
jobTitle,
numberOfPolygons,
numberOfPatterns
};
}
/**
* Parse 5-byte timestamp from SatLoc format according to LOGFileFormat_Air_3_76.md
* Returns date components to avoid timezone interpretation issues
* Validates components and returns null if invalid to prevent NaN timestamps
* Handles rollover case for legacy 4-bit year encoding
*
* Format:
* Byte 4 = (Y<<4) + Month, where Y is year-1993
* 4 bytes = ((Y>>4)<<29) + (Day<<24) + (Hour<<19) + (Minute<<13) + (Seconds<<7) + Hundredths
*
* Rollover handling:
* - Modern format: Uses 7 bits for year (3 high + 4 low), valid 1993-2120
* - Legacy format: Uses 4 bits for year (only low), valid 1993-2008, then rolls over
* - Example: Year 2009 in legacy format appears as 1993 (0+1993), but we detect and correct it
*/
parseTimestamp(data, offset) {
if (data.length < offset + 5) return null;
// Byte 4 (first byte): Y (year low, 4 bits) + Month (4 bits)
const byte4 = data[offset];
const yearLow4 = (byte4 >> 4) & 0x0F; // Y low 4 bits (year - 1993)
const month = byte4 & 0x0F; // Month (4 bits)
// Bytes 3-0 (4 bytes): Read as little-endian 32-bit value
// Formula: ((Y >> 4) << 29) + (Day << 24) + (Hour << 19) + (Minute << 13) + (Seconds << 7) + Hundredths
const timeValue = data.readUInt32LE(offset + 1);
// Extract components according to specification encoding formula
const yearHigh3 = (timeValue >> 29) & 0x07; // Y high 3 bits (year - 1993)
const day = (timeValue >> 24) & 0x1F; // Day (5 bits)
const hour = (timeValue >> 19) & 0x1F; // Hour (5 bits)
const minute = (timeValue >> 13) & 0x3F; // Minute (6 bits)
const seconds = (timeValue >> 7) & 0x3F; // Seconds (6 bits)
const hundredths = timeValue & 0x7F; // Hundredths (7 bits)
// Reconstruct full year: combine high 3 bits and low 4 bits
let yearOffset = (yearHigh3 << 4) | yearLow4;
// Handle rollover case for legacy 4-bit year encoding
// If high 3 bits are 0, this might be legacy format (valid 1993-2008)
// "If the top three bits of byte 3 are used, this is valid to 2120. If not, it will roll over after 2008."
if (yearHigh3 === 0 && yearLow4 <= 15) {
// Legacy 4-bit encoding detected - yearLow4 represents (year - 1993) with 4 bits (0-15)
// This covers 1993-2008. After 2008, it rolls over.
const currentYear = new Date().getFullYear();
const legacyYear = yearLow4 + 1993; // 1993-2008
// Only apply rollover correction if:
// 1. The parsed year is significantly older than current year (more than 15 years)
// 2. AND we're in a time period where rollover is likely (after 2008)
const yearDifference = currentYear - legacyYear;
const isLikelyRollover = yearDifference > 15 && currentYear >= 2009;
if (isLikelyRollover) {
// Apply rollover: add 16 to get to next 16-year cycle
// This maps: 0->16, 1->17, ..., 15->31
// Which gives us: 2009-2024 for the first rollover cycle
yearOffset = yearLow4 + 16;
}
}
const year = yearOffset + 1993;
// Validate components to prevent invalid timestamps
// Extended year range to accommodate rollover handling
const isValid = year >= 1993 && year <= 2120 &&
month >= 1 && month <= 12 &&
day >= 1 && day <= 31 &&
hour >= 0 && hour <= 23 &&
minute >= 0 && minute <= 59 &&
seconds >= 0 && seconds <= 59 &&
hundredths >= 0 && hundredths <= 99;
if (!isValid) {
// Return null for invalid timestamps instead of invalid date components
return null;
}
// Return date components instead of Date object to avoid timezone issues
return {
year,
month,
day,
hour,
minute,
seconds,
milliseconds: hundredths * 10
};
}
/**
* Create ApplicationDetail record from position data and accumulated context
* Updated mapping based on SATLOC_TO_APPLICATIONDETAIL_MAPPING.csv
*/
createApplicationDetail(positionRecord, fileContext, context = {}) {
const { currentGPS, currentFlow, currentFlowSetup, currentWind, currentSwath, currentSwathing, currentEnvironmental, currentLaser, currentAppliedRate, currentTargetRate, currentPressure, currentControllerType, currentTach, currentAgdisp, currentSystemSetup } = context;
// Extract job information for matching - prioritize filename-based job ID
const filenameJobId = fileContext.filenameJobId || null;
const swathingJobId = currentSwathing?.jobId || null;
const jobLongLabelName = currentSwathing?.jobLongLabelName || null;
// Use filename job ID as primary, fall back to jobLongLabelName from Swathing Setup (120)
const satlocJobId = filenameJobId || jobLongLabelName;
const aircraftId = currentSystemSetup?.aircraftId || null;
// Enhanced: boomControlStatus bit 0 = boom on/off. Short: Numeric value: 0 - Boom Off, 2 - Boom On
const sprayStat = (positionRecord.isEnhanced ? (positionRecord.boomControlStatus & 0x01)
: (positionRecord.flags == 2)) ? 1 : 0;
const appDetail = {
// Context data
fileId: fileContext.fileId,
// GPS/Position data from Position record (Type 1) - mapped from CSV
gpsTime: positionRecord.timestamp ?
(() => {
// SatLoc timestamp is local time - create local moment from components, then convert to UTC
const utcMoment = moment.utc({
year: positionRecord.timestamp.year,
month: positionRecord.timestamp.month - 1, // moment expects 0-indexed month
date: positionRecord.timestamp.day,
hour: positionRecord.timestamp.hour,
minute: positionRecord.timestamp.minute,
second: positionRecord.timestamp.seconds,
millisecond: positionRecord.timestamp.milliseconds
}).subtract(currentSystemSetup?.gmtOffset || 0, 'minutes'); // Adjust for GMT offset to get UTC time
// Preserve millisecond precision like job worker does
// Calculate total seconds including milliseconds (similar to utils.timeToSeconds + fixedTo)
const totalSeconds = utcMoment.unix() + (utcMoment.milliseconds() / 1000);
return fixedTo(totalSeconds, 3); // 3 decimal places for millisecond precision, mostly centisecond only
})() : 0,
lat: positionRecord.lat || 0, // lat -> lat
lon: positionRecord.lon || 0, // lon -> lon
tslu: positionRecord.differentialAge || 0, // differentialAge -> tslu (Time since last update)
xTrack: positionRecord.xTrack || 0, // xTrack -> xTrack
grSpeed: positionRecord.speed, // fixedTo(positionRecord.speed || 0, 2), // speed -> grSpeed (2 decimal places)
alt: positionRecord.altitude || 0, // altitude -> alt
// gpsAlt: positionRecord.altitude || 0, // altitude -> gpsAlt (same source, used for AGNAV RPM pkg only)
sprayStat: sprayStat,
head: positionRecord.track, // fixedTo(positionRecord.track || 0, 1), // track -> head (heading in degrees, 1 decimal place)
// Swath data - swath width from Swathing Setup record (Type 120)
swath: currentSwathing?.swathWidth, // fixedTo(currentSwathing?.swathWidth || 0, 1), // swathWidth -> swath (1 decimal place)
// GPS quality data from GPS record (Type 10)
satCount: currentGPS?.satellitesTracked || 0, // satellitesTracked -> satCount
// Flow data prioritized from Enhanced Position (1)-> Target Rates (32)-> Flow Monitor (30)-> Flow Setup (140)
// Get all application and target rates
...this.getFlowRates(positionRecord, currentFlow, currentFlowSetup, currentAppliedRate, currentTargetRate, currentSwathing?.swathWidth),
// Controller type from Controller Type By Name record (Type 46)
// sens: currentControllerType?.controllerType || '', // controllerType -> sens (string)
// Environmental data from Wind record (Type 50)
windSpd: currentWind?.windSpeed || 0, // windSpeed -> windSpd
windDir: currentWind?.windDirection || 0, // windDirection -> windDir
// Environmental data from Environmental record (Type 110)
temp: currentEnvironmental?.temperature || 0, // temperature -> temp
humid: fixedTo(currentEnvironmental?.relativeHumidity || 0, 0), // humidity -> humid (0 decimal places)
// Convert kPsc to Psi (1 kPsc = 0.14503773773 Psi)
baroPsi: (currentEnvironmental?.barometricPressure || 0) * 0.14503773773, // barometricPressure -> baroPsi
// Valve position from Enhanced Position (Priority 1) or Flow Monitor (Priority 2)
valvePos: positionRecord.valvePosition || currentFlow?.valvePosition || 0, // valvePosition -> valvePos
// Laser altimeter data from Laser Altimeter record (Type 42)
raserAlt: currentLaser?.laserAltitude || 0, // laserAltitude -> raserAlt
// System pressure from IF2 Liquid BOOM Pressure (Type 47) or fallback to position record pressure
// (1 Lbs = 1 Psi for pressure)
psi: (currentPressure ? currentPressure?.primaryPressure : currentPressure?.dualPressure) || 0, // if2LiqPriBoomPressure -> psi
// System setup data from System Setup record (Type 100)
// Note: sprayWidth is NOT in the SatLoc spec for Type 100, would need to come from another source
gmtOffset: currentSystemSetup?.gmtOffset || 0, // gmtOffset -> gmtOffset
// Tach data from Tach Times record (Type 45)
tachSec: currentTach?.totalTachCurrentTime || 0, // totalTachCurrentTime -> tachSec
tachTotalSec: currentTach?.totalTachTotalTime || 0, // totalTachTotalTime -> tachTotalSec
// AgDisp data from AgDisp Data record (Type 43)
windOffsetDir: currentAgdisp?.windOffsetDirection || 0, // windOffsetDirection -> windOffsetDir
appWindOffset: currentAgdisp?.appliedOffsetInMeters || 0, // appliedOffsetInMeters -> appWindOffset
// Fields not available in SatLoc or not yet mapped (marked as n/a in CSV)
llnum: 0, // Lock/Spray line (not available)
timeAdv: 0, // Time advance for GPS & system lag compensation (not available)
utmX: 0, // UTM X coordinate (would need conversion from lat/lon)
utmY: 0, // UTM Y coordinate (would need conversion from lat/lon)
noAC: 0, // Number of aircraft (not available)
stdHdop: currentGPS?.hdop || 0,
satsIn: currentGPS?.satellitesUsed || 0, // satellites used -> satsIn
calcodeFreq: 0, // Calibration code for spray offset (not available)
sprayHeight: 0, // Spray height from laser altimeter (not available)
radarAlt: 0, // Radar altitude (not available)
// Additional fields that may be used by ApplicationDetail schema
driftX: 0,
driftY: 0,
depositX: 0,
depositY: 0,
applicRate: currentAppliedRate?.channels?.[0]?.appliedRate || 0, // Applied rate from Type 36 record
rpm: [],
weight: 0,
// From SatLoc GPS (Type 10) or GPS Status Extended (Type 11)
gdop: currentGPS?.gdop || 0,
};
return appDetail;
}
/**
* Get both application and target flow rates with combined prioritization logic
* Returns all available rate units for flexibility with liquid/solid materials
* @param {Object} positionRecord - Position record (may be enhanced)
* @param {Object} currentFlow - Current Flow Monitor record (Type 30)
* @param {Object} currentFlowSetup - Current Flow Setup record (Type 140)
* @param {Object} currentAppliedRate - Current Applied Rates record (Type 36)
* @param {Object} currentTargetRate - Current Target Application Rates record (Type 32)
* @param {Number} swathWidth - Current Swath width in meters
* @returns {Object} Object with all rate fields (lminApp, lhaApp, lminReq, lhaReq)
*/
getFlowRates(positionRecord, currentFlow, currentFlowSetup, currentAppliedRate, currentTargetRate, swathWidth) {
const rates = {
lminApp: 0, // L/min application rate for liquids
// lhaApp: 0, // L/ha application rate for liquids
lminReq: 0, // L/min target rate for liquids
lhaReq: 0, // L/ha target rate for liquids
};
// PRIORITY 1: Enhanced Position record (most accurate, real-time)
if (positionRecord.isEnhanced) {
// Application rates from enhanced position
if (positionRecord.flowRateLmin !== undefined) {
rates.lminApp = positionRecord.flowRateLmin;
}
// if (positionRecord.flowRateLha !== undefined) {
// rates.lhaApp = positionRecord.flowRateLha;
// }
// Target rates from enhanced position
if (positionRecord.targetFlowRateLmin !== undefined) {
rates.lminReq = positionRecord.targetFlowRateLmin;
}
if (positionRecord.targetFlowRateLha !== undefined) {
rates.lhaReq = positionRecord.targetFlowRateLha;
}
// Enhanced position has both app and target data, return it now
if (positionRecord.flags === 2) { // Boom is ON
return rates;
} // Else pass down because there is rates data when boom is OFF
}
// PRIORITY 2: Specific rate records for missing values
// Target Application Rates (Type 32)
if (currentTargetRate) {
// Target rates - Target Application Rates (Type 32) - always in L/min per specification
if (rates.lminReq === 0 && currentTargetRate?.targetRate !== undefined) {
rates.lminReq = currentTargetRate.targetRate; // Already in L/min according to specification
}
}
// Applied Rates (Type 36)
if (currentAppliedRate?.channels && currentAppliedRate.channels.length > 0) {
// Use first channel from Applied Rates record
const firstChannel = currentAppliedRate.channels[0];
rates.lminApp = firstChannel.appliedRate; // Assuming L/min for now
}
// PRIORITY 3: Flow Setup (Type 140) fallback for any missing values for target rates
if (currentFlowSetup) {
if (currentFlowSetup.applicationRate !== undefined) {
if (rates.lminReq === 0) {
rates.lminReq = currentFlowSetup.applicationRate;
}
}
if (currentFlowSetup.applicationPerArea !== undefined) {
if (rates.lhaReq === 0) {
rates.lhaReq = currentFlowSetup.applicationPerArea;
}
}
}
// NOTES: When Controller is ON, if no applied rates found => fallback to Flow Monitor (Type 30) then target rate flow rate
// This applied for old SatLoc files without enhanced position records from LEGACY (old) systems like Bantam
if (!positionRecord.isEnhanced && positionRecord.flags === 2 && rates.lminApp === 0) {
if (currentFlow && currentFlow?.flowRate) {
rates.lminApp = currentFlow.flowRate; // flowRate is in L/min or Kg/min
} else {
rates.lminApp = rates.lminReq; // Use target flow rate
if (!rates.lminApp && rates.lhaReq && currentFlowSetup && swathWidth) {
// Fallback: convert per-area rate to per-minute rate using ground speed and swath width
rates.lminApp = this.convertPerAreaToPerMinute(rates.lhaReq, positionRecord.speed, swathWidth,
currentFlowSetup.flowControlStatus.dry ? FCTypes.DRY : FCTypes.LIQUID);
}
}
if (!rates.lminReq && rates.lminApp) {
rates.lminReq = rates.lminApp; // Edgecase: ensure target rate is at least equal to applied rate
}
}
return rates;
}
/**
* Convert application rate from per-area to per-minute
* Supports both liquid (L/ha -> L/min) and solid/dry (Kg/ha -> Kg/min) materials
*
* @param {number} ratePerHa - Application rate per hectare (L/ha or Kg/ha)
* @param {number} groundSpeedMs - Ground speed in meters per second
* @param {number} swathWidthM - Swath width in meters
* @param {string} materialType - Material type: FCTypes.LIQUID or FCTypes.DRY
* @returns {number} Application rate per minute (L/min or Kg/min)
*/
convertPerAreaToPerMinute(ratePerHa, groundSpeedMs, swathWidthM, materialType) {
// Validate inputs
if (!ratePerHa || ratePerHa <= 0) {
return 0;
}
if (!groundSpeedMs || groundSpeedMs <= 0) {
if (this.options.verbose) {
this.logger.debug('Cannot convert per-area to per-minute rate: missing or invalid ground speed');
}
return 0;
}
if (!swathWidthM || swathWidthM <= 0) {
if (this.options.verbose) {
this.logger.debug('Cannot convert per-area to per-minute rate: missing or invalid swath width');
}
return 0;
}
// Calculate area covered per minute in hectares
// Formula: area_ha/min = (swath_width_m × ground_speed_m/s × 60_seconds) / 10000_m²/ha
const areaCoveredPerMinHa = (swathWidthM * groundSpeedMs * 60) / 10000;
// Calculate flow rate per minute
// For liquid: L/min = L/ha × ha/min
// For dry: Kg/min = Kg/ha × ha/min
const ratePerMin = ratePerHa * areaCoveredPerMinHa;
return ratePerMin;
}
/**
* Batch insert application details to database
*/
async saveApplicationDetails(applicationDetails, options = {}) {
if (!applicationDetails || applicationDetails.length === 0) {
return { inserted: 0 };
}
const batchSize = options.batchSize || this.options.batchSize;
let totalInserted = 0;
for (let i = 0; i < applicationDetails.length; i += batchSize) {
const batch = applicationDetails.slice(i, i + batchSize);
try {
const result = await ApplicationDetail.insertMany(batch, {
ordered: false,
lean: true
});
totalInserted += result.length;
this.logger.info({ batchNumber: Math.floor(i / batchSize) + 1, recordCount: result.length }, `Inserted batch ${Math.floor(i / batchSize) + 1}: ${result.length} records`);
} catch (error) {
this.logger.error({ error: error.message, batchNumber: Math.floor(i / batchSize) + 1 }, `Error inserting batch: ${error.message}`);
// Continue with next batch on error
}
}
return { inserted: totalInserted };
}
/**
* Get parsing statistics
*/
getStatistics() {
return { ...this.statistics };
}
}
module.exports = { SatLocLogParser, RECORD_TYPES };