agmission/server/docs/ADVANCED_REPORT_METRICS_VERIFICATION.md

544 lines
52 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

# Advanced Application Report — Metrics Calculation & Verification
**Purpose:** trace every displayed number on the Advanced Application Report (pages 13) back to its
raw source field, document the exact formula and unit conversions at each step, and record whether
it was verified correct, fixed, or left open for a product decision.
**Scope:** `helpers/report_util.js` (D1 analytics engine — pure computation over `ApplicationDetail`
points), `controllers/advanced_report.js` (D2 datasource builder — formatting/localization/overrides),
plus the shared conversion helpers in `helpers/utils.js` and `helpers/geo_util.js`.
**Report layout referenced:**
- **Page 1** — Mission Information, KPI tiles, Mission Statistics, Products table, Weather box
- **Page 2** — Mission Coverage grid (`coverageCards`, client-side page, suppressed for single-zone jobs)
- **Page 2/3** — Zone Detail (one page per zone: Flight Statistics box + zone map)
- **Page 3** — Flight Line Statistics table (per-line rows, nested under each zone)
---
## Legacy Consistency Matrix
Every calculation reviewed, checked against the actual legacy code (not documentation prose — one
documented legacy rule turned out to be wrong when checked against the real code, see §5 item 3), with
an explicit verdict on whether a legacy equivalent exists and whether this report's logic matches it.
Legacy source: `workers/job_worker.js` (import pipeline), `controllers/job.js` (legacy report
datasource, `makeJobAppDataSource`/`preAppReport_post`), `helpers/job_util.js`.
| Metric | Legacy equivalent? | Same logic? | Verdict |
|---|---|---|---|
| Point-to-point distance | Yes — `geoUtil.distance()`, literally the same function | ✅ identical (not a copy, the same call) | Match |
| Midnight-wrap time diff (`todDiff`) | Yes — inline in 3 places in `job_worker.js`, same `80000`/`86400` constants | ✅ byte-for-byte same formula | Match |
| Planned area (incl. exclusion-zone netting) | Yes — `jobUtil.calcTTSprayAreas()` | ✅ mirrors exactly, including its shared double-subtraction bug on overlapping exclusions (§5 item 11) | Match (fixed to match, this session) |
| Spray-pass segmentation (line breaks) | Yes — `getSprayOnSegments()`, `controllers/job.js:604` | ⚠️ **partial** — shares the core rules (llnum change, marker, ≥1km jump) but legacy also has a satellite-quality (`satsIn`) edge trim and a fuller `endSegChecker()` transition table this engine doesn't replicate | **Needs a decision — see below** |
| Turn time (state machine) | Yes — inline in `job_worker.js:1486-1516` | ✅ same core pattern, **plus** a deliberate correctness addition (`atFresh` guard) legacy lacks | Match + intentional improvement |
| Avg Speed (per-point rule) | Yes — `totalSpeedAcc/spraySpeedCount`, gated on `sprayStat>0` only | ✅ fixed this session to include the line-start marker, matching legacy exactly (previously wrongly excluded it based on a doc error) | Match (fixed) |
| Avg XT Error (per-point rule) | Yes — gated on `sprayStat===1\|\|===3` | ✅ matches exactly | Match |
| Avg Height (per-point rule) | **No legacy field at all** | — | New (unavoidable) |
| Total Flight Time / Duration / Ferry Time | Yes — sum of consecutive-point deltas, each capped ≤120s | ✅ fixed this session (was wall-clock last-minus-first; now matches legacy's capped-delta sum exactly) | Match (fixed) |
| Volume/material integration | Partial — legacy's `getAppliedRate()` 3-way priority (configured rate / flow-derived rate / `lhaReq` fallback), area × rate per record | ⚠️ **different method** — this engine always integrates flow over time directly when flow data exists, with no per-record priority scheme; mathematically equivalent to legacy's flow-derived branch, but legacy *prefers* the configured-rate branch by default. The configured-rate branch itself is no longer used as a mission-level fallback here at all — see §5 item 10 | Match where flow data exists; **intentionally diverges** where it doesn't (dashes instead of a configured-rate guess) — see §5 item 10 |
| Volume integration's outlier gap | Legacy caps every time accumulator at 120s | ✅ fixed this session — this engine's volume loop had no upper gap bound at all until now | Match (fixed) |
| Avg Speed / Avg XT / Avg Height — **mission-level** weighting | Legacy: one flat mean across every qualifying record in the whole job (no zones, no lines — just count-weighted) | ⚠️ **different** — this engine derives the mission figure from a zone roll-up that's point-count-weighted pass→line, then **time**-weighted line→zone and zone→mission. Quantified divergence: a constructed case with uneven GPS logging density across zones showed **49.1 vs. legacy's-equivalent 18.2** (2.7×) for the same underlying readings | **Open — flagging for a decision, see below. Not fixed without confirmation: reconciling this changes core architecture (mission would no longer be a pure function of the zone breakdown for these three fields specifically)** |
| Avg Speed / Avg XT / Avg Height — **zone/line-level** weighting | **No legacy equivalent** — legacy never had a per-zone or per-line breakdown of these at all | — | New (unavoidable) — the weighting choice here (point-count then time) is a reasonable original design, not a legacy deviation, since there's nothing in legacy to deviate from |
| Coverage % (any level) | **No legacy equivalent** — legacy's "coverage" is a raw area figure (hectares), never a percentage | — | New (unavoidable) |
| Zone assignment (`majorityZone`/`nearestZone`) | **No legacy equivalent** — legacy never attributes a GPS point to a specific zone | — | New (unavoidable) — `nearestZone()`'s no-sanity-check fallback fixed this session after being found misattributing an entire unrelated real flight on live data, see §5 item 9 |
| Products table Rate/Total Rate | Yes — `controllers/job.js:933,940` | ✅ byte-for-byte same pattern (bare unit, no `/ac` suffix) | Match |
| Weather (wind/temp/humidity units) | Yes — same raw field units (`windSpd` m/s, `temp` °C) | ✅ same conversions | Match |
| Mission Information passthrough fields (name, customer, pilot, aircraft, dates, etc.) | Yes — `makeJobAppDataSource()` | ✅ same source fields; two intentional additions (License Number, Flight # tail-number fallback — the latter since reverted to match legacy exactly) | Match |
**Two items above need your explicit call before any further code change, since fixing them "properly"
means non-trivial architecture work, not a quick patch:**
1. **Spray-pass segmentation's missing satellite-quality trim and transition table.** Legacy trims
low-satellite-count (`satsIn<99`) points from a segment's edges, and has a more detailed
`endSegChecker()` covering specific `sprayStat` transition pairs. Replicating this would require
adding `satsIn` to `DETAIL_PROJECTION` (`controllers/advanced_report.js:47`) and porting the trim
logic a real change, not a one-line fix. Is this in scope, or is the current simplified rule
(llnum change / marker / 1km jump) acceptable given it covers the common cases?
2. **Mission-level Avg Speed/XT/Height weighting.** Matching legacy exactly here means tracking a
*separate*, flat, count-weighted accumulator across the whole mission (mirroring legacy's job-level
number) specifically for the mission's own tile, independent of the zone breakdown underneath it
which means the mission figure would **no longer be mathematically derivable from the zone rows**
for these three fields, unlike every other reconciled metric in this report (area, time, distance,
volume). That's a deliberate trade-off: closer to legacy, but a new kind of inconsistency *within*
this report. Want this changed, or is the current zone-derived approach acceptable given there was
never a zone-level precedent to begin with?
---
## 0. Foundational primitives
Three primitive functions do all the raw geometry/time work; every other figure in the report is built
by combining their outputs. They are **not** the same function, and don't all feed the same metrics
being precise about which figure depends on which matters here.
| Function | Formula | File | Verdict |
|---|---|---|---|
| `geoUtil.distance(latLng1, latLng2)` | `turf.distance()` (haversine great-circle, spherical earth ~6371km radius), after reordering `[lat,lon]` `[lon,lat]` for turf | `helpers/geo_util.js:176-181` | Correct accurate for mission-scale distances, returns **km** |
| `turf.area(feature)` (called directly, no `geoUtil` wrapper) | Polygon surface area from its boundary shape a completely separate calculation, no point-to-point distance summing involved | `report_util.js:49,51-52` (`plannedAreaM2()`) | Correct |
| `todDiff(t2, t1)` | Seconds-of-day difference, corrected for the midnight rollover: if the raw difference is negative and 80,000s (~22.2h), recompute as `(86400 t1) + t2` | `report_util.js:33-37` | Correct one narrow theoretical edge case at the `WRAP_GUARD_S` threshold (see below), not practically triggerable given ~1Hz sampling within a single continuous file |
**`todDiff()` edge case (low risk, not a live bug):** the `80000`-second cutoff creates a narrow "dead
zone" a same-file gap of `79999`s returns a nonsensical `-79999` (not treated as a wrap) while `80000`s
correctly flips to being treated as one. Triggering this on real data would require two consecutive
points in the *same file* roughly 1.822 hours apart landing near midnight not realistic for ~1Hz GPS
sampling within one continuous flight file. If it ever did occur, the result degrades gracefully (a
negative/zero duration renders as a dash per the formatters' `≤0 → DASH` rule) rather than showing a
wrong number. Verified every `gpsTime` difference in the engine routes through this function no raw
subtraction bypasses it.
**What actually depends on which:**
- **Distance** (Total/Spray/Ferry Distance) and **Length** (a flight line's length) built *directly* by
summing `geoUtil.distance()` over consecutive GPS points (`report_util.js:151-152,225`). No other
function is involved.
- **A flight line's Area Covered** *indirect*: `Length × Swath Width` (`report_util.js:243`). Needs
`geoUtil.distance()`'s output plus a separate, unrelated input (the sprayer's configured swath).
- **A zone's Planned Area** does **not** use `geoUtil.distance()` at all. It comes from `turf.area()`,
measuring the drawn polygon's shape directly a different calculation entirely.
- **Avg Speed** *not* built from either function in the normal case; it comes straight from the
aircraft's own GPS speed sensor reading (`grSpeed`, recorded per point). `geoUtil.distance()` only
enters as a fallback when no valid sensor speed exists at all: `Length ÷ Time` (`report_util.js:238`).
---
## 1. Page 1 — Mission Information
Simple passthrough fields (no calculation), verified against the actual Mongoose schema (not just
assumed from the field name):
| Field | Source | File | Verdict |
|---|---|---|---|
| Mission Name | `job.name` | `advanced_report.js:378` | |
| Job Type | `job.appType` | `advanced_report.js:379` | |
| Crop | `job.crop.name` (populated ref) or raw `job.crop` (legacy string fallback) | `advanced_report.js:380`, schema `model/job.js:74` | |
| Date - Planned | `moment(job.startDate)` `moment(job.endDate)` | `advanced_report.js:362,381-382`, schema `model/job.js:66-67` | |
| Date/Time - Actual | first `apps[0].startDateTime` last `apps[N-1].endDateTime`, apps pre-sorted by `startDateTime` asc | `advanced_report.js:366-374`, query sort at `advanced_report.js:146` | correct for sequential flights see note below |
| Customer / Customer Address | `job.client.name` (ref `UserTypes.CLIENT`) / `getFormattedAddress(job.client)` | `advanced_report.js:385-386`, schema `model/job.js:70` | confirmed `client` and the separately-fetched `applicator` (via `job.byPuid`) are genuinely two different entities, not a naming collision |
| Pilot / Operator | `job.operator.name` (ref `UserTypes.PILOT`) | `advanced_report.js:387`, schema `model/job.js:69` | |
| License Number | `job.operator.licence` | `advanced_report.js:388`, schema `model/pilot.js:9` | (spelling matches schema exactly) |
| Aircraft | `job.vehicle.name` + `job.vehicle.model` | `advanced_report.js:389`, schema `model/vehicle.js:20` | |
| Flight # | `job.flightNumber` (dash if absent) | `advanced_report.js:390`, schema `model/job.js:73`, set from imported data in `workers/job_worker.js:714` | reverted to match legacy exactly (`controllers/job.js:1229`); previously fell back to `vehicle.tailNumber`, which was removed since a tail number identifies the aircraft permanently, not this specific flight see below |
| Applicator / Applicator Address | `Customer.findOne({_id: job.byPuid})` | `advanced_report.js:168-171`, schema comment `model/job.js:161` ("Applicator userId") | |
| Remark | `job.remark` | `advanced_report.js:411` | |
** Note (minor, low risk):** "Actual Dates" assumes the app with the latest `startDateTime` also has
the latest `endDateTime` (`apps[apps.length-1].endDateTime`). True for normal sequential single-aircraft
flights; could misorder only if two flight files genuinely overlap in time.
---
## 2. Page 1 — KPI Tiles
| Tile | Formula (source engine display) | Engine file:line | Display file:line | Verdict |
|---|---|---|---|---|
| **Coverage** | `min(Σzone min(sprayedAreaM2, plannedAreaM2) ÷ Σzone plannedAreaM2(net of excludedAreas) × 100, 100)` each zone's contribution capped at its own plan *before* summing | `report_util.js:45-58` (per-zone planned area), `:385-392` (per-zone-capped mission roll-up) | `advanced_report.js:344-352,400` | correct (fixed see §5.1 and §5.6) |
| **Avg Speed** | mean `grSpeed` per pass (m/s, **includes** `sprayStat==3` marker) weighted by point-count to line weighted by spray-time to zone weighted by spray-time to mission `×2.23694` (mph) / `×3.6` (km/h) | `report_util.js:232,238` (pass), `:267,289` (line), `:316,326` (zone), `:361` (mission) | `advanced_report.js:319` | correct matches the actual legacy `avgSpraySpeed` code (see §5, item 3 for the correction history) |
| **Avg Height** | mean `sprayHeight` per pass (m, >0 only, null if no FM sensor) → same weighted cascade → `×3.28084` (ft) | `report_util.js:234,240,269,291,318,328,363` | `advanced_report.js:321` | ✅ |
| **Avg XT Error** | mean `\|xTrack\|` per pass (m, ≠0, **includes** `sprayStat==3`) → same weighted cascade → `×3.28084` (ft) | `report_util.js:233,239,268,290,317,327,362` | `advanced_report.js:321` | ✅ — confirmed this correctly includes the marker point, matching the legacy `avgXtError` gate (`sprayStat===1 \|\| sprayStat===3`, `workers/job_worker.js:1478`); Avg Speed also includes the marker (both now confirmed to use the same inclusive rule — see §5, item 3) |
| **Total Volume** | Σ trapezoidal `lminApp` integration (L) across sprayed zones; **if null** (no flow-meter data): dash, same as zones/lines | `report_util.js:227-228,241,315,350,360` (measured) | `advanced_report.js:359-365` | ✅ fixed — mission-level estimate fallback removed, see §5 item 10 |
| **Zones Sprayed** | `count(zones with lineCount>0) / count(all zones)` | `report_util.js:340,365-366` | `advanced_report.js:399` | ✅ trivial, no arithmetic risk |
---
## 3. Page 1 — Mission Statistics
| Field | Formula | File:line | Verdict |
|---|---|---|---|
| Planned Area | `areaStr(plannedM2)` — manual Report-Settings override if set, else `mission.plannedAreaM2` (net of exclusion zones) | `advanced_report.js:346-347,400`; engine `report_util.js:352` | ✅ (fixed — see §5.1) |
| Sprayed Area | `areaStr(sprayedM2)` — manual override if set, else `mission.sprayedAreaM2` | `advanced_report.js:348-349,401` | ✅ |
| Total Flight Time / Total Duration (top box) | `hm(mission.totalFlightS)`; `totalFlightS` = Σ consecutive-point deltas, each excluded entirely if ≤0 or >120s — matches legacy exactly (`workers/job_worker.js:1447-1455`) | `report_util.js:142-158,372` | ✅ correct (fixed — see §5, item 9) |
| Total Spray Time | `hm(mission.sprayTimeS)` = Σ sprayed-zone `sprayTimeS` | `report_util.js:348,354`; `advanced_report.js:403` | ✅ — reconciliation to zone sum unit-tested (`test_report_util.js`, NFR-3.3 case) |
| Ferry Time | `hm(mission.ferryTimeS)` = `max(totalFlightS sprayTimeS, 0)` | `report_util.js:373` | ✅ correct (inherits the fix to `totalFlightS` — see §5, item 9) |
| Total Distance | `distStr(mission.totalDistanceM)` = `max(Σ all-point-pair distances gated at <1km jump, sprayDistanceM)` | `report_util.js:140-154,357` | ✅ |
| Spray Distance | `distStr(mission.sprayDistanceM)` = Σ line `lengthM` | `report_util.js:225,349,358` | ✅ |
| Ferry Distance | `distStr(mission.ferryDistanceM)` = `max(totalDistanceM sprayDistanceM, 0)` | `report_util.js:359` | ✅ |
| Avg App Rate | `rateStr(volumeL, sprayedM2)` = `(volume in job units) ÷ (area in job units)` | `advanced_report.js:335-338,408` | ✅ fixed — now correctly dashes with no flow data, consistent with zones/lines — **see §5 item 10** |
| Avg Flow Rate | `flowStr(mission.avgFlowLmin)` = `mission.volumeL ÷ (sprayTimeS/60)`, **measured only, no estimate fallback** | `report_util.js:369`; `advanced_report.js:409` | ✅ correctly stays dash when unmeasured (consistent, unlike Avg App Rate) |
| Swath Width | `job.swathWidth` (job config value, **not** the analytics-derived per-line swath used in area math) | `advanced_report.js:410` | ✅ — intentional: shows the job setting, distinct from `pass.swathM` used in area calc (`report_util.js:242`) |
---
## 4. Page 1 — Products Table
| Field | Formula | File:line | Verdict |
|---|---|---|---|
| Product Name / Restricted / EPA Reg# / Type | passthrough of populated `job.products[].product` fields | `advanced_report.js:505-508`, populate at `:102` | ✅ |
| Rate | `${rate} ${getProdUnit(unit)}` — bare unit string ("gal", "lb", "lit"...), no per-area suffix | `advanced_report.js:509`, `utils.getProdUnit()` at `helpers/utils.js:717-747` | ✅ matches legacy exactly (`controllers/job.js:933,940`) — API doc example was wrong, now fixed, see §5 item 8 |
| Total Volume Used | `rate × (sprayedArea in job units)`, with `oz→gal` conversion when `unit===OZ` | `advanced_report.js:511-513`, `utils.ozToGal()` at `helpers/utils.js:411-413` | ✅ math correct — inherits `sprayedM2` override behavior; this is a config rate × area, always known and always labeled correctly, unlike the mission/zone rate fields (see §5 item 17) |
**Verified:** `job.products[].rate/unit` schema (`model/job.js:51-57`) matches field access exactly;
`APTypes.CARRIER` (`helpers/constants.js:36-40`) and `Units`/`getProdUnit` mapping (`helpers/constants.js:5`,
`helpers/utils.js:717-747`) are correctly aligned — 0=oz, 1=gal, 2=lb, 3=lit, 4=kg, matching both enums.
---
## 5. Page 1 — Weather Box
| Field | Formula | File:line | Verdict |
|---|---|---|---|
| Wind Speed | Manual: `wi.windSpd` (already knots, per schema) shown as-is. Aggregated: `mpSecToKnot(avgWindSpd)` = `×1.94384` (`application_detail.windSpd` is m/s) | `advanced_report.js:536,547`; schema `model/job.js` weatherInfo (knots), `model/application_detail.js:37` (m/s); `utils.mpSecToKnot` at `helpers/utils.js:371-374` | ✅ both paths verified against actual field units in the schema, not assumed |
| Wind Direction | Manual: raw compass string. Aggregated: `round(avgWindDir)° + deg2Compass(avgWindDir)` (16-point compass) | `advanced_report.js:537,548`; `utils.deg2Compass` at `helpers/utils.js:219-231`; source field `model/application_detail.js:38` (degrees) | ✅ correct, though the two paths render differently in style (plain string vs "270° WNW") — cosmetic only |
| Temperature | Manual: `(wi.temp32)×5/9` if US (assumes °F stored), else as-is. Aggregated: `avgTemp` direct (already °C per schema) | `advanced_report.js:534,538,549`; `model/application_detail.js:39` (Celsius) | ✅ |
| Humidity | direct passthrough, rounded to 0 decimals | `advanced_report.js:539,550`; `model/application_detail.js:40` | ✅ |
| Aggregation query | `AppDetail.aggregate()``$avg` over `windSpd>0, windDir∈[0,360], temp∈[5,60], humid∈[9,90]` | `helpers/job_util.js:325-338` (`getDataWeatherInfo`) | ✅ sane outlier gates |
---
## 6. Page 2 — Mission Coverage Grid (`coverageCards`)
Always populated for every zone regardless of Report Contents filtering (§6 of the API contract), even
if the client suppresses the page for single-zone jobs.
| Field | Formula | File:line | Verdict |
|---|---|---|---|
| Sprayed / Planned | `${sprayedArea} / ${plannedArea}` (dash on sprayed side if `lineCount==0`) | `advanced_report.js:433-435` | ✅ — reuses already-verified zone `sprayedAreaM2`/`plannedAreaM2` |
| Coverage % | `zs.coveragePct` (dash if unsprayed) — **uncapped**, can show >100% on real overspray | `advanced_report.js:436`; engine `report_util.js:349` | ✅ (fixed — see §5, item 6 companion fix) |
---
## 7. Page 2/3 — Zone Detail
All zone-level fields reuse the same accumulator logic already verified for the mission-level KPIs
(§23), rolled up to per-zone instead of per-mission. Zone-specific formulas:
| Field | Formula | File:line | Verdict |
|---|---|---|---|
| Planned/Sprayed Area, Coverage %, Avg Speed/Height/XT Error/Flow Rate | same formulas as mission-level, applied per zone (Coverage % uncapped — see §5, item 6) | `advanced_report.js:453-464` | ✅ |
| Volume Applied / Avg App Rate | `zs.volumeL` — measured only | `advanced_report.js:456-457` | ✅ — mission-level tile now matches this behavior too, see §5 item 10 |
| Product | comma-joined names of the job's active-ingredient products (`job.products`, filtered to exclude `APTypes.CARRIER`) — mission-wide, identical value on every zone, same convention as `crop: mission.crop` | `advanced_report.js:576-592` | ✅ correct by construction — not a per-zone calculation, deliberately mission-wide |
| Start Time / End Time (zone-level) | `todStr(zs.startTimeS)` / `todStr(zs.endTimeS)` — the zone's own first/last spray-on timestamp, engine-side `_firstT`/`_lastT` now also exposed as `startTimeS`/`endTimeS` instead of being discarded after deriving `flightTimeS` | `advanced_report.js:598-599`; engine `report_util.js:524-525` (set right after `flightTimeS` at line 521, before the `_firstT`/`_lastT` cleanup at line 533) | ✅ same source timestamps `flightTimeS` already derives from, no new engine logic |
| Flight Time | `hm(zs.flightTimeS)` = `todDiff(last spray-end+turn, first spray-start)` **within that zone only** — a different definition than mission `totalFlightS` (not gap-tolerant the same way, and doesn't include ferry to/from the zone) | `advanced_report.js:458`; engine `report_util.js:308-309,318,331` | ✅ well-defined as its own metric — just not directly summable to the mission Total Flight Time field (different scope by design) |
| Avg Turn Time | `secStr(zs.avgTurnTimeS)` = **unweighted mean of each line's own (already-averaged) turn time** | `advanced_report.js:460`; engine `report_util.js:285,297,306,316,329` | ✅ underlying `turnTimeS` values fixed (§5.4) — ⚠️ minor residual note: still an unweighted double-average across lines; could skew if a zone's lines have very uneven turn counts (not fixed, low-impact) |
| Map image | zone capture `.jpg`, falls back to mission `map.jpg` placeholder on capture failure | `advanced_report.js:465` | ✅ (capture pipeline, not a calculation) |
---
## 8. Page 3 — Flight Line Statistics Table
| Column | Formula | File:line | Verdict |
|---|---|---|---|
| Start Time | `secondsToHMS(startTimeS % 86400, format=1)``HH:MM:SS` | `advanced_report.js:340,479`; `helpers/utils.js:804-816` | ✅ |
| Spray Time | `secStr(sprayTimeS)` | `advanced_report.js:480` | ✅ |
| Length | `lenStr(lengthM)` = `×3.28084` ft | `advanced_report.js:481` | ✅ |
| Avg Speed | same formula as §2 | `advanced_report.js:482` | ✅ |
| Area Covered | `toArea(areaM2, isUS)` (ac/ha), 2 decimals | `advanced_report.js:483` | ✅ |
| Rate | `rateStr(l.volumeL, l.areaM2)` — measured only, dash when no flow data (the common case, matches the screenshot) | `advanced_report.js:484` | ✅ — mission-level tile now consistent with this, see §5 item 10 |
| Avg XT Error | same formula as §2 | `advanced_report.js:485` | ✅ |
| Turn Time | `secStr(l.turnTimeS)` — mean of measured 5120s off→on gaps before this line, null if none measured | `advanced_report.js:486`; engine `report_util.js:168-184,285,297` | ✅ (fixed — see §5.4) |
| Unsprayed-zone placeholder row | single all-dash row per zone with `lineCount===0` | `advanced_report.js:488-495` | ✅ matches FR-4.6 |
---
## 5. Findings Summary
### Fixed this pass
1. **§2, §3 — Planned Area ignored exclusion zones.** `plannedAreaM2()` now nets out intersecting
`job.excludedAreas` (mirrors `jobUtil.calcTTSprayAreas`), matching `Job.ttSprArea`.
Fixed in `helpers/report_util.js:45-58,97,103,301`, wired in `controllers/advanced_report.js:141`.
Tests added in `tests/test_report_util.js`.
2. **Duration formatting rounding bug.** `hm()` displayed `"60m"`/`"1h 60m"` instead of carrying into
the hour. Fixed in `controllers/advanced_report.js:327-334`.
3. **Avg Speed marker-exclusion — fixed, then found to be wrong, then reverted.** Originally excluded
`sprayStat==3` from the speed average, citing `AGGREGATED_FIELDS_CALCULATION.md`'s claim that legacy
`avgSpraySpeed` excludes the marker. That claim turned out to be **false** — reading the actual
legacy code (`workers/job_worker.js:1459-1483`) shows the `sprayStat!==3` exclusion there applies to
the **spray-time** accumulator, not speed; the speed accumulator (`totalSpeedAcc`) fires for every
`sprayStat>0` record, marker included, with no exclusion at all. The doc's prose conflated the two
rules. **Reverted** the exclusion in `helpers/report_util.js:236` back to including the marker
(matching the real legacy code), inverted the test in `tests/test_report_util.js` to assert
inclusion instead of exclusion, and corrected both this doc and
`ADVANCED_REPORTS_IMPLEMENTATION_PLAN.md` §3 to stop citing that doc as authoritative on this point.
Net effect: current code now matches legacy exactly. Lesson: verify claims against the referenced
code directly, not against a doc's prose description of that code.
4. **Turn Time leaked across zones when a line number was reused in a different zone** (e.g. Zone A's
line 1 and Zone B's line 1 both flown, but only Zone A's line 1→2 transition had a measured turn).
`turnGaps` were keyed only by bare `llnum`, and zone assignment happens *after* the turn-time state
machine runs (it only knows zone membership once `finish()` does point-in-polygon assignment on the
completed passes) — so both line rows pulled from the same bucket and Zone B's line incorrectly
showed a 20s turn that was actually Zone A's. Reproduced and confirmed empirically before fixing.
Fixed by tagging each recorded gap with the `passes` index of the pass it follows
(`helpers/report_util.js:126-131,176`), then resolving that pass's assigned zone once zone
assignment is done and keying the lookup by `zoneIdx:llnum` (`helpers/report_util.js:277-285`) —
matching how line rows themselves are already keyed. Two regression tests added
(`tests/test_report_util.js`) using the existing "typical multi-zone mission" fixture, which already
contained this exact scenario but never asserted on it.
5. **Flight Line table order broke across a midnight wrap.** The final `.sort((a,b) =>
a.startTimeS - b.startTimeS)` on the `lines` array used plain numeric subtraction — every other
time comparison in this file deliberately uses the wrap-safe `todDiff()` instead. Reproduced
directly: a line flown right before midnight (`startTimeS: 86390`) and one flown right after
(`startTimeS: 10`, chronologically *later*) came out in reverse order (`llnum 2` before `llnum 1`).
Fixed by removing the sort entirely (`helpers/report_util.js:287-303`) — `lineMap`'s Map insertion
order already reflects true chronological order (a pass for a given zone+llnum is always first
encountered at its true start time, since `passes` is built strictly in stream order and a pass
can't start until the previous one ends), so no timestamp math was needed at all. Regression test
added (`tests/test_report_util.js`).
6. **Coverage % could show 100% while zones were still completely untouched.** The mission-level
ratio summed each sprayed zone's *raw, uncapped* area before dividing by total planned area — so an
overlapped/oversprayed zone (overlap is never deduplicated, see §2) could numerically exceed its own
planned size and mask an entirely different zone that was never sprayed at all. Reported directly
against real data (Job #96: "Coverage: 100%" alongside "Zones Sprayed: 22/27" — 5 zones never
touched, yet the raw sprayed-area sum exceeded the full 27-zone planned total once overlap was
counted). Reproduced with a minimal case: one zone oversprayed at 14,357% of its own plan, one zone
completely untouched — old formula: 100%; correct answer: 50% (one zone fully done, one zone at
zero). Fixed by capping each zone's contribution at `min(zone.sprayedAreaM2, zone.plannedAreaM2)`
*before* summing, both in the analytics engine (`helpers/report_util.js:385-392`) and in the
datasource builder's independent recomputation, which now reuses the engine's corrected value when
no manual Report-Settings override is active (`controllers/advanced_report.js:344-352,400`).
`mission.sprayedAreaM2` itself (the true, uncapped swept-area total shown as "Sprayed Area") is
unchanged — only the coverage *percentage* is capped per zone (at the mission level only — see
below). Regression test added (`tests/test_report_util.js`).
**Companion fix, same root question:** a zone's *own* `coveragePct` (shown on its Zone Detail page
and its Mission Coverage Grid card) was *also* capped at 100% (`report_util.js:349`, now removed).
Real overspray of a single zone — swath overlap, turns, re-flown sections, all normal in actual
spraying — is legitimate, useful information (e.g. how much extra product went down), and capping
it at "100%" threw that away, showing an oversprayed zone identically to an exactly-matched one.
Confirmed against real data (Job #96's Mission Coverage Grid: zones showing `367/366.3 ac` ≈ 100.2%
and `433.1/412.3 ac` ≈ 105.0%, both displayed as a flat "100%"). Removed the cap
(`helpers/report_util.js:349`) so the zone's own card/detail page now shows the true percentage,
which can exceed 100%. This does **not** affect the mission-level fix above — the mission
calculation caps each zone's *contribution to the sum* independently, using the raw area values
directly, not this field. `pctStr()` (`controllers/advanced_report.js:325`) has no capping of its
own, so it renders whatever value it's given correctly. Regression test added.
**Second companion fix — the manual Report-Settings override path had the same bug via a different
mechanism.** Job #96's report kept showing "Coverage: 100%" even after the fix above, because
`Job.rptOp.printArea/areaSize/coverage` had a manual override active (confirmed directly via
MongoDB: `rptOp.areaSize` and `rptOp.coverage` convert to exactly the report's displayed 37,335.7 ac
/ 42,341.8 ac). The datasource builder's override-fallback branch — used precisely because an
override has no per-zone breakdown to cap against — still did `Math.min(ratio, 100)`, so it
reproduced the identical bug through a path the zone-capping fix doesn't touch at all. And the same
contradiction persisted: "Zones Sprayed: 22/27" is always computed from real per-zone data
regardless of any override, so it kept correctly showing 5 untouched zones right next to a
now-doubly-explained "100%". Verified 42,341.8/37,335.7 = 113.41% — the override *itself* claims
more was sprayed than planned; capping that hides real information the same way the original bug
did. Removed the cap on this fallback branch too (`controllers/advanced_report.js:353-355`) — it now
shows the true, possibly >100%, ratio. No test file exists for `advanced_report.js` (only
`report_util.js` has unit test coverage); verified manually against Job #96's exact `rptOp` values,
confirmed the formula now returns 113.41% instead of 100%.
7. **Total Flight Time / Duration / Ferry Time used a wall-clock definition instead of legacy's
120-second-gap-capped sum.** Re-verified directly (not just re-stated from before): the existing
`test_report_util.js` "multiple files" test only proved *inter-file* gaps are excluded (a gap
*between* two uploaded files) — it said nothing about a gap *within* one file. Tested that
separately: one file, a spray segment, a 600-second internal gap (GPS dropout / idle) with no
`fileBreak()` in between, then more spraying — `mission.totalFlightS` came back as **609** (the
full last-minus-first span, gap included), where legacy's convention (sum of per-record deltas
each capped at ≤120s, `workers/job_worker.js:1447-1455`) would have dropped that single 600s delta
entirely, landing near 8s instead. Per explicit instruction to follow legacy's existing logic
rather than invent new behavior: **fixed**`totalFlightS` is now accumulated the same way,
summing consecutive-point deltas and excluding any single gap that's ≤0 or >120s
(`helpers/report_util.js:142-158`). This also let `fileFirstT`/`fileLastT`/`closeFileTime()` be
removed entirely — the per-point accumulation naturally resets at `fileBreak()` since `prev` is
already nulled there, so no separate file-boundary bookkeeping was needed. Verified: the 600s-gap
case now returns `8` instead of `609`. Regression test added (`tests/test_report_util.js`); the
existing "multiple files" test still passes with the same expected value (both mechanisms agree
when there's no internal gap), its description updated to stop citing the now-removed mechanism.
8. ~~Products table "Rate" column is missing its per-area unit suffix.~~ **Resolved — not a bug.**
Checked legacy's own product-rate formatting directly (`controllers/job.js:933,940`,
`makeJobAppDataSource`'s product loop): `rateStr: utils.toLocaleStr(rate,2,lang) + ' ' +
utils.getProdUnit(unit)` — byte-for-byte the same bare-unit pattern (`"0.50 gal"`, no `/ac`) as
`advanced_report.js:509`. The advanced report's Products table faithfully mirrors an established
legacy convention; it was the **API contract doc's example that was wrong** (`docs/
ADVANCED_REPORTS_API.md`, showed `"rateStr": "0.50 gal/ac"`). Fixed the doc example to `"0.50 gal"`
to match both the real legacy behavior and the actual implementation. No code change needed.
9. **`nearestZone()` could attribute an entire, unrelated real flight to whatever zone happened to be
"least wrong" — no matter how implausibly far away it actually was.** Found on real production data:
Job #95's "Spray_09" zone showed a full, convincing Zone Detail page — 55 flight lines, "Sprayed
Area: 643.7 ac," "Coverage: 6.6%" — for ground that was **never actually flown over**. Traced the
underlying GPS points: they're at 44.35°N, 44.36°N (southern Ontario) while Spray_09's actual polygon
sits at 31.5631.61°N (coastal Georgia) — **over 1,400 km away**. Confirmed via the job's own file
metadata that this data (`test-June23-01.zip`, evidently a generic test recording) genuinely belongs
to Job #95 — it's not a cross-job data mix-up — it simply has no real geographic relationship to any
of the job's 9 zones. `majorityZone()` correctly found zero matches (as it should), which triggered
the `nearestZone()` fallback — but that fallback had no distance sanity check at all, so it forced an
assignment regardless of plausibility. This subsumes the old low-confidence longitude-compression
note (previously item 11 in this section): fixed by rewriting `nearestZone()` to measure real
great-circle distance (`geoUtil.distance()`) instead of raw lat/lon-degree math, **and** by adding
`NEAREST_ZONE_MAX_KM = 50` — a line whose closest zone is still farther than that is now genuinely
unassignable (`zoneIdx = -1`), rather than forced onto the "least far" zone
(`helpers/report_util.js`). Unassigned lines are excluded from every zone and zone-derived mission
total (area, spray time, distance) the same way a truly-outside-every-zone line already was, and are
now summarized separately in a new `mission.unassigned` field (`lineCount`, `sprayTimeS`, `lengthM`,
`areaM2`) so this activity is visible in the datasource rather than silently vanishing or being
mislabeled — surfacing it in the actual report template is a follow-up (not touched here, out of
reach without the Stimulsoft designer). **Verified end-to-end against Job #95's real zones and all
58,200 of its real GPS points, not just a synthetic case**: Spray_09's `lineCount` went from 55 to 0,
its `sprayedAreaM2` from 643.7 ac to 0, and `mission.unassigned` now correctly reports the 55 lines,
~30.7 minutes of spray time (matching the original report's "Spray Time: 31m" almost exactly) that
used to be fabricated into Spray_09's page. Three regression tests added (`tests/test_report_util.js`),
including one reproducing this exact real-world scenario.
10. **Total Volume / Avg App Rate estimate broke mission ≡ Σ(zones) and turned out not to be
measurement-free.** When there's no flow-meter data, the mission-level tile silently substituted
`job.appRate × sprayedArea` (a legacy-derived estimate, `controllers/job.js:850-858`, "Total used
volume, estimated"), while the zone/line breakdown underneath stayed dashed (measured-only) — visible
directly on Job #104/#96 screenshots and reproduced cleanly on real Job #97 data (single zone, all
292.6 ac of it sprayed, no flow-controller data): the mission tile would show "Total Volume: 2,926
gal, Avg App Rate: 10.00 gal/ac" while that job's *only* zone — which covers the exact same
292.6 ac — showed "Volume Applied: , Avg App Rate: " on its own Zone Detail page. Examined what the
estimate actually represents: `job.appRate` is a single static value configured **before the flight
ever happened** — a target, not anything measured during or after it — so the formula assumes
uniform delivery at that exact rate everywhere the aircraft flew, with no allowance for rate changes,
drift, or real variation. Legacy's own one-page Application Report has shown this same assumption-based
number for years, but legacy has no zone/line breakdown to contradict — the Advanced Report's zone
hierarchy is what turns "a labeled estimate" into "a number that visibly disagrees with the zone that
contains it." Decided (explicit product direction): since the estimate isn't measurement-free, **drop
it from the mission level** rather than push it down to zones/lines — mission-level Total
Volume/Avg App Rate now dash under the same no-flow-data condition zones/lines already do, restoring
mission ≡ Σ(zones). The manual actual-volume override (`rptOp.useActualVol`/`rptOp.actualVol`) is
unaffected — that's a user-entered figure, not an assumption-based guess. Fixed by removing the
`job.appRate × sprayedArea` fallback block entirely (`controllers/advanced_report.js:359-365`); no
test file exists for `advanced_report.js` (only `report_util.js` has unit coverage — see D1/D2 split
in the implementation plan), verified manually against Job #97's real `mission.volumeL === null`
engine output and the resulting dashed tile. The deeper question of whether "Avg App Rate" should mean
a *measured* rate or a *planned/configured* rate remains — legacy's own `Application.appRate` field is
itself defined as `mean(lhaReq)` (the requested rate recorded per GPS point,
`workers/job_worker.js:1643-1687`), a third definition this engine doesn't currently read at all
(`lhaReq` isn't in `DETAIL_PROJECTION`, `controllers/advanced_report.js:47`) — noted for a future pass,
not blocking this fix.
11. **`majorityZone()`'s "whole line to one zone" rule let a small zone lose most of its coverage
credit to a much larger neighbor sharing a boundary.** Found on real production data: Job #106's
Zone 4 (18 ac planned) showed **12.4% coverage** — its map thumbnail was almost entirely empty except
one thin sliver — while Zone 3 (45.6 ac) showed only **68.8%** with just half its polygon painted, and
the adjacent, much larger Zone 2 (412.3 ac) showed **107.7%**, over its own plan. Traced the cause: a
pass whose GPS points straddle the boundary between two zones was being handed to whichever zone held
the sampled majority of its points — for a pass mostly inside Zone 2 but genuinely also covering part
of Zone 3/4, Zone 2 took 100% of the credit, Zone 3/4 got none of theirs. Confirmed via the underlying
line numbers: 74/8391 (the boundary-crossing passes) appeared in Zone 2's, Zone 3's, *and* Zone 4's
Flight Line tables simultaneously — proof the same physical passes were being fragmented and
miscredited across zone boundaries, not a rendering artifact. Fixed by adding a cheap sampled
pre-check (`straddlesMultipleZones`, reuses `majorityZone`'s existing sample — no added cost for the
overwhelming majority of lines that stay inside one zone) that only escalates to a full, unsampled
per-point zone lookup (`splitPassByZone`) for lines that actually touch more than one real zone; each
resulting per-zone segment gets its own stats via `computeSegmentStats` (extracted, byte-identical to
the old per-pass loop — DRY, zero behavior change for the non-split path). Turn-gap attribution
(`passLastZone`) and the map-drawing `draw.spray` segments were updated to key off the new per-segment
zone tags instead of the old per-pass one. Documented, deliberate trade-off: the single GPS interval
that actually crosses the boundary isn't counted in either segment's length/area/volume — a
per-crossing discrepancy of one GPS interval (a few meters), traded for not needing to split that
edge's distance between two zones. **Verified end-to-end against Job #106's real zones and all 40,156
of its real GPS points**: Zone 4 went from 12.4% to **96.9%** coverage, Zone 3 from 68.8% to **112%**,
Zone 2 correctly came back down from 107.7% to **98.1%**, while Zone 1 — which doesn't border any
other zone in this job — stayed byte-for-byte identical (99.8% both before and after), confirming the
fix is scoped to boundary-sharing zones only. Two regression tests added
(`tests/test_report_util.js`): one confirming the pre-existing majority-zone case (a line leaving a
zone into empty space) is untouched, one reproducing the genuine cross-zone-boundary split and
checking mission totals still reconcile exactly with the split zone roll-ups (NFR-3.3).
### Confirmed bug — fix deferred
17. **Ounce-configured jobs (`job.appRateUnit === RateUnits.OZ_PER_ACRE`) get mislabeled rate fields for
measured data.** None of the mission/zone/line volume or rate formatters have an oz-aware conversion
path, unlike the Products table (which already does this correctly — see §4 above). `rateStr()`
(`advanced_report.js:335-338`) always labels the number with the raw
`rateUnitString(job.appRateUnit, ...)` ("oz/ac"), but the number itself always comes from
`toVolume(volL, isLiquid, isUS)`, which for `isLiquid===true` unconditionally means *gallons*, never
ounces. So for **measured** flow data on an oz-configured job, "Avg App Rate"/"Rate" shows a real
gal/ac figure under an "oz/ac" label — the number is correct, the unit suffix isn't. `volStr` has the
same gap (hardcodes a "gal" label, no oz path). **Legacy already solves this**
`controllers/job.js:853-858` converts with `utils.ozToGal()` *and* swaps the displayed rate-unit to
`GAL_PER_ACRE` so label and number never disagree (the same pattern already applied correctly in the
Products table's `unit === Units.OZ` branch, `advanced_report.js:520`) — but the Advanced Report has
no equivalent of either step in the mission/zone/line paths.
Originally found alongside a second sub-bug: the mission-level *estimate* (`job.appRate × area`) fed
an oz-denominated number into the same gallon-only conversion, producing a volume 128× too large
(reproduced on Job 13603: 1,716.85 oz true volume computed and displayed as 1,717 gal). That sub-bug
is now **moot** — item 10's fix removed the estimate-fallback block entirely
(`controllers/advanced_report.js:359-365` no longer computes an estimate at all), so there's nothing
left to mislabel on that path. The mislabeling above, for genuinely **measured** flow data on an
oz-configured job, is unaffected by that fix and remains open.
- **Fix, not yet applied.** Recommended approach mirrors legacy exactly: thread a "display rate unit"
(raw `job.appRateUnit`, or `GAL_PER_ACRE` when it was oz) through `rateStr`/`volStr` instead of
always assuming gallons for any `isLiquid` unit.
### Open — deferred pending a product decision
18. **Legacy's "Actual Spray Volume" fills in a guessed rate whenever the flow sensor reads zero;
the Advanced Report's `volumeL` never does.** Both engines agree exactly on every segment where the
flow sensor (`lminApp`) actually reported something — confirmed on real Job 105 data (JBI - AutoCal,
21,824 real points, 3 files): a faithful reconstruction of legacy's own per-record formula
(`getAppliedRate()`, `workers/job_worker.js:1640-1662` + `helpers/utils.js:1795-1820`), using the
real stored `utmX`/`utmY` (not an approximation), lands at **120.16 gal** from flow-derived segments
alone — matching the engine's real `mission.volumeL` (**117.43 gal**) to within rounding. The two
methods are mathematically equivalent when both are looking at real sensor data; this is not a
calculation-method difference.
- The gap to the DB's actual stored `App.totalSprayMat` (**154.07 gal** — the "Mat Sprayed"/"Actual
Spray Volume" figure) is fully explained, and reproduces to the decimal: `getAppliedRate()`
(`helpers/utils.js:154-176,1798`) reads a per-file configured rate from the imported file's own
Q-file metadata (`fileMeta.appRate` — 0.16 gal/ac for this file, an equipment setting, **not**
`job.appRate`) and substitutes it whenever `record.lminApp` is falsy for that GPS record, instead
of treating a zero reading as zero volume. For Job 105, **4,078 of the ~18,634 contributing spray
segments (≈22%) had `lminApp === 0`** — legacy added `33.91 gal` for those moments using the
substitute rate; the Advanced Report added `0`. `120.16 + 33.91 = 154.07` — an exact match to the
stored value, confirming this is the complete and only mechanism, not one contributing factor among
several.
- **The real, unresolved question is which behavior is more correct**, and it can't be settled from
the data alone: if a `lminApp === 0` reading reflects a genuine sensor dropout/glitch while the
nozzle kept running, legacy's fill-in is the more accurate total. If it reflects the nozzle
genuinely being off at that instant (boom-section cycling, swath-overlap avoidance switching
sections off, a tank running dry), the Advanced Report's "trust the zero" is more accurate and
legacy has been quietly overstating volume by padding every such moment. Given how large and
systematic the share is on this file (≈1 in 5 segments, not an occasional blip), a genuine
zero-flow condition looks more likely than sensor noise, but this is a judgment call about
equipment behavior, not something resolvable by re-reading either codebase.
- **Not fixed.** Needs a decision: leave the Advanced Report trusting real zero readings (current
behavior, arguably the more defensible default absent evidence the sensor is unreliable), or add an
equivalent "assume nominal rate on a zero reading" fallback to match legacy's number exactly.
### Pre-existing platform bug — found here, but out of scope to fix in this report
11. **Overlapping exclusion zones double-subtract their shared overlap in `plannedAreaM2()`**, and can
drive the planned area negative. Reproduced directly: a zone with two exclusions covering 60% and
60% (overlapping each other, together covering 90%) should net 10% remaining — instead returns a
**negative** area, because each exclusion's overlap is subtracted independently instead of first
unioning the exclusions together. Confirmed this is **not new**`jobUtil.calcTTSprayAreas()`
(`helpers/job_util.js:117-149`) has the byte-identical flaw and produces the exact same negative
number on the same input, since `plannedAreaM2()` was deliberately written to mirror it. This affects
the live `Job.ttSprArea` field today, not just this report — fixing it only here would create a new
mismatch rather than remove one. Flagging as a separate platform issue, not fixing in
`report_util.js`. Only triggers when a user draws two exclusion zones that overlap each other inside
the same spray zone.
### Low-confidence / low-impact notes (not fixed)
11. `majorityZone()` gives all point-in-polygon credit to the first matching zone only (`break` on
match) — confirmed directly: two overlapping zones with 10 test points inside *both* produced
`counts: [10, 0]`, denying the second zone any credit for points genuinely inside it too. Matters
only if spray zones (not just exclusion zones, which are already known to sometimes overlap) are
ever drawn overlapping each other (`report_util.js:63-77`).
12. Zone-level `avgTurnTimeS` is an unweighted mean of each line's own (already-averaged) turn time — a
double average, separate from the cross-zone leak fixed in item 4 above (`report_util.js:329`).
13. Manual Report Settings overrides (`rptOp.areaSize`/`coverage`) affect the mission summary
(`advanced_report.js:346-349`) but not `coverageCards`/`zonesDS`, which always show computed values
— pre-existing legacy-pattern behavior, not something newly introduced.
14. "Actual Dates" assumes the last app sorted by `startDateTime` also has the latest `endDateTime`
(`advanced_report.js:366-374`) — true for normal sequential flights only.
15. A self-intersecting ("bowtie") zone polygon silently computes a near-zero area (shoelace-formula
signed-area cancellation) rather than erroring — an inherent limitation of this class of area
algorithm for any invalid, non-simple polygon, not specific to this code. Only matters if the
zone-drawing tool ever allows saving such a shape.
16. Turn-time state machine has no branch for an off-period whose `llnum` doesn't match the line just
being tracked (only reachable with anomalous data that doesn't carry over the previous line's
number during ferry/off travel — not the convention this codebase's own test fixtures assume).
Reproduced directly: a genuine 8s turn got silently dropped (`turnTimeS: null`) when the off-period
in between carried an unexpected `llnum`. Fails safe — degrades to "unmeasured," never to a wrong
value — consistent with every other edge case found in this engine (`report_util.js:171-181`).
---
## Test coverage
`tests/test_report_util.js` — 40 passing cases covering: midnight-wrap time math, planned-area
exclusion-zone netting (new), multi-zone/multi-file reconciliation (NFR-3.3), no-flow-controller
degradation, SatLoc-style missing-sensor degradation, unsprayed-zone dash handling, boundary-straddling
majority-zone assignment (a line leaving a zone into empty space, stays whole), genuine cross-zone-
boundary splitting (new — a line actually crossing into a different zone becomes one segment per zone,
reproduces the exact Job #106 real-world case, item 11), a degenerate zero-length/zero-time line dropped
from the report instead of showing a "0 ft / 0 ac" row (new), nearest-zone fallback, the line-start-marker
speed *inclusion* (matching the real legacy code — see §5, item 3), the cross-zone turn-time leak on a
reused line number (new), line ordering across a midnight wrap (new), mission Coverage % never reaching
100% while a zone remains untouched (new), a zone's own Coverage % correctly showing >100% on real
overspray instead of capping (new), `totalFlightS` excluding an internal >120s gap the same way legacy
does (new), volume integration excluding the same gap (new), and a line implausibly far from every zone
landing in `mission.unassigned` instead of being force-assigned (new — reproduces the exact Job #95
real-world case).
Run: `npx mocha --exit --require tests/setup.js tests/test_report_util.js`