agmission/server/tests/test_report_util.js

466 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* D1 Analytics engine unit tests (ADVANCED_REPORTS_IMPLEMENTATION_PLAN.md §3, §8.1)
* Pure fixtures — no DB, no HTTP. Covers the plan's fixture list: typical multi-zone,
* no-flow-controller, SatLoc-style (no xTrack/turn), unsprayed zone, single zone,
* boundary-straddling line — plus midnight wrap and mission ≡ zone reconciliation (NFR-3.3).
*/
const { expect } = require('chai');
const { createMissionAnalytics, plannedAreaM2, todDiff } = require('../helpers/report_util');
// ~11.13 m per 0.0001° of latitude
const STEP_DEG = 0.0001, STEP_M = 11.13;
function zone(name, lonMin, latMin, lonMax, latMax, area) {
return {
properties: { name, ...(area ? { area } : {}) },
geometry: {
type: 'Polygon',
coordinates: [[[lonMin, latMin], [lonMax, latMin], [lonMax, latMax], [lonMin, latMax], [lonMin, latMin]]]
}
};
}
function pt(lat, lon, gpsTime, llnum, sprayStat, extra = {}) {
return Object.assign({
lat, lon, gpsTime, llnum, sprayStat,
grSpeed: 11.13, xTrack: 0.5, sprayHeight: 4, lminApp: 60, swath: 15
}, extra);
}
/** n spray-on points heading north from (lat0, lon0), one per second */
function sprayLine({ lat0, lon0, t0, llnum, n = 20, extra = {} }) {
const pts = [];
for (let i = 0; i < n; i++)
pts.push(pt(lat0 + i * STEP_DEG, lon0, t0 + i, llnum, i === 0 ? 3 : 1, extra));
return pts;
}
/** spray-off travel points (same heading), one per second */
function offRun({ lat0, lon0, t0, llnum, n = 10, extra = {} }) {
const pts = [];
for (let i = 0; i < n; i++)
pts.push(pt(lat0 + i * STEP_DEG, lon0, t0 + i, llnum, 0, extra));
return pts;
}
function run(zones, pointBatches, opts = {}) {
const engine = createMissionAnalytics({
zones, swathWidthM: opts.swathWidthM || 15, excludedAreas: opts.excludedAreas || []
});
for (let b = 0; b < pointBatches.length; b++) {
pointBatches[b].forEach(p => engine.push(p));
engine.fileBreak();
}
return engine.finish();
}
const zoneA = () => zone('Zone A', 0, 0, 0.01, 0.01);
const zoneB = () => zone('Zone B', 0.02, 0, 0.03, 0.01);
describe('report_util — D1 analytics engine', function () {
describe('todDiff', function () {
it('plain difference within a day', function () {
expect(todDiff(100, 40)).to.equal(60);
});
it('corrects the midnight wrap', function () {
expect(todDiff(10, 86390)).to.equal(20);
});
it('small negative diffs stay negative (out-of-order points, not a wrap)', function () {
expect(todDiff(40, 100)).to.equal(-60);
});
});
describe('plannedAreaM2', function () {
it('prefers the stored properties.area', function () {
expect(plannedAreaM2(zone('z', 0, 0, 0.01, 0.01, 5000))).to.equal(5000);
});
it('computes from geometry when no stored area (live-data gap)', function () {
const a = plannedAreaM2(zone('z', 0, 0, 0.01, 0.01));
// ~1.11 km square ≈ 1.23e6 m²
expect(a).to.be.greaterThan(1.1e6).and.lessThan(1.4e6);
});
it('nets out an intersecting exclusion zone (mirrors jobUtil.calcTTSprayAreas)', function () {
const z = zoneA();
const xcl = zone('hole', 0, 0, 0.01, 0.005); // bottom half of zone A
const full = plannedAreaM2(z);
const net = plannedAreaM2(z, [xcl]);
expect(net).to.be.closeTo(full / 2, full * 0.05);
});
it('does not subtract a non-overlapping exclusion zone', function () {
const z = zoneA();
const xcl = zoneB(); // disjoint from zone A
expect(plannedAreaM2(z, [xcl])).to.be.closeTo(plannedAreaM2(z), 0.001);
});
it('ignores a stored properties.area once exclusion zones exist — a pre-exclusion figure can\'t be trusted', function () {
const z = zone('z', 0, 0, 0.01, 0.01, 5000);
const xcl = zone('hole', 0, 0, 0.01, 0.005);
expect(plannedAreaM2(z, [xcl])).to.not.equal(5000);
});
});
describe('typical multi-zone mission', function () {
// zone A: llnum 1 and 2 with a measured turn between; zone B: llnum 1 again
// (same line number in a different zone must NOT merge — keyed by zone+llnum)
const batch = [
...sprayLine({ lat0: 0.001, lon0: 0.002, t0: 1000, llnum: 1 }),
...offRun({ lat0: 0.003, lon0: 0.0025, t0: 1020, llnum: 1, n: 15 }),
...sprayLine({ lat0: 0.001, lon0: 0.003, t0: 1040, llnum: 2 }),
...sprayLine({ lat0: 0.001, lon0: 0.025, t0: 1100, llnum: 1 }),
];
const res = run([zoneA(), zoneB()], [batch]);
it('produces one row per zone+llnum, ordered by start time', function () {
expect(res.lines).to.have.length(3);
expect(res.lines.map(l => l.llnum)).to.deep.equal([1, 2, 1]);
expect(res.lines[0].zoneIdx).to.equal(0);
expect(res.lines[1].zoneIdx).to.equal(0);
expect(res.lines[2].zoneIdx).to.equal(1);
});
it('computes line length/area from geometry × swath', function () {
const l = res.lines[0];
expect(l.lengthM).to.be.closeTo(19 * STEP_M, 2);
expect(l.areaM2).to.be.closeTo(l.lengthM * 15, 1);
expect(l.sprayTimeS).to.equal(19);
expect(l.avgSpeedMps).to.be.closeTo(11.13, 0.01);
});
it('measures the turn between line 1 and line 2 (5120 s window)', function () {
expect(res.lines[0].turnTimeS).to.equal(20); // off at t=1020, next spray-on at t=1040
});
it('does not leak that turn onto zone B\'s unrelated reused llnum 1 (no off-run precedes it)', function () {
expect(res.lines[2].zoneIdx).to.equal(1);
expect(res.lines[2].llnum).to.equal(1);
expect(res.lines[2].turnTimeS).to.be.null;
});
it('rolls zones up from their lines', function () {
const [a, b] = res.zones;
expect(a.lineCount).to.equal(2);
expect(b.lineCount).to.equal(1);
expect(a.sprayedAreaM2).to.be.closeTo(res.lines[0].areaM2 + res.lines[1].areaM2, 0.001);
expect(a.avgTurnTimeS).to.equal(20);
expect(b.avgTurnTimeS).to.be.null; // zone B's reused llnum 1 has no measured turn of its own
expect(a.volumeL).to.be.closeTo(38, 2); // 2 lines × (60 L/min over 19 s)
});
it('mission totals reconcile exactly with zone roll-ups (NFR-3.3)', function () {
const zoneAreaSum = res.zones.reduce((acc, z) => acc + z.sprayedAreaM2, 0);
const zoneVolSum = res.zones.reduce((acc, z) => acc + (z.volumeL || 0), 0);
const zoneSpraySum = res.zones.reduce((acc, z) => acc + z.sprayTimeS, 0);
expect(res.mission.sprayedAreaM2).to.equal(zoneAreaSum);
expect(res.mission.volumeL).to.equal(zoneVolSum);
expect(res.mission.sprayTimeS).to.equal(zoneSpraySum);
expect(res.mission.sprayDistanceM).to.be.closeTo(res.lines.reduce((a, l) => a + l.lengthM, 0), 0.001);
expect(res.mission.zonesSprayed).to.equal(2);
expect(res.mission.zonesTotal).to.equal(2);
expect(res.mission.lineCount).to.equal(3);
});
it('ferry figures are total minus spray, never negative', function () {
expect(res.mission.ferryTimeS).to.equal(res.mission.totalFlightS - res.mission.sprayTimeS);
expect(res.mission.ferryDistanceM).to.be.at.least(0);
});
});
describe('avgSpraySpeed includes the line-start marker (sprayStat 3)', function () {
// legacy job_worker.js:1470-1472 accumulates speed for every sprayStat>0 record with no
// sprayStat!==3 exclusion (that exclusion applies only to the spray-time accumulator) —
// AGGREGATED_FIELDS_CALCULATION.md's prose description of this rule is wrong; verified
// against the actual legacy code, not the doc
const pts = [pt(0.001, 0.002, 0, 1, 3, { grSpeed: 100 })];
for (let i = 1; i < 10; i++)
pts.push(pt(0.001 + i * STEP_DEG, 0.002, i, 1, 1, { grSpeed: 11.13 }));
const res = run([zoneA()], [pts]);
it('includes the marker point\'s grSpeed (matches the actual legacy avgSpraySpeed code)', function () {
const expected = (100 + 9 * 11.13) / 10;
expect(res.lines[0].avgSpeedMps).to.be.closeTo(expected, 0.01);
});
});
describe('no-flow-controller job (lminApp flat 0)', function () {
const batch = sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1, extra: { lminApp: 0 } });
const res = run([zoneA()], [batch]);
it('volume and flow degrade to null, everything else computes', function () {
expect(res.lines[0].volumeL).to.be.null;
expect(res.zones[0].volumeL).to.be.null;
expect(res.zones[0].avgFlowLmin).to.be.null;
expect(res.mission.volumeL).to.be.null;
expect(res.mission.avgFlowLmin).to.be.null;
expect(res.lines[0].lengthM).to.be.greaterThan(0);
});
});
describe('SatLoc-style job (no xTrack, no height, no turns)', function () {
const batch = sprayLine({
lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1,
extra: { xTrack: 0, sprayHeight: 0 }
});
const res = run([zoneA()], [batch]);
it('XT / height / turn degrade to null', function () {
expect(res.lines[0].avgXtM).to.be.null;
expect(res.lines[0].avgHeightM).to.be.null;
expect(res.lines[0].turnTimeS).to.be.null;
expect(res.zones[0].avgXtM).to.be.null;
expect(res.mission.avgXtM).to.be.null;
});
});
describe('mission with an exclusion zone (planned area matches Job.ttSprArea)', function () {
const xcl = zone('pond', 0, 0, 0.01, 0.005); // bottom half of zone A
const res = run([zoneA(), zoneB()],
[sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1 })],
{ excludedAreas: [xcl] });
const noXclRes = run([zoneA(), zoneB()],
[sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1 })]);
it('nets the exclusion zone out of the overlapping zone only', function () {
expect(res.zones[0].plannedAreaM2).to.be.lessThan(noXclRes.zones[0].plannedAreaM2);
expect(res.zones[1].plannedAreaM2).to.be.closeTo(noXclRes.zones[1].plannedAreaM2, 0.001);
});
it('mission planned area is still the exact sum of the netted zone areas (NFR-3.3)', function () {
const zoneSum = res.zones.reduce((acc, z) => acc + z.plannedAreaM2, 0);
expect(res.mission.plannedAreaM2).to.equal(zoneSum);
});
});
describe('unsprayed zone (FR-4.6 dash page)', function () {
const res = run([zoneA(), zoneB()],
[sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1 })]);
it('keeps the zone in the roll-ups with zero activity', function () {
const b = res.zones[1];
expect(b.lineCount).to.equal(0);
expect(b.sprayedAreaM2).to.equal(0);
expect(b.coveragePct).to.equal(0);
expect(b.plannedAreaM2).to.be.greaterThan(0);
expect(res.mission.zonesSprayed).to.equal(1);
expect(res.mission.zonesTotal).to.equal(2);
});
it('mission coveragePct never reaches 100% while an untouched zone remains (an overlapped zone cannot stand in for it)', function () {
// zone A gets heavily oversprayed (huge swath -> far more than its own planned area);
// zone B (untouched) has zero activity. An unclamped sum would let zone A's excess mask
// zone B entirely, misreporting the mission as fully covered.
const overspray = run([zoneA(), zoneB()],
[sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1, n: 2, extra: { swath: 400000 } })]);
expect(overspray.zones[0].sprayedAreaM2).to.be.greaterThan(overspray.zones[0].plannedAreaM2);
expect(overspray.mission.zonesSprayed).to.equal(1);
expect(overspray.mission.zonesTotal).to.equal(2);
expect(overspray.mission.coveragePct).to.be.closeTo(50, 0.01); // zone A capped at its own 100%, zone B at 0%
});
it('a zone\'s own coveragePct is NOT capped at 100% — real overspray (overlap, turns) should show as such', function () {
const overspray = run([zoneA(), zoneB()],
[sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1, n: 2, extra: { swath: 400000 } })]);
expect(overspray.zones[0].coveragePct).to.be.greaterThan(100);
expect(overspray.zones[1].coveragePct).to.equal(0); // untouched zone (has a planned area, zero sprayed) stays 0%
});
});
describe('boundary-straddling line (FR-5.2 majority rule)', function () {
// 20 points heading EAST from inside zone A across its edge: 12 in, 8 out
const pts = [];
for (let i = 0; i < 20; i++)
pts.push(pt(0.005, 0.0088 + i * STEP_DEG, i, 7, i === 0 ? 3 : 1));
const res = run([zoneA(), zoneB()], [pts]);
it('assigns the whole line to the majority zone', function () {
expect(res.lines).to.have.length(1);
expect(res.lines[0].zoneIdx).to.equal(0);
});
});
describe('genuine boundary-straddling line crossing INTO a different zone (FR-5.2 refinement: split by zone instead of majority-take-all)', function () {
// heads east from inside zone A, through the unzoned gap between A and B, into zone B —
// unlike the majority-rule case above (which leaves a zone into empty space and stays
// whole), this one actually touches TWO real zones and must be split
const pts = [
pt(0.005, 0.003, 0, 9, 3),
pt(0.005, 0.005, 1, 9, 1),
pt(0.005, 0.007, 2, 9, 1),
pt(0.005, 0.012, 3, 9, 1), // gap — no zone, rides along with zone A's run
pt(0.005, 0.018, 4, 9, 1), // gap — no zone, rides along with zone A's run
pt(0.005, 0.022, 5, 9, 1),
pt(0.005, 0.025, 6, 9, 1),
pt(0.005, 0.028, 7, 9, 1)
];
const res = run([zoneA(), zoneB()], [pts]);
it('produces two line rows — one per zone actually crossed — instead of handing the whole pass to one zone', function () {
expect(res.lines).to.have.length(2);
expect(res.lines.map(l => l.zoneIdx).sort()).to.deep.equal([0, 1]);
expect(res.lines.every(l => l.llnum === 9)).to.equal(true);
expect(res.lines.every(l => l.lengthM > 0)).to.equal(true);
});
it('mission totals still reconcile exactly with the split zone roll-ups (NFR-3.3)', function () {
const zoneAreaSum = res.zones.reduce((acc, z) => acc + z.sprayedAreaM2, 0);
expect(res.mission.sprayedAreaM2).to.equal(zoneAreaSum);
expect(res.mission.zonesSprayed).to.equal(2);
});
});
describe('line fully outside every zone', function () {
const res = run([zoneA(), zoneB()],
[sprayLine({ lat0: 0.05, lon0: 0.05, t0: 0, llnum: 3 })]);
it('falls back to the nearest zone so totals still reconcile', function () {
expect(res.lines[0].zoneIdx).to.be.oneOf([0, 1]);
const zoneAreaSum = res.zones.reduce((acc, z) => acc + z.sprayedAreaM2, 0);
expect(res.mission.sprayedAreaM2).to.equal(zoneAreaSum);
});
});
describe('line implausibly far from every zone (Job #95 real-world case: a flight recorded ~1,400km away)', function () {
// zoneA sits near the equator; this line is at 44°N, thousands of km from either zone —
// nearestZone() must refuse to force an assignment rather than silently attribute a real,
// unrelated flight to whichever zone happens to be "least wrong"
const res = run([zoneA(), zoneB()],
[sprayLine({ lat0: 44.35, lon0: -81.0, t0: 0, llnum: 65535 })]);
it('is excluded from every zone, not force-assigned to the least-implausible one', function () {
expect(res.lines[0].zoneIdx).to.equal(-1);
expect(res.zones[0].lineCount).to.equal(0);
expect(res.zones[1].lineCount).to.equal(0);
expect(res.zones[0].sprayedAreaM2).to.equal(0);
});
it('is summarized separately in mission.unassigned instead of vanishing', function () {
expect(res.mission.unassigned.lineCount).to.equal(1);
expect(res.mission.unassigned.sprayTimeS).to.equal(res.lines[0].sprayTimeS);
expect(res.mission.unassigned.lengthM).to.be.closeTo(res.lines[0].lengthM, 0.001);
});
it('does not leak into mission-level zone-derived totals', function () {
expect(res.mission.sprayedAreaM2).to.equal(0);
expect(res.mission.sprayDistanceM).to.equal(0);
expect(res.mission.zonesSprayed).to.equal(0);
});
});
describe('midnight wrap', function () {
const batch = sprayLine({ lat0: 0.001, lon0: 0.002, t0: 86390, llnum: 1 }); // wraps at i=10
// gpsTime must wrap 86399 -> 0
batch.forEach(p => { p.gpsTime = p.gpsTime % 86400; });
const res = run([zoneA()], [batch]);
it('spray time stays positive across the wrap', function () {
expect(res.lines[0].sprayTimeS).to.equal(19);
expect(res.mission.totalFlightS).to.equal(19);
});
});
describe('line order across a midnight wrap (FR-4.5)', function () {
// line 1 flies before midnight (large raw gpsTime), line 2 flies after (small raw gpsTime) —
// a plain numeric sort on startTimeS would wrongly put line 2 first
const beforeMidnight = sprayLine({ lat0: 0.001, lon0: 0.002, t0: 86390, llnum: 1, n: 5 });
const afterMidnight = sprayLine({ lat0: 0.001, lon0: 0.003, t0: 10, llnum: 2, n: 5 });
const res = run([zoneA()], [[...beforeMidnight, ...afterMidnight]]);
it('keeps the pre-midnight line before the post-midnight line', function () {
expect(res.lines.map(l => l.llnum)).to.deep.equal([1, 2]);
expect(res.lines[0].startTimeS).to.equal(86390);
expect(res.lines[1].startTimeS).to.equal(10);
});
});
describe('multiple files', function () {
const res = run([zoneA()], [
sprayLine({ lat0: 0.001, lon0: 0.002, t0: 1000, llnum: 1 }),
sprayLine({ lat0: 0.001, lon0: 0.004, t0: 5000, llnum: 2 }),
]);
it('flight time sums consecutive-point deltas — the inter-file gap is not flight time', function () {
expect(res.mission.totalFlightS).to.equal(38); // 19 + 19, not 4019
expect(res.lines).to.have.length(2);
});
});
describe('total flight time matches legacy\'s capped-delta convention (workers/job_worker.js:1447-1455)', function () {
// one continuous file: a spray segment, a 600s ground pause (refuel, GPS dropout — no
// fileBreak() in between), then more spraying. Legacy sums consecutive-point deltas and
// excludes any single gap >120s entirely, rather than taking a wall-clock first-to-last span.
const batch = [
...sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1, n: 5 }),
...sprayLine({ lat0: 0.001, lon0: 0.003, t0: 605, llnum: 1, n: 5 }),
];
const res = run([zoneA()], [batch]);
it('excludes the internal >120s gap from totalFlightS entirely', function () {
expect(res.mission.totalFlightS).to.equal(8); // 4 + 4, not 609
});
});
describe('volume integration also excludes a >120s gap (same legacy cap, all time accumulators)', function () {
// one continuous pass (same llnum, no marker reappears, no distance jump — so it never
// splits) with a 300s gap between two of its points, both with real lminApp readings
const pts = [
pt(0.001, 0.002, 0, 1, 3, { lminApp: 60 }),
pt(0.0011, 0.002, 1, 1, 1, { lminApp: 60 }), // interval: dt=1s, avg 60 -> 1 L
pt(0.0011, 0.002, 301, 1, 1, { lminApp: 60 }), // interval: dt=300s (>120s) -> must be excluded
pt(0.0012, 0.002, 302, 1, 1, { lminApp: 60 }), // interval: dt=1s, avg 60 -> 1 L
];
const res = run([zoneA()], [pts]);
it('excludes the 300s-gap interval\'s contribution from volumeL', function () {
expect(res.lines[0].volumeL).to.be.closeTo(2, 0.001); // 1 + 1, not 1 + 300 + 1
});
});
describe('single-zone job', function () {
const res = run([zoneA()], [sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 1 })]);
it('zone roll-up equals mission totals', function () {
expect(res.mission.sprayedAreaM2).to.equal(res.zones[0].sprayedAreaM2);
expect(res.mission.sprayTimeS).to.equal(res.zones[0].sprayTimeS);
expect(res.mission.coveragePct).to.be.closeTo(res.zones[0].coveragePct, 0.0001);
});
});
describe('first line logged with the llnum sentinel (Job #90 real-world case: first line recorded as 65535 instead of 1)', function () {
const res = run([zoneA()], [
[
...sprayLine({ lat0: 0.001, lon0: 0.002, t0: 0, llnum: 65535 }),
...sprayLine({ lat0: 0.001, lon0: 0.0035, t0: 30, llnum: 2 })
]
]);
it('normalizes the sentinel line number to 1, not 65535', function () {
expect(res.lines[0].llnum).to.equal(1);
expect(res.lines.some(l => l.llnum === 65535)).to.equal(false);
});
it('keeps it as a distinct line from a genuine line 2', function () {
expect(res.lines.length).to.equal(2);
expect(res.lines[1].llnum).to.equal(2);
});
});
describe('degenerate zero-length line (real Job #106 case: a single-point boundary fragment produced a nonsense "0 ft / 0 ac" report row)', function () {
// two points at the exact same position and gpsTime — a real (length()>1) pass, but
// with zero length and zero spray time
const res = run([zoneA()], [
[pt(0.001, 0.002, 1000, 1, 3), pt(0.001, 0.002, 1000, 1, 1)]
]);
it('is dropped from the reported lines instead of showing a 0 ft / 0 ac row', function () {
expect(res.lines).to.have.length(0);
});
it('does not inflate the zone\'s lineCount', function () {
expect(res.zones[0].lineCount).to.equal(0);
});
});
});