'use strict'; /** * Advanced Report analytics engine (D1 — ADVANCED_REPORTS_IMPLEMENTATION_PLAN.md §3). * * Pure computation over ApplicationDetail point streams: no HTTP, no Mongo, no * Puppeteer (NFR-6.3). The caller streams points in stored file order (gpsTime is * seconds-of-day and can wrap past midnight, so records are never re-sorted — same * convention as controllers/job.js getAppDataByJobId) and reads back per-line, * per-zone and mission aggregates computed in that single pass (NFR-1.2, NFR-3.3). * * All results are numeric and metric (meters, seconds, liters, m/s); display * formatting/localization belongs to the datasource builder, not this module. */ const turf = require('@turf/turf'), geoUtil = require('./geo_util'), utils = require('./utils'); // Spray-on records: 1 = spray on (inside the mapped zone), 3 = spray segment START marker, // 10 = spray on but OUTSIDE the mapped zone boundary (docs/DATA_EXPORT_API_DESIGN.md). // Whether out-of-zone spray (10) counts toward coverage/area is a per-user "Spray Coverage: // All/Inside" preference (Setting.sprayPath.dataOp — 0=All, 1=Inside; today only wired into // the map editor's drawing endpoint, controllers/job.js getData_post). createMissionAnalytics's // includeOutOfZoneSpray option lets the caller mirror that preference here; the actual // Set is built per-call inside the function, not as a shared module-level constant, since // it now depends on that option. // Consecutive points further apart than this are a data gap, not travel // (getSprayOnSegments breaks spray segments at the same 1 km jump) const GAP_KM = 1; // Valid turn duration window in seconds (workers/job_worker.js turn-time loop) const TURN_MIN_S = 5, TURN_MAX_S = 120; // Max gap between consecutive points still counted as flight time — same threshold as // TURN_MAX_S today, but a conceptually distinct cap (workers/job_worker.js:1447-1455's // totalFlightTime loop), kept as its own named constant rather than reusing TURN_MAX_S const FLIGHT_GAP_MAX_S = 120; // Seconds-of-day wrap guard (workers/job_worker.js: negative diffs >= 80000 are midnight wraps) const DAY_S = 86400, WRAP_GUARD_S = 80000; // Max points sampled per pass for point-in-polygon zone assignment const PIP_SAMPLES = 25; // A line whose nearest zone center is still farther than this is not a boundary-straddle // or GPS-drift case — it's unrelated data (wrong file, GPS fault, mixed-in test data) that // doesn't belong to any zone in this job. Generous on purpose (a single ag mission's zones // are normally within a few km of each other; this only rejects genuinely implausible // matches, e.g. a flight recorded hundreds of km away) const NEAREST_ZONE_MAX_KM = 50; // Equipment quirk: the very first line of a file can be logged with llnum 65535 // (2^16-1, the max value of an unsigned 16-bit integer) instead of a real line // number — no real mission has anywhere near 65535 flight lines, so this is always // the sentinel/underflow artifact, never a legitimate count. Confirmed on real data // (Job #90): the file's other line numbers run 2, 3, 4...37 with no "1" anywhere, // and 65535 sits chronologically exactly where line 1 belongs. const LLNUM_SENTINEL = 65535; /** Seconds-of-day difference t2 - t1, corrected across the midnight wrap */ function todDiff(t2, t1) { let d = t2 - t1; if (d < 0 && Math.abs(d) >= WRAP_GUARD_S) d = (DAY_S - t1) + t2; return d; } /** Planned area of a spray zone in m², net of any intersecting exclusion zones — * mirrors jobUtil.calcTTSprayAreas so this matches the job's own Job.ttSprArea figure. * Falls back to the stored properties.area (sprayArea.properties.area is absent in * much live data) only when there are no exclusion zones to net out — a stored area * predates any excludedAreas subtraction and can't be trusted once there's overlap to * remove. */ function plannedAreaM2(zone, excludedAreas = []) { if (!excludedAreas.length && zone.properties && zone.properties.area > 0) return zone.properties.area; try { const feature = { type: 'Feature', properties: {}, geometry: zone.geometry }; let area = turf.area(feature); for (const xcl of excludedAreas) { const diff = turf.intersect(feature, { type: 'Feature', properties: {}, geometry: xcl.geometry }); if (diff) area -= turf.area(diff); } return area; } catch (err) { return 0; } } /** Single point-in-polygon zone lookup (first matching zone wins, same as the sampled * callers below) — -1 when the point falls inside none of them. */ function zoneOfPoint(p, zoneFeatures) { const xy = [p.lon, p.lat]; for (let z = 0; z < zoneFeatures.length; z++) if (turf.booleanPointInPolygon(xy, zoneFeatures[z])) return z; return -1; } /** Shared sampling pass behind majorityZone/straddlesMultipleZones — walks up to * PIP_SAMPLES points at a stride and returns per-zone hit counts, so both callers * do exactly one point-in-polygon sweep instead of two. */ function sampleZoneCounts(points, zoneFeatures) { const stride = Math.max(1, Math.floor(points.length / PIP_SAMPLES)); const counts = new Array(zoneFeatures.length).fill(0); let sampled = 0; for (let i = 0; i < points.length; i += stride) { sampled++; const z = zoneOfPoint(points[i], zoneFeatures); if (z >= 0) counts[z]++; } return { counts, sampled }; } /** Majority point-in-polygon zone index for a pass; -1 when no sampled point is inside * any zone (FR-5.2 — straddling lines go to the zone holding most of their points) */ function majorityZone(points, zoneFeatures) { const { counts, sampled } = sampleZoneCounts(points, zoneFeatures); let best = -1, bestCount = 0; for (let z = 0; z < zoneFeatures.length; z++) if (counts[z] > bestCount) { best = z; bestCount = counts[z]; } return sampled ? best : -1; } /** Cheap pre-check (same sample as majorityZone, no extra PIP work): does this pass's * sample touch more than one zone at all? Only passes that do pay for the full, * unsampled per-point split below (FR-5.2 refinement) — a pass that's cleanly inside * one zone costs exactly what it did before this refinement. */ function straddlesMultipleZones(points, zoneFeatures) { const { counts } = sampleZoneCounts(points, zoneFeatures); return counts.filter(c => c > 0).length > 1; } /** Splits a pass into one segment per zone it actually crosses, instead of handing the * whole pass to whichever zone the sampled majority favors (FR-5.2 refinement — a small * zone next to a much bigger one was otherwise losing coverage credit for genuinely-its * passes to the bigger neighbor's majority vote). Only called for passes already flagged * by straddlesMultipleZones, so every point gets a real (unsampled) zone lookup here — * that cost is bounded to the minority of passes that actually straddle a boundary. * * A point that itself resolves to no zone (-1 — e.g. sitting exactly on a shared * boundary edge) rides along with whichever run is already open rather than forcing a * spurious extra split; a run only breaks when a point resolves to a DIFFERENT real * zone than the run in progress. A run that never finds a real zone anywhere in it * (all -1) falls back to nearestZone, same as the non-split path's own fallback. * * Documented trade-off: the single GPS interval that actually crosses the boundary * (the edge from one run's last point to the next run's first point) isn't counted in * either segment's length/area/volume — splitting that one edge's distance between two * zones would add real complexity for a per-crossing discrepancy of one GPS interval * (a few meters), which nets out as negligible against a whole mission's totals. */ function splitPassByZone(pass, zoneFeatures, zoneCenters, computeSegmentStats) { const pts = pass.points; const runs = []; let curZone = zoneOfPoint(pts[0], zoneFeatures); let runStart = 0; for (let i = 1; i < pts.length; i++) { const z = zoneOfPoint(pts[i], zoneFeatures); if (z >= 0 && z !== curZone) { runs.push({ zoneIdx: curZone, startIdx: runStart, endIdx: i - 1 }); runStart = i; curZone = z; } } runs.push({ zoneIdx: curZone, startIdx: runStart, endIdx: pts.length - 1 }); return runs.map(r => { const runPts = pts.slice(r.startIdx, r.endIdx + 1); const zoneIdx = r.zoneIdx >= 0 ? r.zoneIdx : nearestZone(runPts[0], zoneCenters); return Object.assign({ llnum: pass.llnum, zoneIdx, points: runPts }, computeSegmentStats(runPts)); }); } /** Nearest zone (by center, real great-circle km — not raw lat/lon degrees, which * under-counts longitude distance away from the equator) — fallback so a line that * straddles no zone at all still lands somewhere plausible. Returns -1 when even the * closest zone is farther than NEAREST_ZONE_MAX_KM: that's not a boundary-straddle or * GPS-drift case, it's unrelated data with no real zone to belong to (mission totals * then no longer include it — see the mission.unassigned summary in finish()) */ function nearestZone(point, zoneCenters) { let best = -1, bestKm = Infinity; for (let z = 0; z < zoneCenters.length; z++) { const km = geoUtil.distance([point.lat, point.lon], [zoneCenters[z][1], zoneCenters[z][0]]); if (km < bestKm) { bestKm = km; best = z; } } return bestKm <= NEAREST_ZONE_MAX_KM ? best : -1; } /** * Create a single-pass mission analytics accumulator. * * @param {Object} opts * @param {Array} opts.zones job.sprayAreas (GeoJSON-ish: { properties, geometry }) * @param {Number} opts.swathWidthM job swath width in meters — fallback when points carry no swath * @param {Array} opts.excludedAreas job.excludedAreas — netted out of each zone's planned area * @param {Boolean} opts.includeOutOfZoneSpray whether sprayStat=10 (spraying outside the mapped * zone) counts as spray-on for pass/area/distance/speed — mirrors the user's "Spray Coverage: * All/Inside" preference (Setting.sprayPath.dataOp: 0=All/1=Inside). Defaults to true ("All"), * matching that setting's own default. XT Error always stays restricted to sprayStat 1/3 * regardless of this option — see missionXtAcc/missionXtN below. * @returns {{ push: Function, fileBreak: Function, finish: Function, lineCount: Function }} * * Point fields consumed: lat, lon, gpsTime, llnum, sprayStat, grSpeed, xTrack, * sprayHeight, lminApp, swath. */ function createMissionAnalytics({ zones = [], swathWidthM = 0, collectDraw = false, excludedAreas = [], includeOutOfZoneSpray = true } = {}) { const SPRAY_ON = includeOutOfZoneSpray ? new Set([1, 3, 10]) : new Set([1, 3]); const zoneFeatures = zones.map(z => ({ type: 'Feature', properties: {}, geometry: z.geometry })); const zoneCenters = zoneFeatures.map(f => turf.getCoord(turf.center(f))); const passes = []; // contiguous spray-on runs let curPass = null; // whole-flight accumulators (all points, spray or not) let prev = null; // previous point (across spray state, within a file) let totalDistanceM = 0; let totalFlightS = 0; // flat, unweighted XT accumulator across every spray-on point in the mission — // deliberately NOT rolled up through the pass->line->zone->mission weighted-mean // chain (which weights by point-count then spray-time, and so doesn't equal a // simple per-reading average); matches the client playback's own avg-XT method // (job-map-edit.component.ts playXt: a flat running average of every spray-on // reading) so the mission KPI and the playback figure follow the same method. // Excludes exact-zero xTrack, same as the pass-level xtAcc/xtN below — verified // ~44% of readings are exactly 0 (the schema default for an unpopulated field, not // a real "dead on target" measurement), so including them would dilute the average // with likely-missing data rather than bring it closer to playback's own figure. // Always restricted to sprayStat 1/3, independent of includeOutOfZoneSpray/SPRAY_ON — // cross-track-error-from-line isn't meaningful once outside the mapped zone // (workers/job_worker.js:1478 draws the same distinction for the legacy calculation). // Accumulated here in push(), NOT inside the per-pass loop in finish(), because // endPass() silently discards single-point pass fragments (curPass.points.length > 1 // guard) — those fragments become more common once SPRAY_ON excludes 10 (a run of // spray-on-outside-area points can chop an otherwise-continuous pass into slivers), // and a flat "average of every reading" figure must not lose readings just because // they landed in a fragment too small to become a real pass. let missionXtAcc = 0, missionXtN = 0; // optional map-drawing geometry, built in the same pass so the report never // re-reads ApplicationDetail for the captures (NFR-1.2) const DRAW_STRIDE = 3; const flightSegs = []; // ferry/flight paths as [[lat, lon], ...] let curFlightSeg = null, flightPtCount = 0; // turn-time state machine (workers/job_worker.js:1486 pattern, plus an atFresh // guard: a gap only counts as a turn when off-travel was actually observed // between the two lines — a bare llnum change with no off records is a data // hole, not a measured turn) const turn = { line: null, at: null, nextOff: false, atFresh: false }; // { beforeLlnum, seconds, passIndex } — passIndex is the `passes` index of the pass this gap // immediately follows; zone isn't known yet at push() time (assigned later in finish()), so the // gap can't be keyed by zone+llnum until the pass it belongs to has been zone-assigned. Looking it // up by bare llnum alone would let the same llnum reused in a different zone steal this gap. const turnGaps = []; function endPass() { if (curPass && curPass.points.length > 1) passes.push(curPass); curPass = null; } function endFlightSeg() { if (curFlightSeg && curFlightSeg.length > 1) flightSegs.push(curFlightSeg); curFlightSeg = null; } function push(p) { // normalize the llnum sentinel before anything downstream (turn-time tracking, // pass segmentation, line keying/display) reads it if (p.llnum === LLNUM_SENTINEL) p.llnum = 1; // ---- whole-flight time & distance -------------------------------------- // matches the legacy convention exactly (workers/job_worker.js:1447-1455): sum // consecutive-point deltas, excluding any gap that's zero/negative or >120s entirely — // a long pause (refuel stop, GPS dropout) is not counted as flight time, unlike a plain // last-minus-first span which would silently include it let gapJump = false; if (prev) { const dt = todDiff(p.gpsTime, prev.gpsTime); if (dt > 0 && dt <= FLIGHT_GAP_MAX_S) totalFlightS += dt; const dKm = geoUtil.distance([prev.lat, prev.lon], [p.lat, p.lon]); if (dKm < GAP_KM) totalDistanceM += dKm * 1000; else gapJump = true; } // ---- flight-path drawing geometry --------------------------------------- if (collectDraw) { if (gapJump) endFlightSeg(); if (!curFlightSeg) curFlightSeg = []; if (flightPtCount % DRAW_STRIDE === 0) curFlightSeg.push([p.lat, p.lon]); flightPtCount++; } // ---- turn time between spray lines -------------------------------------- if (turn.line === null) { if (!SPRAY_ON.has(p.sprayStat)) { turn.line = p.llnum; turn.at = p.gpsTime; turn.atFresh = true; } } else if (turn.line !== p.llnum) { if (SPRAY_ON.has(p.sprayStat)) { if (turn.atFresh) { const gap = todDiff(p.gpsTime, turn.at); if (gap >= TURN_MIN_S && gap <= TURN_MAX_S) turnGaps.push({ beforeLlnum: turn.line, seconds: gap, passIndex: passes.length - 1 }); turn.atFresh = false; } turn.line = p.llnum; turn.nextOff = true; } } else { if (!SPRAY_ON.has(p.sprayStat) && turn.nextOff) { turn.at = p.gpsTime; turn.nextOff = false; turn.atFresh = true; } else if (SPRAY_ON.has(p.sprayStat)) turn.nextOff = true; } // ---- spray pass segmentation -------------------------------------------- if (SPRAY_ON.has(p.sprayStat)) { const jump = prev && geoUtil.distance([prev.lat, prev.lon], [p.lat, p.lon]) >= GAP_KM; if (curPass && (curPass.llnum !== p.llnum || p.sprayStat === 3 || jump)) endPass(); if (!curPass) curPass = { llnum: p.llnum, points: [] }; curPass.points.push(p); } else if (curPass) { endPass(); } // ---- mission-wide flat XT accumulator (see the declaration above for why this // lives here rather than in the per-pass loop in finish()) --------------------- if (utils.isNumber(p.xTrack) && p.xTrack !== 0 && (p.sprayStat === 1 || p.sprayStat === 3)) { missionXtAcc += Math.abs(p.xTrack); missionXtN++; } prev = p; } /** Call between files: file order is only guaranteed within a file */ function fileBreak() { endPass(); endFlightSeg(); prev = null; turn.line = null; turn.at = null; turn.nextOff = false; turn.atFresh = false; } function lineCount() { // upper bound used for the NFR-2.1 line limit while streaming return passes.length + (curPass ? 1 : 0); } function finish() { fileBreak(); // Per-point-array stats shared by both the non-split path and each zone segment a // straddling pass gets split into (DRY — this is the exact same computation that // used to run once per pass, unchanged in every respect other than being callable // per-segment too). function computeSegmentStats(pts) { const startT = pts[0].gpsTime; const endT = pts[pts.length - 1].gpsTime; const sprayS = todDiff(endT, startT); let lenM = 0, speedAcc = 0, speedN = 0, xtAcc = 0, xtN = 0, heightAcc = 0, heightN = 0, volumeL = 0, flowN = 0, swathAcc = 0, swathN = 0; for (let i = 0; i < pts.length; i++) { const p = pts[i]; if (i > 0) { lenM += geoUtil.distance([pts[i - 1].lat, pts[i - 1].lon], [p.lat, p.lon]) * 1000; const dt = todDiff(p.gpsTime, pts[i - 1].gpsTime); const flow = ((pts[i - 1].lminApp || 0) + (p.lminApp || 0)) / 2; // L/min across the interval // legacy caps every time-based accumulator at this same gap (AGGREGATED_FIELDS_ // CALCULATION.md: "Max time gap: 120s — outlier rejection for all time accumulators"); // without it, a stray timestamp gap with no matching distance jump (so the pass never // splits) would integrate flow across an unrealistically long, likely-bogus interval if (flow > 0 && dt > 0 && dt <= FLIGHT_GAP_MAX_S) { volumeL += flow * (dt / 60); flowN++; } } // sprayStat 3 (line-start marker) IS included in the speed average — verified against the // actual legacy code (workers/job_worker.js:1470-1472): the sprayStat!==3 exclusion there // applies to the spray-TIME accumulator, not speed. avgSpraySpeed fires for every record // with sprayStat>0, marker included. AGGREGATED_FIELDS_CALCULATION.md's prose description // conflates the two rules and is wrong on this point — don't trust it over the real code. if (utils.isNumber(p.grSpeed) && p.grSpeed > 0) { speedAcc += p.grSpeed; speedN++; } // XT Error always stays restricted to sprayStat 1/3, even when includeOutOfZoneSpray // widens SPRAY_ON to include 10 — cross-track-error-from-line isn't a meaningful // measurement once the aircraft is outside the mapped zone (workers/job_worker.js:1478 // draws the same distinction for the legacy avgXtError calculation) if (utils.isNumber(p.xTrack) && p.xTrack !== 0 && (p.sprayStat === 1 || p.sprayStat === 3)) { xtAcc += Math.abs(p.xTrack); xtN++; } if (utils.isNumber(p.sprayHeight) && p.sprayHeight > 0) { heightAcc += p.sprayHeight; heightN++; } if (utils.isNumber(p.swath) && p.swath > 0) { swathAcc += p.swath; swathN++; } } const swathM = swathN ? swathAcc / swathN : swathWidthM; return { startT, endT, sprayS, lengthM: lenM, avgSpeedMps: speedN ? speedAcc / speedN : (sprayS > 0 ? lenM / sprayS : 0), avgXtM: xtN ? xtAcc / xtN : null, // null: no xTrack recorded (SatLoc etc.) avgHeightM: heightN ? heightAcc / heightN : null, // null: no Flight Master height volumeL: flowN ? volumeL : null, // null: lminApp flat 0 (no flow controller) swathM, areaM2: lenM * swathM }; } // ---- per-pass stats, zone assignment; straddling passes split into segments ---- // (FR-5.2 refinement) — passLastZone remembers each original pass's LAST zone // segment (by array position, so a non-split pass just records its one zone) for // the turn-gap attribution below: a turn starts right after the pass's last point, // so it belongs to whichever zone that pass was in when it ended. const segments = []; const passLastZone = new Array(passes.length).fill(-1); passes.forEach((pass, passIdx) => { const pts = pass.points; if (zoneFeatures.length && straddlesMultipleZones(pts, zoneFeatures)) { for (const seg of splitPassByZone(pass, zoneFeatures, zoneCenters, computeSegmentStats)) { segments.push(seg); passLastZone[passIdx] = seg.zoneIdx; } } else { let zoneIdx = -1; if (zoneFeatures.length) { zoneIdx = majorityZone(pts, zoneFeatures); if (zoneIdx < 0) zoneIdx = nearestZone(pts[0], zoneCenters); } segments.push(Object.assign({ llnum: pass.llnum, zoneIdx, points: pts }, computeSegmentStats(pts))); passLastZone[passIdx] = zoneIdx; } }); // ---- line rows: one per (zone, llnum), ordered by start time (FR-4.5) --- const lineMap = new Map(); for (const seg of segments) { const key = seg.zoneIdx + ':' + seg.llnum; if (!lineMap.has(key)) lineMap.set(key, { zoneIdx: seg.zoneIdx, llnum: seg.llnum, startT: seg.startT, sprayS: 0, lengthM: 0, areaM2: 0, _speedAcc: 0, _speedW: 0, _xtAcc: 0, _xtW: 0, _volL: 0, _volKnown: false, _heightAcc: 0, _heightW: 0, _swathAcc: 0, _swathW: 0, turnS: null }); const line = lineMap.get(key); if (todDiff(seg.startT, line.startT) < 0) line.startT = seg.startT; line.sprayS += seg.sprayS; line.lengthM += seg.lengthM; line.areaM2 += seg.areaM2; line._speedAcc += seg.avgSpeedMps * seg.points.length; line._speedW += seg.points.length; if (seg.avgXtM !== null) { line._xtAcc += seg.avgXtM * seg.points.length; line._xtW += seg.points.length; } if (seg.avgHeightM !== null) { line._heightAcc += seg.avgHeightM * seg.points.length; line._heightW += seg.points.length; } if (seg.volumeL !== null) { line._volL += seg.volumeL; line._volKnown = true; } line._swathAcc += seg.swathM * seg.points.length; line._swathW += seg.points.length; } // attribute measured turn gaps to their line rows (mean when a line turned more than once) — // keyed by zone+llnum (via the gap's originating pass's LAST zone segment, now zone-assigned // above) so a reused llnum in a different zone can't inherit someone else's turn time const turnByLine = new Map(); for (const g of turnGaps) { const key = passLastZone[g.passIndex] + ':' + g.beforeLlnum; if (!turnByLine.has(key)) turnByLine.set(key, []); turnByLine.get(key).push(g.seconds); } // NOT re-sorted by raw startTimeS: gpsTime wraps past midnight, so a plain numeric sort would // put a post-midnight line (small startTimeS) before a pre-midnight one (large startTimeS) — // backwards. lineMap's insertion order already IS chronological (Map iteration order = first- // insertion order = the order passes were built in push()'s stream order, and a pass for a given // zone+llnum key is always first encountered at its true starting time), so it's left as-is // rather than re-sorted with a wrap-unsafe comparator (FR-4.5 — ordered by start time). const lines = [...lineMap.values()].map(l => { const gaps = turnByLine.get(l.zoneIdx + ':' + l.llnum); return { zoneIdx: l.zoneIdx, llnum: l.llnum, startTimeS: l.startT, // seconds of day sprayTimeS: l.sprayS, lengthM: l.lengthM, areaM2: l.areaM2, avgSpeedMps: l._speedW ? l._speedAcc / l._speedW : 0, avgXtM: l._xtW ? l._xtAcc / l._xtW : null, avgHeightM: l._heightW ? l._heightAcc / l._heightW : null, volumeL: l._volKnown ? l._volL : null, avgSwathM: l._swathW ? l._swathAcc / l._swathW : 0, turnTimeS: gaps && gaps.length ? gaps.reduce((a, b) => a + b, 0) / gaps.length : null }; }) // Drop fully-degenerate rows: zero length AND zero spray time (e.g. a single-point // fragment left over at a zone boundary). These already contribute nothing to any // weighted average below — every _speedAcc/_xtAcc/etc. accumulator above is weighted // by sprayTimeS or point count, so a 0-sprayTimeS line already adds value*0 — this // filter only removes the confusing "0 ft / 0 ac" row from the printed Flight Line // Statistics table and stops it from inflating lineCount. Deliberately conservative // (AND, not OR): a line with real length but zero measured spray time (or vice versa) // is kept, since it still reflects something that actually happened. .filter(l => l.lengthM > 0 || l.sprayTimeS > 0); // ---- zone roll-ups ------------------------------------------------------- const zoneStats = zones.map((z, idx) => ({ zoneIdx: idx, name: (z.properties && z.properties.name) || '', plannedAreaM2: plannedAreaM2(z, excludedAreas), sprayedAreaM2: 0, sprayTimeS: 0, flightTimeS: 0, volumeL: null, lineCount: 0, avgSpeedMps: null, avgXtM: null, avgHeightM: null, avgTurnTimeS: null, avgFlowLmin: null, avgSwathM: null, _firstT: null, _lastT: null, _speedAcc: 0, _speedW: 0, _xtAcc: 0, _xtW: 0, _heightAcc: 0, _heightW: 0, _turnAcc: 0, _turnN: 0, _swathAcc: 0, _swathW: 0 })); for (const line of lines) { if (line.zoneIdx < 0 || line.zoneIdx >= zoneStats.length) continue; const zs = zoneStats[line.zoneIdx]; zs.lineCount++; zs.sprayedAreaM2 += line.areaM2; zs.sprayTimeS += line.sprayTimeS; if (line.volumeL !== null) zs.volumeL = (zs.volumeL || 0) + line.volumeL; zs._speedAcc += line.avgSpeedMps * line.sprayTimeS; zs._speedW += line.sprayTimeS; if (line.avgXtM !== null) { zs._xtAcc += line.avgXtM * line.sprayTimeS; zs._xtW += line.sprayTimeS; } if (line.avgHeightM !== null) { zs._heightAcc += line.avgHeightM * line.sprayTimeS; zs._heightW += line.sprayTimeS; } if (line.turnTimeS !== null) { zs._turnAcc += line.turnTimeS; zs._turnN++; } zs._swathAcc += line.avgSwathM * line.sprayTimeS; zs._swathW += line.sprayTimeS; if (zs._firstT === null || todDiff(line.startTimeS, zs._firstT) < 0) zs._firstT = line.startTimeS; const lineEnd = line.startTimeS + line.sprayTimeS + (line.turnTimeS || 0); if (zs._lastT === null || todDiff(lineEnd, zs._lastT) > 0) zs._lastT = lineEnd; } for (const zs of zoneStats) { zs.avgSpeedMps = zs._speedW ? zs._speedAcc / zs._speedW : null; zs.avgXtM = zs._xtW ? zs._xtAcc / zs._xtW : null; zs.avgHeightM = zs._heightW ? zs._heightAcc / zs._heightW : null; zs.avgTurnTimeS = zs._turnN ? zs._turnAcc / zs._turnN : null; zs.avgSwathM = zs._swathW ? zs._swathAcc / zs._swathW : null; // zone flight time: first spray start to last spray end incl. its turn — spray + in-zone turns zs.flightTimeS = zs._firstT !== null ? todDiff(zs._lastT, zs._firstT) : 0; // exposed as their own fields (not just consumed via flightTimeS above) so the report can // show the actual start/end clock times, not just the elapsed duration between them zs.startTimeS = zs._firstT; zs.endTimeS = zs._lastT; zs.avgFlowLmin = (zs.volumeL !== null && zs.sprayTimeS > 0) ? zs.volumeL / (zs.sprayTimeS / 60) : null; // uncapped: a zone genuinely can be oversprayed past its own plan (swath overlap, turns, // re-flown sections — all normal in real spraying), and hiding that behind a 100% ceiling // throws away real information (e.g. how much extra product went down). The mission-level // coveragePct below is unaffected — it caps each zone's own CONTRIBUTION to that sum, but // this field is what gets displayed on the zone's own card/detail page. zs.coveragePct = zs.plannedAreaM2 > 0 ? (zs.sprayedAreaM2 / zs.plannedAreaM2) * 100 : null; delete zs._firstT; delete zs._lastT; delete zs._speedAcc; delete zs._speedW; delete zs._xtAcc; delete zs._xtW; delete zs._heightAcc; delete zs._heightW; delete zs._turnAcc; delete zs._turnN; delete zs._swathAcc; delete zs._swathW; } // ---- mission totals: exact sums / weighted means of the zone values ------ const sprayed = zoneStats.filter(z => z.lineCount > 0); const sum = (arr, f) => arr.reduce((a, z) => a + f(z), 0); const wMean = (arr, vf, wf) => { let acc = 0, w = 0; for (const z of arr) { const v = vf(z); if (v !== null) { acc += v * wf(z); w += wf(z); } } return w ? acc / w : null; }; // lines that landed in no zone at all — nearestZone() rejected even the closest match as // implausibly far (unrelated data: wrong file, GPS fault, mixed-in test data). Excluded from // every zone/mission total below the same way; summarized separately so the report can still // surface that this flight activity exists, instead of it just silently vanishing const assignedLines = lines.filter(l => l.zoneIdx >= 0 && l.zoneIdx < zoneStats.length); const unassignedLines = lines.filter(l => l.zoneIdx < 0 || l.zoneIdx >= zoneStats.length); const sprayTimeS = sum(sprayed, z => z.sprayTimeS); const sprayDistanceM = sum(assignedLines, l => l.lengthM); const volKnown = sprayed.some(z => z.volumeL !== null); const mission = { plannedAreaM2: sum(zoneStats, z => z.plannedAreaM2), sprayedAreaM2: sum(sprayed, z => z.sprayedAreaM2), sprayTimeS, totalFlightS: Math.max(totalFlightS, sprayTimeS), ferryTimeS: Math.max(totalFlightS - sprayTimeS, 0), totalDistanceM: Math.max(totalDistanceM, sprayDistanceM), sprayDistanceM, ferryDistanceM: Math.max(totalDistanceM - sprayDistanceM, 0), volumeL: volKnown ? sum(sprayed, z => z.volumeL || 0) : null, avgSpeedMps: wMean(sprayed, z => z.avgSpeedMps, z => z.sprayTimeS), // flat average of every spray-on reading (matches playback's playXt), not the // zone-weighted mean used by the other avg* fields — see missionXtAcc/missionXtN above avgXtM: missionXtN ? missionXtAcc / missionXtN : null, avgHeightM: wMean(sprayed, z => z.avgHeightM, z => z.sprayTimeS), avgSwathM: wMean(sprayed, z => z.avgSwathM, z => z.sprayTimeS), avgFlowLmin: null, zonesSprayed: sprayed.length, zonesTotal: zoneStats.length, lineCount: lines.length, unassigned: { lineCount: unassignedLines.length, sprayTimeS: sum(unassignedLines, l => l.sprayTimeS), lengthM: sum(unassignedLines, l => l.lengthM), areaM2: sum(unassignedLines, l => l.areaM2) } }; mission.avgFlowLmin = (mission.volumeL !== null && sprayTimeS > 0) ? mission.volumeL / (sprayTimeS / 60) : null; // Coverage % caps each zone's contribution at its OWN planned area before summing, so an // overlapped/oversprayed zone can never numerically stand in for a zone that was never // touched at all — otherwise "100%" could be reached while some zones are still untouched, // contradicting zonesSprayed < zonesTotal. mission.sprayedAreaM2 itself stays the true, // uncapped swept-area total (a legitimate, separate figure — "how much ground was passed // over," overlap included) and is not changed by this. mission.coveragePct = mission.plannedAreaM2 > 0 ? Math.min((sum(sprayed, z => Math.min(z.sprayedAreaM2, z.plannedAreaM2)) / mission.plannedAreaM2) * 100, 100) : null; const result = { lines, zones: zoneStats, mission }; if (collectDraw) result.draw = { // spraydata.js `data[].data` / `data[].fdata` shape used by the map page. Each // segment carries its own zoneIdx (already computed above — a straddling pass is // now split into one segment per zone it actually crosses) so the Zone Detail // capture can show only the focused zone's own spray corridors instead of every // zone's — a zone's map page shouldn't display coverage that belongs to a // neighboring zone, and shouldn't display a neighboring zone's own crossing pass either. spray: segments.map(seg => ({ zoneIdx: seg.zoneIdx, pts: seg.points.filter((_, i) => i % 2 === 0 || i === seg.points.length - 1) .map(p => [p.lat, p.lon]) })), // Same zoneIdx tagging as spray above, and for the same reason: a flight/ferry // segment's own bounding box almost always spans the whole mission (it's transit // between zones), so a plain "does this layer's bounds overlap the focused zone" // check — which is how non-tagged layers get faded in applyZoneFocusStyle — is // never false for it; the segment would show at full opacity in every zone's // thumbnail/detail regardless of focus. Tagging each segment with the zone it // mostly passes through/near (majorityZone, same fallback to nearestZone as passes // use) lets applyZoneFocusStyle hide it the same explicit way it already hides // out-of-zone spray corridors. flight: flightSegs.map(pts => { const llPts = pts.map(p => ({ lat: p[0], lon: p[1] })); let zoneIdx = zoneFeatures.length ? majorityZone(llPts, zoneFeatures) : -1; if (zoneIdx < 0 && zoneCenters.length) zoneIdx = nearestZone(llPts[0], zoneCenters); return { zoneIdx, pts }; }) }; return result; } return { push, fileBreak, finish, lineCount }; } module.exports = { createMissionAnalytics, plannedAreaM2, todDiff, };