agmission/server/helpers/report_util.js

636 lines
34 KiB
JavaScript

'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,
};