544 lines
52 KiB
Markdown
544 lines
52 KiB
Markdown
# Advanced Application Report — Metrics Calculation & Verification
|
||
|
||
**Purpose:** trace every displayed number on the Advanced Application Report (pages 1–3) 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.8–22 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.temp−32)×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
|
||
(§2–3), 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 5–120s 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.56–31.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/83–91 (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`
|