@lumikmz/kmz 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it } from "vite-plus/test";
2
2
  import { degrees, meters, metersPerSecond } from "../types/branded.js";
3
3
  import type { TargetDetectionTemplate } from "../types/template.js";
4
4
  import { plan } from "./index.js";
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it } from "vite-plus/test";
2
2
  import { degrees, meters, metersPerSecond } from "../types/branded.js";
3
3
  import { DroneEnum, PayloadEnum } from "../types/drone-payload.js";
4
4
  import type { Mapping2dTemplate, Mapping3dTemplate, WaypointTemplate } from "../types/template.js";
@@ -0,0 +1,241 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { selectStripRouteCandidates, type StripRouteCandidate } from "./strip-route-order.js";
3
+ import * as stripOrder from "./strip-route-order.js";
4
+
5
+ function route(patch: Partial<StripRouteCandidate> = {}): StripRouteCandidate {
6
+ return {
7
+ start: [0, 0],
8
+ end: [10, 0],
9
+ entryDirection: [1, 0],
10
+ exitDirection: [1, 0],
11
+ length: 10,
12
+ ...patch,
13
+ };
14
+ }
15
+
16
+ describe("strip region transition ordering", () => {
17
+ it("chooses a slightly longer transfer when it avoids reversing the entry heading", () => {
18
+ const previous = route({ end: [0, 0] });
19
+ const reverse = route({ start: [10, 0], entryDirection: [-1, 0] });
20
+ const aligned = route({ start: [12, 0] });
21
+ expect(selectStripRouteCandidates([[previous], [reverse, aligned]])).toEqual([0, 1]);
22
+ });
23
+
24
+ it("prefers approaching the shared junction over cutting across a bent strip", () => {
25
+ const diagonal = Math.SQRT1_2;
26
+ const previous = route({ end: [0, 0], exitDirection: [diagonal, diagonal] });
27
+ const across = route({ start: [10, 0], entryDirection: [1, 0] });
28
+ const along = route({ start: [0, 12], entryDirection: [0, 1] });
29
+ expect(selectStripRouteCandidates([[previous], [across, along]], undefined, [[0, 10]])).toEqual(
30
+ [0, 1],
31
+ );
32
+ });
33
+
34
+ it("includes the candidate's internal flight length", () => {
35
+ expect(selectStripRouteCandidates([[route({ length: 100 }), route({ length: 10 })]])).toEqual([
36
+ 1,
37
+ ]);
38
+ });
39
+
40
+ it("keeps heading preferences soft when the aligned transfer would be much longer", () => {
41
+ const previous = route({ end: [0, 0] });
42
+ expect(
43
+ selectStripRouteCandidates([
44
+ [previous],
45
+ [route({ start: [10, 0], entryDirection: [-1, 0] }), route({ start: [100, 0] })],
46
+ ]),
47
+ ).toEqual([0, 0]);
48
+ });
49
+
50
+ it("uses the exit and entry headings even when the endpoints coincide", () => {
51
+ const previous = route({ end: [0, 0] });
52
+ expect(
53
+ selectStripRouteCandidates([[previous], [route({ entryDirection: [-1, 0] }), route()]]),
54
+ ).toEqual([0, 1]);
55
+ });
56
+
57
+ it("optimizes the entire sequence instead of taking the locally closest start", () => {
58
+ const first = route({ end: [0, 0] });
59
+ const near = route({ start: [1, 0], end: [100, 0] });
60
+ const connected = route({ start: [2, 0], end: [5, 0] });
61
+ const last = route({ start: [6, 0] });
62
+ expect(selectStripRouteCandidates([[first], [near, connected], [last]])).toEqual([0, 1, 0]);
63
+ });
64
+
65
+ it("uses the takeoff point when selecting the first orientation", () => {
66
+ expect(selectStripRouteCandidates([[route({ start: [100, 0] }), route()]], [0, 0])).toEqual([
67
+ 1,
68
+ ]);
69
+ });
70
+
71
+ it("handles coincident region endpoints without undefined directions", () => {
72
+ const groups = [[route({ end: [0, 0] })], [route(), route()]];
73
+ expect(selectStripRouteCandidates(groups, undefined, [[0, 0]])).toEqual([0, 0]);
74
+ expect(selectStripRouteCandidates([])).toEqual([]);
75
+ });
76
+
77
+ it("includes the final home leg only when the finish action requests it", () => {
78
+ const groups = [[route({ end: [100, 0], length: 100 }), route({ end: [1, 0], length: 101 })]];
79
+ expect(
80
+ selectStripRouteCandidates(groups, [0, 0], [], {
81
+ flightSpeed: 1,
82
+ transitSpeed: 1,
83
+ finishAction: "goHome",
84
+ }),
85
+ ).toEqual([1]);
86
+ for (const finishAction of ["noAction", "autoLand"] as const) {
87
+ expect(
88
+ selectStripRouteCandidates(groups, [0, 0], [], {
89
+ flightSpeed: 1,
90
+ transitSpeed: 1,
91
+ finishAction,
92
+ }),
93
+ ).toEqual([0]);
94
+ }
95
+ });
96
+
97
+ it("uses different survey and transit speeds instead of minimizing distance", () => {
98
+ const groups = [
99
+ [route({ end: [0, 0], length: 200 }), route({ start: [100, 0], end: [200, 0], length: 100 })],
100
+ ];
101
+ expect(
102
+ selectStripRouteCandidates(groups, [0, 0], [], {
103
+ flightSpeed: 1,
104
+ transitSpeed: 10,
105
+ finishAction: "noAction",
106
+ }),
107
+ ).toEqual([1]);
108
+ expect(
109
+ selectStripRouteCandidates(groups, [0, 0], [], {
110
+ flightSpeed: 1,
111
+ transitSpeed: 1,
112
+ finishAction: "noAction",
113
+ }),
114
+ ).toEqual([0]);
115
+ });
116
+
117
+ it("prefers the nearer entry when total flying times differ by at most two seconds", () => {
118
+ expect(
119
+ selectStripRouteCandidates(
120
+ [
121
+ [
122
+ route({ start: [100, 0], end: [200, 0], length: 100 }),
123
+ route({ start: [0, 0], end: [210, 0], length: 210 }),
124
+ ],
125
+ ],
126
+ [0, 0],
127
+ [],
128
+ { flightSpeed: 10, transitSpeed: 10, finishAction: "noAction" },
129
+ ),
130
+ ).toEqual([1]);
131
+ });
132
+
133
+ it("does not sacrifice a substantial time saving just to start closer", () => {
134
+ expect(
135
+ selectStripRouteCandidates(
136
+ [
137
+ [
138
+ route({ start: [100, 0], end: [200, 0], length: 100 }),
139
+ route({ start: [0, 0], end: [250, 0], length: 250 }),
140
+ ],
141
+ ],
142
+ [0, 0],
143
+ [],
144
+ { flightSpeed: 10, transitSpeed: 10, finishAction: "noAction" },
145
+ ),
146
+ ).toEqual([0]);
147
+ });
148
+
149
+ it("retains the nearer first entry after both paths reach the same later candidates", () => {
150
+ const groups = [
151
+ [
152
+ route({
153
+ start: [100, 0],
154
+ end: [0, 0],
155
+ entryDirection: [-1, 0],
156
+ exitDirection: [-1, 0],
157
+ length: 100,
158
+ }),
159
+ route({ end: [0, 0], length: 120 }),
160
+ ],
161
+ [route({ start: [0, 0], end: [100, 0], length: 100 })],
162
+ [route({ start: [100, 0], end: [200, 0], length: 100 })],
163
+ ];
164
+ expect(
165
+ selectStripRouteCandidates(groups, [0, 0], [], {
166
+ flightSpeed: 10,
167
+ transitSpeed: 100,
168
+ finishAction: "goHome",
169
+ }),
170
+ ).toEqual([1, 0, 0]);
171
+ });
172
+
173
+ it("applies the two-second tolerance once to the complete mission, not once per region", () => {
174
+ const groups = [[route({ end: [0, 0], length: 100 })]];
175
+ for (let index = 0; index < 4; index++)
176
+ groups.push([
177
+ route({ end: [0, 0], length: 100, entryDirection: [-1, 0], exitDirection: [-1, 0] }),
178
+ route({ end: [0, 0], length: 101.5 }),
179
+ ]);
180
+ const selected = selectStripRouteCandidates(groups);
181
+ const duration = groups.reduce((sum, group, index) => sum + group[selected[index]!]!.length, 0);
182
+ expect(duration).toBeLessThanOrEqual(502);
183
+ });
184
+
185
+ it("returns to the first waypoint without needing a takeoff reference", () => {
186
+ const groups = [[route({ end: [100, 0], length: 100 }), route({ end: [1, 0], length: 101 })]];
187
+ expect(
188
+ selectStripRouteCandidates(groups, undefined, [], {
189
+ flightSpeed: 1,
190
+ transitSpeed: 1,
191
+ finishAction: "gotoFirstWaypoint",
192
+ }),
193
+ ).toEqual([1]);
194
+ expect(
195
+ selectStripRouteCandidates(groups, undefined, [], {
196
+ flightSpeed: 1,
197
+ transitSpeed: 1,
198
+ finishAction: "goHome",
199
+ }),
200
+ ).toEqual([0]);
201
+ });
202
+
203
+ it("measures survey, ingress, between-region and finish legs with their respective speeds", () => {
204
+ const routes = [
205
+ route({ start: [10, 0], end: [30, 0], length: 20 }),
206
+ route({ start: [50, 0], end: [80, 0], length: 30 }),
207
+ ];
208
+ for (const [finishAction, distance, duration] of [
209
+ ["goHome", 160, 10.5],
210
+ ["noAction", 80, 6.5],
211
+ ["gotoFirstWaypoint", 150, 10],
212
+ ] as const) {
213
+ expect(
214
+ stripOrder.measureStripMission(routes, [0, 0], {
215
+ flightSpeed: 10,
216
+ transitSpeed: 20,
217
+ finishAction,
218
+ }),
219
+ ).toMatchObject({ distance, duration });
220
+ }
221
+ });
222
+
223
+ it("rejects invalid speeds instead of producing non-finite costs", () => {
224
+ for (const speed of [0, -1, NaN, Infinity]) {
225
+ expect(() =>
226
+ selectStripRouteCandidates([[route()]], [0, 0], [], {
227
+ flightSpeed: speed,
228
+ transitSpeed: 10,
229
+ finishAction: "goHome",
230
+ }),
231
+ ).toThrow();
232
+ expect(() =>
233
+ selectStripRouteCandidates([[route()]], [0, 0], [], {
234
+ flightSpeed: 10,
235
+ transitSpeed: speed,
236
+ finishAction: "goHome",
237
+ }),
238
+ ).toThrow();
239
+ }
240
+ });
241
+ });
@@ -0,0 +1,192 @@
1
+ import type { EnuPoint } from "./geometry.js";
2
+ import type { FinishAction } from "../types/enums.js";
3
+ import { PlannerError } from "../types/errors.js";
4
+
5
+ // Secondary route-shape preferences, never added to the estimated flying time.
6
+ // A complete reversal costs 20 preference units; junction detours remain soft.
7
+ const HEADING_COST_M = 10;
8
+ const JUNCTION_DETOUR_WEIGHT = 0.5;
9
+ const NEAR_EQUAL_TIME_S = 2;
10
+ const TIME_EPS_S = 1e-8;
11
+
12
+ const distance = (a: EnuPoint, b: EnuPoint) => Math.hypot(a[0] - b[0], a[1] - b[1]);
13
+ const headingChange = (a: EnuPoint, b: EnuPoint) =>
14
+ 1 - Math.max(-1, Math.min(1, a[0] * b[0] + a[1] * b[1]));
15
+
16
+ export interface StripRouteCandidate {
17
+ start: EnuPoint;
18
+ end: EnuPoint;
19
+ entryDirection: EnuPoint;
20
+ exitDirection: EnuPoint;
21
+ length: number;
22
+ }
23
+
24
+ export interface StripRouteTiming {
25
+ flightSpeed: number;
26
+ transitSpeed: number;
27
+ finishAction: FinishAction;
28
+ }
29
+
30
+ const DEFAULT_TIMING: StripRouteTiming = {
31
+ flightSpeed: 1,
32
+ transitSpeed: 1,
33
+ finishAction: "noAction",
34
+ };
35
+
36
+ function validateTiming(timing: StripRouteTiming): void {
37
+ if (
38
+ ![timing.flightSpeed, timing.transitSpeed].every((speed) => Number.isFinite(speed) && speed > 0)
39
+ )
40
+ throw new PlannerError(
41
+ "mappingStrip flight and transit speeds must be finite and greater than zero",
42
+ );
43
+ }
44
+
45
+ function finishDistance(
46
+ end: EnuPoint,
47
+ first: EnuPoint,
48
+ home: EnuPoint | undefined,
49
+ finish: FinishAction,
50
+ ): number {
51
+ const target = finish === "goHome" ? home : finish === "gotoFirstWaypoint" ? first : undefined;
52
+ return target ? distance(end, target) : 0;
53
+ }
54
+
55
+ /** Horizontal-flight estimate; vertical motion, wind and acceleration are not modeled. */
56
+ export function measureStripMission(
57
+ routes: readonly Pick<StripRouteCandidate, "start" | "end" | "length">[],
58
+ home: EnuPoint | undefined,
59
+ timing: StripRouteTiming,
60
+ ): { distance: number; duration: number } {
61
+ validateTiming(timing);
62
+ if (routes.length === 0) return { distance: 0, duration: 0 };
63
+ const first = routes[0]!.start;
64
+ const workLength = routes.reduce((sum, route) => sum + route.length, 0);
65
+ let transitLength = home ? distance(home, first) : 0;
66
+ for (let index = 1; index < routes.length; index++)
67
+ transitLength += distance(routes[index - 1]!.end, routes[index]!.start);
68
+ transitLength += finishDistance(routes.at(-1)!.end, first, home, timing.finishAction);
69
+ return {
70
+ distance: workLength + transitLength,
71
+ duration: workLength / timing.flightSpeed + transitLength / timing.transitSpeed,
72
+ };
73
+ }
74
+
75
+ function transitionPreference(
76
+ previous: StripRouteCandidate,
77
+ next: StripRouteCandidate,
78
+ junction?: EnuPoint,
79
+ ): number {
80
+ const length = distance(previous.end, next.start);
81
+ const direction: EnuPoint | undefined =
82
+ length > 0.05
83
+ ? [(next.start[0] - previous.end[0]) / length, (next.start[1] - previous.end[1]) / length]
84
+ : undefined;
85
+ const turn = direction
86
+ ? headingChange(previous.exitDirection, direction) +
87
+ headingChange(direction, next.entryDirection)
88
+ : headingChange(previous.exitDirection, next.entryDirection);
89
+ const detour = junction
90
+ ? Math.max(0, distance(previous.end, junction) + distance(junction, next.start) - length)
91
+ : 0;
92
+ return HEADING_COST_M * turn + JUNCTION_DETOUR_WEIGHT * detour;
93
+ }
94
+
95
+ interface FinalChoice {
96
+ lastIndex: number;
97
+ time: number;
98
+ preference: number;
99
+ ingress: number;
100
+ back: number[][];
101
+ }
102
+
103
+ /** Optimize mission time, then favor nearer entries within a two-second total budget. */
104
+ export function selectStripRouteCandidates(
105
+ groups: readonly (readonly StripRouteCandidate[])[],
106
+ home?: EnuPoint,
107
+ junctions: readonly EnuPoint[] = [],
108
+ timing: StripRouteTiming = DEFAULT_TIMING,
109
+ ): number[] {
110
+ if (groups.length === 0) return [];
111
+ validateTiming(timing);
112
+ if (groups.some((group) => group.length === 0))
113
+ throw new PlannerError("mappingStrip region has no route candidates");
114
+ const finals: FinalChoice[] = [];
115
+
116
+ // Keep each possible first entry separate. Otherwise a local near-equal-time
117
+ // tie can discard the globally fastest path or accumulate a tolerance per cut.
118
+ groups[0]!.forEach((first, firstIndex) => {
119
+ const ingress = home ? distance(home, first.start) : 0;
120
+ let times = groups[0]!.map((candidate, index) =>
121
+ index === firstIndex
122
+ ? candidate.length / timing.flightSpeed + ingress / timing.transitSpeed
123
+ : Infinity,
124
+ );
125
+ let preferences = groups[0]!.map((_, index) => (index === firstIndex ? 0 : Infinity));
126
+ const back = [groups[0]!.map(() => -1)];
127
+ for (let index = 1; index < groups.length; index++) {
128
+ const parents: number[] = [];
129
+ const nextPreferences: number[] = [];
130
+ const nextTimes = groups[index]!.map((candidate) => {
131
+ let bestTime = Infinity;
132
+ let bestPreference = Infinity;
133
+ let parent = 0;
134
+ groups[index - 1]!.forEach((previous, previousIndex) => {
135
+ const time =
136
+ times[previousIndex]! +
137
+ candidate.length / timing.flightSpeed +
138
+ distance(previous.end, candidate.start) / timing.transitSpeed;
139
+ const preference =
140
+ preferences[previousIndex]! +
141
+ transitionPreference(previous, candidate, junctions[index - 1]);
142
+ if (
143
+ time < bestTime - TIME_EPS_S ||
144
+ (Math.abs(time - bestTime) <= TIME_EPS_S && preference < bestPreference)
145
+ ) {
146
+ bestTime = time;
147
+ bestPreference = preference;
148
+ parent = previousIndex;
149
+ }
150
+ });
151
+ parents.push(parent);
152
+ nextPreferences.push(bestPreference);
153
+ return bestTime;
154
+ });
155
+ times = nextTimes;
156
+ preferences = nextPreferences;
157
+ back.push(parents);
158
+ }
159
+ groups.at(-1)!.forEach((last, lastIndex) => {
160
+ if (!Number.isFinite(times[lastIndex])) return;
161
+ finals.push({
162
+ lastIndex,
163
+ ingress,
164
+ back,
165
+ time:
166
+ times[lastIndex]! +
167
+ finishDistance(last.end, first.start, home, timing.finishAction) / timing.transitSpeed,
168
+ preference: preferences[lastIndex]!,
169
+ });
170
+ });
171
+ });
172
+ const fastest = Math.min(...finals.map((choice) => choice.time));
173
+ const close = finals.filter((choice) => choice.time <= fastest + NEAR_EQUAL_TIME_S + TIME_EPS_S);
174
+ const chosen = close.reduce((best, candidate) => {
175
+ if (candidate.ingress < best.ingress - 0.05) return candidate;
176
+ if (Math.abs(candidate.ingress - best.ingress) > 0.05) return best;
177
+ if (candidate.preference < best.preference - TIME_EPS_S) return candidate;
178
+ if (
179
+ Math.abs(candidate.preference - best.preference) <= TIME_EPS_S &&
180
+ candidate.time < best.time
181
+ )
182
+ return candidate;
183
+ return best;
184
+ });
185
+ let selected = chosen.lastIndex;
186
+ const result = groups.map(() => 0);
187
+ for (let index = groups.length - 1; index >= 0; index--) {
188
+ result[index] = selected;
189
+ selected = chosen.back[index]![selected]!;
190
+ }
191
+ return result;
192
+ }
package/vite.config.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import { defineConfig } from "vite-plus";
2
2
 
3
3
  export default defineConfig({
4
+ test: {
5
+ // Vitest v4 compatibility: preserve mock call history.
6
+ // Remove after tests no longer rely on calls from setup or earlier tests.
7
+ // https://viteplus.dev/guide/vitest-v5#remove-unneeded-compatibility-settings
8
+ // https://vitest.dev/guide/migration/#clearmocks-is-enabled-by-default
9
+ clearMocks: false,
10
+ },
4
11
  pack: {
5
12
  entry: ["src/index.ts"],
6
13
  format: "esm",