agmission/server/docs/AGGREGATED_FIELDS_CALCULATION.md

317 lines
15 KiB
Markdown
Raw Permalink 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.

# Aggregated Fields Calculation Reference
This document explains how all aggregated metric fields on the `Application` (`applications`) and `AppFile` (`appfiles`) collections are calculated, and which code paths produce them.
---
## Collections and Fields
### Application (`applications`)
| Field | Unit | Description |
|---|---|---|
| `totalSprayed` | Hectares | Total area covered with spray ON |
| `totalSprLength` | Meters | Total path distance during spray-ON periods |
| `totalFlightLength` | Meters | Total flight path distance (spray ON + turns) |
| `totalSprayTime` | Seconds | Total time with spray ON |
| `totalTurnTime` | Seconds | Total turning time between spray lines |
| `totalFlightTime` | Seconds | Total flight time (all GPS intervals) |
| `totalSprayMat` | L or Kg | Total material dispensed |
| `totalSprayMatUnit` | Code | Rate unit: `3` = L/ha, `4` = Kg/ha |
| `avgSpraySpeed` | m/s | Average ground speed during spray-ON periods |
| `avgXtError` | m | Average absolute cross-track error across spray-on records (stat 1 & 3), valid segments only; `null` when the device firmware does not record xTrack (all binary values are 0) |
| `avgHdop` | — | Average HDOP across spray-ON records; lower = better (< 1 excellent, 12 good, > 5 poor) |
| `flowAccuracyPct` | % | Flow control accuracy: `(totalSprayMat / totalSprayed / appRate) × 100`. `null` when any source field is zero/absent |
| `appRate` | L/ha or Kg/ha | Average application rate recorded from data |
| `startDateTime` | String `YYYYMMDDTHHmmss` | Timestamp of the first GPS record |
| `endDateTime` | String `YYYYMMDDTHHmmss` | Timestamp of the last GPS record |
### AppFile (`appfiles`)
Mirrors the same set of aggregated fields as `Application`, computed per individual data file within the upload archive. Fields: `totalSprayed`, `totalSprLength`, `totalFlightLength`, `totalSprayTime`, `totalTurnTime`, `totalFlightTime`, `totalSprayMat`, `totalSprayMatUnit`.
### ApplicationDetail (`application_details`)
Stores the raw GPS + sensor records parsed from each data file. These are the source rows from which all aggregated fields above are derived. Key fields used in aggregation: `gpsTime`, `utmX`, `utmY`, `swath`, `sprayStat`, `llnum`, `grSpeed`, `lhaReq`, `lhaApp`, `lminApp`, `xTrack`, `sprayHeight`, `radarAlt`.
### Job (`jobs`)
| Field | Unit | Description |
|---|---|---|
| `ttSprArea` | Hectares | Planned spray area minus exclusion zones (from GeoJSON polygons, not from flight data) |
---
## Code Paths
There are two independent processing pipelines that calculate these fields:
```
Upload (AgNav binary / Shape)
└─► job_worker.js ──► importData()
└─► importDataFiles() (per file)
├─► readNTFile() (AgNav binary .nt files)
└─► readShapeDataFile() (ESRI Shape .dbf files)
Partner sync (SatLoc partner logs)
└─► partner_sync_worker.js
└─► satloc_application_processor.js ──► processJobGroup()
```
---
## Pipeline 1 — AgNav Binary / Shape Files (`job_worker.js`)
### Entry point: `importData()` (`workers/job_worker.js` line 943)
Orchestrates the full import for one uploaded archive. Steps:
1. Scans the unzipped folder for data files matching known patterns.
2. Classifies files: `FILE.DATA_AGNAV` (`.nt` binary), `FILE.DATA_SHAPE` (`.dbf` shape), or legacy `FILE.DATA_SALOG` (`.asc` ASCII — no longer processed).
3. Sorts files by AGN timestamp prefix to process them in chronological order.
4. Calls `importDataFiles()` for each file/pair and accumulates the per-file sub-totals.
5. After all files are processed, computes the final `appData` object:
```javascript
appRate = mean(avgRates[]) // average of per-file average rates
totalSprayed = sum(data.totalSprayed) // m² converted to ha at the Application level
totalSprLength = sum(data.totalSprLength)
totalFlightLength = sum(data.totalFlightLength)
totalSprayTime = sum(data.sprayTime)
totalTurnTime = sum(data.turnTime)
totalFlightTime = sum(data.totalTime)
totalSprayMat = sum(data.totalSprayMat)
avgSpraySpeed = weightedMean(avgSpraySpeed * spraySpeedCount)
avgHdop = weightedMean(hdopSum across files) / totalHdopCount
```
6. Writes the computed totals to the `Application` document (with `m² → ha` unit conversion for `totalSprayed`).
7. After all Application fields are set, `work()` computes `flowAccuracyPct = (totalSprayMat / totalSprayed / appRate) × 100` when all three are positive, and writes it to Application.
### Per-file processing: `importDataFiles()` (line 1108)
For each data file (or spray-on/spray-off pair):
1. Reads the companion `q*` metadata file to obtain configured application rate, rate unit, and flow controller type.
2. Creates an `AppFile` document.
3. Calls `readNTFile()` or `readShapeDataFile()` to get per-record data and per-file totals.
4. Inserts all `ApplicationDetail` records into MongoDB in 1,000-record batches.
5. Iterates the sorted record array to compute **time-based and point-metric fields** (`totalFlightTime`, `totalSprayTime`, `totalTurnTime`, `avgSpraySpeed`, `avgHdop`, `avgXtError`) — this loop runs over the stored `ApplicationDetail` records after all files in the pair are merged and sorted.
6. Saves the per-file totals to the `AppFile` document.
#### Time calculations (loop in `importDataFiles`, line ~1310)
```
totalFlightTime += timeDif where 0 < timeDif ≤ 120 s (between every consecutive GPS record)
totalSprayTime += timeDif where 0 < timeDif ≤ 120 s (only when sprayStat > 0)
turnTime: counted from spray-OFF on one line number to spray-ON on the next line number
turnTime += timeDif where 5 ≤ timeDif ≤ 120 s
avgSpraySpeed = sum(grSpeed) / count (all spray-ON records except sprayStat === 3)
avgHdop = sum(stdHdop) / count (all spray-ON records where stdHdop > 0)
avgXtError = sum(|xTrack|) / count (spray-ON records where sprayStat ∈ {1, 3} and xTrack ≠ 0)
null when the device firmware does not record xTrack (all binary values are 0)
AppDetail.xTrack unit: metres for all file types.
- AgNav binary (.nt) — decoded from raw cm integer at parse time (÷ 100 in _readAgnBinary / _readAmsRpm DRY)
- SatLoc ASCII (.asc/.log) — X-Track field is natively in metres; no conversion applied
- AgNav Shape (.shp) — XTRACK field is natively in metres; no conversion applied
Application.avgXtError is therefore in metres for all application types.
No further conversion is applied in the dashboard API or migration.
```
The 120-second cap on time differences rejects GPS dropouts or large gaps between segments.
Turn time is line-number-aware: only the gap between the end of one spray line and the start of the **next** line number qualifies.
### AgNav binary reader: `readNTFile()` (line 1423)
Reads the raw AgNav binary packet stream (fixed-size `FILE.AGN_PACK_SIZE` packets).
**Spray area and material per binary record:**
```
sprayStat 3 → "start of line" marker — updates prevUTM_X/Y, prevSwath, prevLine but does NOT accumulate area
sprayStat 1 or 2 (spray ON):
if prevStat > 0 AND prevLine === record.llnum (same spray line):
sprayedSeg = hypot(utmX - prevUTM_X, utmY - prevUTM_Y) × prevSwath [m²]
totalSprays += sprayedSeg
totalSprayMat += (sprayedSeg × SM2HA) × appliedRate
appliedRate priority:
1. Q-file configured rate (converted to metric L/ha or Kg/ha)
2. Fallback to record.lminApp → converted via appRateFromFlowRate()
3. Fallback to record.lhaReq
appRate = mean(lhaReq across all spray-ON records)
```
Area accumulates only within a single spray line (`prevLine === record.llnum`), preventing cross-line area double-counting.
### Shape file reader: `readShapeDataFile()` (line 1598)
Reads spray-on DBF attributes. Area and material logic is identical to `readNTFile()` except:
- Spray-off file contributes only timing records (no area).
- There is no "start-of-line" `sprayStat 3` marker — the same-line guard uses `prevLine === record.llnum`.
### Distance computation — inline streaming
`totalSprLength` and `totalFlightLength` are computed **incrementally during the file-read loop** in `readNTFile()`, `readShapeDataFile()`, and `readSatLogAsc()`. No separate rescan of the record array is needed.
For multi-file shape uploads (spray-on + spray-off pair merged into one `importInfo`), a cross-file boundary segment is also computed once when the two record sets are joined.
Validity gates applied to **every** consecutive pair:
| Gate | Threshold | Reason |
|---|---|---|
| Time gap | `0 < dt ≤ 120 s` | Skip GPS dropouts, instrument pauses, file boundaries |
| Distance | `dist ≤ 1000 m` | Reject GPS position outliers |
| Spray status (sprLength only) | `prev.sprayStat > 0 \|\| curr.sprayStat > 0` | Skip pure turn/off segments |
Midnight rollover (`gpsTime` is seconds-of-day) is handled in every loop: if `dt < 0` and `|dt| ≥ 80000`, then `dt = 86400 prevTime + currTime`.
```javascript
// Pseudocode (applied inside readNTFile / readShapeDataFile)
for each record after timeOffset adjustment:
dt = record.gpsTime - prevRecTime // handle midnight rollover
if (dt > 0 && dt <= 120):
segDist = hypot(record.utmX - prev.utmX, record.utmY - prev.utmY)
if (segDist <= 1000):
totalFlightLen += segDist
if (prevSprStat > 0 || record.sprayStat > 0):
totalSprLen += segDist
```
`_computeFlightLength()` and `_computeSprLength()` remain as **fallback helpers** (used only when inline totals are not available, e.g. from the migration script). They apply the same two-gate logic:
```javascript
// _computeFlightLength() fallback — all GPS pairs, dt ≤ 120 s AND dist ≤ 1000 m
// _computeSprLength() fallback — spray-ON pairs, dt ≤ 120 s AND dist ≤ 1000 m
```
---
## Pipeline 2 — SatLoc Partner Logs (`satloc_application_processor.js`)
### Entry point: `processJobGroup()` (`helpers/satloc_application_processor.js` line ~157)
Called by `partner_sync_worker.js` after `satloc_log_parser.js` has parsed the binary SatLoc log into `ApplicationDetail`-compatible records.
**UTM conversion** (per record, using `@mickeyjohn/geodesy/utm.js`):
```javascript
{ easting: utmX, northing: utmY } = LatLon(lat, lon).toUtm(zone, hemisphere)
```
**Flight time** (same 120 s cap as job_worker):
```javascript
if (0 < timeDif MAX_TIME_DIFF) // MAX_TIME_DIFF = 120 s
totalFlightTime += timeDif
```
**Spray time:**
```javascript
if (prevSprayStat > 0 AND curSprayStat > 0 AND 0 < timeDif 120)
totalSprayTime += timeDif
```
**Spray area and material** (triggered while `curSprayStat > 0` and `prevSprayStat > 0`):
```
distance = hypot(utmX - prevUTM_X, utmY - prevUTM_Y) [m]
swathArea = distance × record.swath [m²]
totalSprayed += swathArea
totalSprayLength += distance
appRate = record.lhaApp || record.lhaReq
totalSprayMat += (swathArea × appRate) / 10000 [L or Kg]
```
Note: `prevUTM_X/Y` is updated **only** while spray is ON, so the distance for area never bridges a spray-OFF gap.
**Unit conversion after the loop:**
```javascript
totalSprayed = totalSprayed × 1E-4 // m² → hectares
```
**Spray segments** are tracked in addition for map rendering: each continuous spray-ON run is stored as a `{ startTime, endTime, startLat/Lon, endLat/Lon, distance, area, points[] }` segment object.
**Material unit** is determined from SatLoc flow controller type:
```javascript
sprayMatUnit = (fcType === FCTypes.LIQUID) ? RateUnits.LIT_PER_HA : RateUnits.KG_PER_HA
```
**Database writes:**
```javascript
ApplicationFile.updateOne({ _id: appFile._id }, { $set: {
totalSprLength, totalSprayTime, totalFlightTime,
totalSprayed, totalSprayMat, totalSprayMatUnit
}});
Application.updateOne({ _id: application._id }, { $set: {
status: AppStatus.DONE,
totalSprayTime, totalFlightTime, totalSprayed,
totalSprayMat, totalSprayMatUnit, totalSprLength,
appRate: 0, // Not yet calculated for SatLoc path
startDateTime, endDateTime
}});
```
---
## Job Planned Spray Area (`job_worker.js` + `job_util.js`)
`Job.ttSprArea` is **not** derived from flight data — it is calculated from the GeoJSON spray-area polygons drawn by the operator when the job is created or updated.
**`jobUtil.calcTTSprayAreas(sprayAreas, excludedAreas)`** (`helpers/job_util.js` line 117):
```
for each sprayArea polygon:
realArea = turf.area(sprayArea) // m²
for each exclusionZone that intersects this polygon (R-tree spatial index):
realArea -= turf.area( turf.intersect(sprayArea, exclusionZone) )
ttSprArea += realArea
job.ttSprArea = calcTTSprayAreas(...) × SM2HA // m² → ha
```
This is recalculated every time a job's spray areas or exclusion zones change.
---
## Unit Conversion Constants
| Constant | Value | Purpose |
|---|---|---|
| `1E-4` | `0.0001` | m² → hectares |
| `CVCST.SM2HA` | `1E-4` | Same as above (used for material calculation) |
| `CVCST.SM2ACR` | `0.000247105` | m² → acres (subscription limit checks) |
| Max time gap | `120 s` | Outlier rejection for all time accumulators |
| Max GPS segment | `1000 m` | Outlier rejection in all distance loops (inline + `_computeFlightLength` / `_computeSprLength` fallbacks) |
| Max time gap (distance) | `120 s` | Skip GPS dropouts in all distance loops — same cap used for flight/spray time accumulators |
---
## Summary: Which Code Sets Which Field
| Field | AgNav binary | Shape | SatLoc partner | Notes |
|---|---|---|---|---|
| `totalSprayed` | `readNTFile``importData` | `readShapeDataFile``importData` | `processJobGroup` | m² accumulated, converted × 1E-4 to ha before DB write |
| `totalSprLength` | inline in `readNTFile` (fallback: `_computeSprLength`) | inline in `readShapeDataFile` (fallback: `_computeSprLength`) | loop in `processJobGroup` | spray-ON segments, dt ≤ 120 s, dist ≤ 1000 m |
| `totalFlightLength` | inline in `readNTFile` (fallback: `_computeFlightLength`) | inline in `readShapeDataFile` (fallback: `_computeFlightLength`) | not computed | all GPS segments, dt ≤ 120 s, dist ≤ 1000 m |
| `totalSprayTime` | loop in `importDataFiles` | loop in `importDataFiles` | loop in `processJobGroup` | 120 s cap |
| `totalTurnTime` | loop in `importDataFiles` | loop in `importDataFiles` | not computed | line-number-aware |
| `totalFlightTime` | loop in `importDataFiles` | loop in `importDataFiles` | loop in `processJobGroup` | 120 s cap |
| `totalSprayMat` | `readNTFile` | `readShapeDataFile` | `processJobGroup` | L or Kg depending on unit |
| `totalSprayMatUnit` | from Q-file or record | from Q-file or record | from SatLoc `fcType` | 3=L/ha, 4=Kg/ha |
| `avgSpraySpeed` | loop in `importDataFiles` | loop in `importDataFiles` | `processJobGroup` | spray-ON records |
| `avgXtError` | loop in `importDataFiles` (fallback: `migrate_applications.js`) | loop in `importDataFiles` (fallback: `migrate_applications.js`) | `migrate_applications.js` | spray-ON (stat 1 & 3), xTrack ≠ 0 |
| `avgHdop` | loop in `importDataFiles` | loop in `importDataFiles` | not computed | spray-ON records, stdHdop > 0; backfilled by migration |
| `flowAccuracyPct` | `work()` post-field-set | `work()` post-field-set | not computed | (totalSprayMat/totalSprayed/appRate)×100; backfilled by migration |
| `avgSpraySpeed` | loop in `importDataFiles` | loop in `importDataFiles` | not computed | mean `grSpeed` while spray ON |
| `appRate` | mean `lhaReq` in `readNTFile` | mean `lhaReq` in `readShapeDataFile` | not computed (set to 0) | |
| `ttSprArea` (Job) | `calcTTSprayAreas` | `calcTTSprayAreas` | `calcTTSprayAreas` | from planned GeoJSON polygons, not flight data |