@meri-imperiumi/signalk-passage-briefing 0.2.0
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.
- package/.editorconfig +5 -0
- package/.github/workflows/publish.yml +34 -0
- package/.github/workflows/signalk-ci.yml +11 -0
- package/.github/workflows/test.yml +21 -0
- package/CHANGELOG.md +423 -0
- package/README.md +86 -0
- package/SPEC.md +488 -0
- package/bin/backfill-report.js +218 -0
- package/bin/backtest-cli.js +132 -0
- package/biome.json +6 -0
- package/doc/here-brief.png +0 -0
- package/package.json +48 -0
- package/plugin/backtest.js +742 -0
- package/plugin/brief-ext.js +173 -0
- package/plugin/bulletin-engine.js +746 -0
- package/plugin/bulletin-source.js +150 -0
- package/plugin/celestial-source.js +417 -0
- package/plugin/fetch-engine.js +717 -0
- package/plugin/history-backfill.js +540 -0
- package/plugin/index.js +1479 -0
- package/plugin/logbook-source.js +463 -0
- package/plugin/notes-publisher.js +228 -0
- package/plugin/notes-store.js +241 -0
- package/plugin/raster-convert.js +168 -0
- package/plugin/sails-configuration.js +111 -0
- package/plugin/spool-watcher.js +218 -0
- package/plugin/sqlite-db.js +378 -0
- package/plugin/state-machine.js +249 -0
- package/plugin/statustilesexamples.js +101 -0
- package/plugin/synoptic-map.json +45 -0
- package/plugin/synoptic-source.js +227 -0
- package/plugin/zone-source.js +339 -0
- package/public/app.js +18 -0
- package/public/brief-ext-model.js +64 -0
- package/public/brief-ext-widget.html +14 -0
- package/public/brief-ext-widget.js +294 -0
- package/public/components/backfill-controls.js +100 -0
- package/public/components/comfort-info.js +94 -0
- package/public/components/conditions-here.js +234 -0
- package/public/components/horizon-sparkline.js +77 -0
- package/public/components/models.mjs +372 -0
- package/public/components/passage-outlook.js +368 -0
- package/public/components/sk-api.js +146 -0
- package/public/components/sk-base-css.js +177 -0
- package/public/components/strategic-outlook.js +248 -0
- package/public/components/synoptic-chart.js +48 -0
- package/public/components/tactical-dashboard.js +172 -0
- package/public/css/visuals.css +292 -0
- package/public/gmdss-zones-min.json +287 -0
- package/public/icon.png +0 -0
- package/public/index.html +13 -0
- package/public/polar.mjs +303 -0
- package/public/route-sim.mjs +750 -0
- package/public/sereno-physics.mjs +592 -0
- package/public/tack-gybe.js +174 -0
- package/public/vendor/plotterext-bus/LICENSE +21 -0
- package/public/vendor/plotterext-bus/README.md +17 -0
- package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
- package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
- package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
- package/public/vendor/plotterext-bus/extension.js +29 -0
- package/public/vendor/plotterext-bus/host.js +28 -0
- package/public/vendor/utif/LICENSE +21 -0
- package/public/vendor/utif/UTIF.js +1171 -0
- package/public/worker.js +35 -0
- package/status-tiles-examples.json +55 -0
- package/tests/backtest.test.js +461 -0
- package/tests/brief-ext.test.js +194 -0
- package/tests/bulletin-engine.test.js +361 -0
- package/tests/celestial-source.test.js +265 -0
- package/tests/fetch-engine.test.js +297 -0
- package/tests/history-backfill.test.js +341 -0
- package/tests/logbook-source.test.js +324 -0
- package/tests/notes-publisher.test.js +161 -0
- package/tests/notes-store.test.js +138 -0
- package/tests/openmeteo-mock.js +100 -0
- package/tests/plugin.test.js +1344 -0
- package/tests/route-sim.test.js +533 -0
- package/tests/sereno-physics.test.js +333 -0
- package/tests/sqlite-db.test.js +164 -0
- package/tests/state-machine.test.js +182 -0
- package/tests/statustilesexamples.test.js +86 -0
- package/tests/synoptic-source.test.js +195 -0
- package/tests/tack-gybe.test.js +211 -0
- package/tests/webapp.test.js +257 -0
- package/tests/zone-source.test.js +226 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
const { test, describe } = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { sampleRoutePoints } = require("../plugin/fetch-engine.js");
|
|
4
|
+
|
|
5
|
+
const simPromise = import("../public/route-sim.mjs");
|
|
6
|
+
const polarPromise = import("../public/polar.mjs");
|
|
7
|
+
|
|
8
|
+
const approx = (actual, expected, epsilon = 1e-6) =>
|
|
9
|
+
assert.ok(
|
|
10
|
+
Math.abs(actual - expected) < epsilon,
|
|
11
|
+
`expected ${actual} ≈ ${expected} (±${epsilon})`,
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Builds a UnifiedWeatherPayload fixture along a 180 nm meridian
|
|
16
|
+
* route with steady conditions.
|
|
17
|
+
*/
|
|
18
|
+
function buildPayload({
|
|
19
|
+
hours = 72,
|
|
20
|
+
tws = 10,
|
|
21
|
+
twd = 45,
|
|
22
|
+
hs = 1.5,
|
|
23
|
+
tp = 8,
|
|
24
|
+
cape = 100,
|
|
25
|
+
kIndex = 20,
|
|
26
|
+
drift = 0,
|
|
27
|
+
set = 0,
|
|
28
|
+
steepHour = null,
|
|
29
|
+
convectiveHour = null,
|
|
30
|
+
} = {}) {
|
|
31
|
+
const track = sampleRoutePoints([
|
|
32
|
+
[0, 0],
|
|
33
|
+
[0, 3],
|
|
34
|
+
]);
|
|
35
|
+
const waypoints = track.map((w) => ({
|
|
36
|
+
...w,
|
|
37
|
+
forecasts: Array.from({ length: hours }, (_, h) => {
|
|
38
|
+
const steep = steepHour != null && h === steepHour;
|
|
39
|
+
const convective = convectiveHour != null && h === convectiveHour;
|
|
40
|
+
return {
|
|
41
|
+
timestamp: new Date(Date.UTC(2026, 5, 21, h)).toISOString(),
|
|
42
|
+
surface: {
|
|
43
|
+
tws,
|
|
44
|
+
twd,
|
|
45
|
+
mslp: 1013,
|
|
46
|
+
gust: tws + 5,
|
|
47
|
+
},
|
|
48
|
+
marine: {
|
|
49
|
+
hsCombined: steep ? 3 : hs,
|
|
50
|
+
tpCombined: steep ? 8 : tp,
|
|
51
|
+
dirCombined: 45,
|
|
52
|
+
hsSwell: hs * 0.6,
|
|
53
|
+
tpSwell: tp + 1,
|
|
54
|
+
dirSwell: 50,
|
|
55
|
+
hsWindSea: hs * 0.4,
|
|
56
|
+
tpWindSea: 5,
|
|
57
|
+
dirWindSea: 40,
|
|
58
|
+
},
|
|
59
|
+
upperAir: {
|
|
60
|
+
cape: convective ? 1800 : cape,
|
|
61
|
+
kIndex: convective ? 30 : kIndex,
|
|
62
|
+
rh700: 55,
|
|
63
|
+
wind850kts: tws + 8,
|
|
64
|
+
},
|
|
65
|
+
current: { drift, set },
|
|
66
|
+
};
|
|
67
|
+
}),
|
|
68
|
+
}));
|
|
69
|
+
return {
|
|
70
|
+
metadata: {
|
|
71
|
+
fetchedAt: "2026-06-21T00:00:00.000Z",
|
|
72
|
+
source: "api",
|
|
73
|
+
models: ["forecast:best_match"],
|
|
74
|
+
},
|
|
75
|
+
waypoints,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const MATRIX = {
|
|
80
|
+
twsBinsKnots: [0, 5, 10, 15, 20, 25, 30, 35, 40],
|
|
81
|
+
twaBinsDegrees: [0, 30, 60, 90, 120, 150, 180],
|
|
82
|
+
matrix: [
|
|
83
|
+
{
|
|
84
|
+
twsBin: 2,
|
|
85
|
+
twaBin: 1,
|
|
86
|
+
night: false,
|
|
87
|
+
preferredSailState: "GENOA_1_MAIN",
|
|
88
|
+
minTwsGustTrigger: 12,
|
|
89
|
+
samplesCount: 3,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
twsBin: 2,
|
|
93
|
+
twaBin: 1,
|
|
94
|
+
night: true,
|
|
95
|
+
preferredSailState: "STAYSAIL_MAIN_1_REEF",
|
|
96
|
+
minTwsGustTrigger: 12,
|
|
97
|
+
samplesCount: 4,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
describe("weatherAt", () => {
|
|
103
|
+
test("interpolates linearly between hours", async () => {
|
|
104
|
+
const { weatherAt } = await simPromise;
|
|
105
|
+
const forecasts = [
|
|
106
|
+
{
|
|
107
|
+
timestamp: "2026-06-21T00:00:00.000Z",
|
|
108
|
+
surface: { tws: 12, twd: 350, mslp: 1013, gust: 17 },
|
|
109
|
+
marine: { hsCombined: 1, tpCombined: 8 },
|
|
110
|
+
upperAir: { cape: 100 },
|
|
111
|
+
current: { drift: 0.5, set: 90 },
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
timestamp: "2026-06-21T01:00:00.000Z",
|
|
115
|
+
surface: { tws: 14, twd: 10, mslp: 1012, gust: 19 },
|
|
116
|
+
marine: { hsCombined: 2, tpCombined: 9 },
|
|
117
|
+
upperAir: { cape: 200 },
|
|
118
|
+
current: { drift: 1, set: 90 },
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
const mid = weatherAt(forecasts, new Date("2026-06-21T00:30:00Z"));
|
|
122
|
+
approx(mid.surface.tws, 13);
|
|
123
|
+
// Angle interpolation goes the short way through north: 350 → 360/0 → 10
|
|
124
|
+
approx(mid.surface.twd, 0);
|
|
125
|
+
approx(mid.marine.hsCombined, 1.5);
|
|
126
|
+
approx(mid.current.drift, 0.75);
|
|
127
|
+
assert.equal(mid.surface.gust, 18);
|
|
128
|
+
|
|
129
|
+
const before = weatherAt(forecasts, new Date("2026-06-20T23:00:00Z"));
|
|
130
|
+
assert.equal(before.clamped, true);
|
|
131
|
+
approx(before.surface.tws, 12);
|
|
132
|
+
|
|
133
|
+
const after = weatherAt(forecasts, new Date("2026-06-21T05:00:00Z"));
|
|
134
|
+
assert.equal(after.clamped, true);
|
|
135
|
+
approx(after.surface.tws, 14);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("simulateRun", () => {
|
|
140
|
+
const START = new Date("2026-06-21T06:00:00Z"); // Daytime at lon 0
|
|
141
|
+
|
|
142
|
+
test("sails the route and arrives within expectations", async () => {
|
|
143
|
+
const { simulateRun } = await simPromise;
|
|
144
|
+
const run = simulateRun({
|
|
145
|
+
payload: buildPayload(),
|
|
146
|
+
startTime: START,
|
|
147
|
+
config: {},
|
|
148
|
+
});
|
|
149
|
+
// ~180 nm at ~5.7 kn close reach
|
|
150
|
+
assert.ok(run.etaHours > 25 && run.etaHours < 40, `eta ${run.etaHours}`);
|
|
151
|
+
assert.equal(run.motoringHours, 0);
|
|
152
|
+
assert.equal(run.fuelGal, 0);
|
|
153
|
+
assert.ok(run.hourly.length > 20);
|
|
154
|
+
assert.ok(run.hourly[0].stwKnots > 3, "under sail");
|
|
155
|
+
assert.equal(run.hourly[0].motoring, false);
|
|
156
|
+
// Comfort evaluated every hour
|
|
157
|
+
for (const block of run.hourly) {
|
|
158
|
+
assert.ok(
|
|
159
|
+
["champagne", "easy", "coffee", "rough", "sick"].includes(
|
|
160
|
+
block.comfortLevel,
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("drift mode: below the wind threshold the boat drifts with zero fuel", async () => {
|
|
167
|
+
const { simulateRun } = await simPromise;
|
|
168
|
+
const run = simulateRun({
|
|
169
|
+
payload: buildPayload(),
|
|
170
|
+
startTime: START,
|
|
171
|
+
config: { motoring_tws_threshold: 20, drift_mode_enabled: true },
|
|
172
|
+
maxHours: 48,
|
|
173
|
+
});
|
|
174
|
+
assert.equal(run.etaHours, 48);
|
|
175
|
+
assert.equal(run.hourly[0].stwKnots, 0);
|
|
176
|
+
assert.equal(run.motoringHours, 0);
|
|
177
|
+
assert.equal(run.fuelGal, 0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("motor mode: below the threshold the engine pushes at 4.5 kn", async () => {
|
|
181
|
+
const { simulateRun } = await simPromise;
|
|
182
|
+
const run = simulateRun({
|
|
183
|
+
payload: buildPayload(),
|
|
184
|
+
startTime: START,
|
|
185
|
+
config: { motoring_tws_threshold: 20, drift_mode_enabled: false },
|
|
186
|
+
});
|
|
187
|
+
// 180 nm at 4.5 kn ≈ 40 h, burning 0.8 gal/h
|
|
188
|
+
assert.ok(run.etaHours > 35 && run.etaHours < 45, `eta ${run.etaHours}`);
|
|
189
|
+
assert.equal(run.hourly[0].stwKnots, 4.5);
|
|
190
|
+
assert.ok(run.motoringHours > 35);
|
|
191
|
+
assert.ok(run.fuelGal > 30 && run.fuelGal < 34, `fuel ${run.fuelGal}`);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("learns sail suggestions from the matrix, day and night bucketed", async () => {
|
|
195
|
+
const { simulateRun } = await simPromise;
|
|
196
|
+
const day = simulateRun({
|
|
197
|
+
payload: buildPayload(),
|
|
198
|
+
startTime: START,
|
|
199
|
+
matrix: MATRIX,
|
|
200
|
+
maxHours: 2,
|
|
201
|
+
});
|
|
202
|
+
assert.equal(day.sailEvents[0].sailState, "GENOA_1_MAIN");
|
|
203
|
+
assert.equal(day.sailEvents[0].night, false);
|
|
204
|
+
|
|
205
|
+
// Equator, 22:00 UTC: after sunset
|
|
206
|
+
const night = simulateRun({
|
|
207
|
+
payload: buildPayload(),
|
|
208
|
+
startTime: new Date("2026-06-21T22:00:00Z"),
|
|
209
|
+
matrix: MATRIX,
|
|
210
|
+
maxHours: 2,
|
|
211
|
+
});
|
|
212
|
+
assert.equal(night.sailEvents[0].sailState, "STAYSAIL_MAIN_1_REEF");
|
|
213
|
+
assert.equal(night.sailEvents[0].night, true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("collects steep-sea and convective anomalies", async () => {
|
|
217
|
+
const { simulateRun } = await simPromise;
|
|
218
|
+
const run = simulateRun({
|
|
219
|
+
payload: buildPayload({ steepHour: 7, convectiveHour: 9 }),
|
|
220
|
+
startTime: START,
|
|
221
|
+
maxHours: 12,
|
|
222
|
+
});
|
|
223
|
+
assert.ok(run.seaStateAnomalies.length >= 1);
|
|
224
|
+
assert.ok(run.seaStateAnomalies[0].steepnessRatio < 3.28);
|
|
225
|
+
assert.ok(run.upperAirAnomalies.some((a) => a.cape > 1000));
|
|
226
|
+
assert.ok(run.upperAirAnomalies.some((a) => a.kIndex > 28));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("current pushes the boat off the heading line", async () => {
|
|
230
|
+
const { simulateRun } = await simPromise;
|
|
231
|
+
const withCurrent = simulateRun({
|
|
232
|
+
payload: buildPayload({ drift: 1, set: 90 }), // 1 kn set east
|
|
233
|
+
startTime: START,
|
|
234
|
+
maxHours: 6,
|
|
235
|
+
});
|
|
236
|
+
// Heading north with current set east: the boat makes easting
|
|
237
|
+
assert.ok(
|
|
238
|
+
withCurrent.hourly[0].lon > 0.005,
|
|
239
|
+
`lon ${withCurrent.hourly[0].lon}`,
|
|
240
|
+
);
|
|
241
|
+
// SOG exceeds STW when the current is (partly) fair
|
|
242
|
+
assert.ok(withCurrent.hourly[0].sogKnots > withCurrent.hourly[0].stwKnots);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("hazardAlerts", () => {
|
|
247
|
+
test("point and polygon notes raise alerts, distant ones do not", async () => {
|
|
248
|
+
const { hazardAlerts } = await simPromise;
|
|
249
|
+
const positions = [
|
|
250
|
+
{ hoursFromNow: 1, timestamp: "2026-06-21T07:00:00Z", lat: 1.0, lon: 0 },
|
|
251
|
+
{ hoursFromNow: 2, timestamp: "2026-06-21T08:00:00Z", lat: 2.0, lon: 0 },
|
|
252
|
+
];
|
|
253
|
+
const notes = [
|
|
254
|
+
{
|
|
255
|
+
id: "near",
|
|
256
|
+
description: "Fishing grounds",
|
|
257
|
+
position: { latitude: 1.0, longitude: 0.02 },
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: "area",
|
|
261
|
+
description: "Firing range",
|
|
262
|
+
feature: {
|
|
263
|
+
geometry: {
|
|
264
|
+
type: "Polygon",
|
|
265
|
+
coordinates: [
|
|
266
|
+
[
|
|
267
|
+
[-0.5, 1.9],
|
|
268
|
+
[-0.5, 2.1],
|
|
269
|
+
[0.5, 2.1],
|
|
270
|
+
[0.5, 1.9],
|
|
271
|
+
[-0.5, 1.9],
|
|
272
|
+
],
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: "far",
|
|
279
|
+
description: "Far away",
|
|
280
|
+
position: { latitude: 5.0, longitude: 5.0 },
|
|
281
|
+
},
|
|
282
|
+
];
|
|
283
|
+
const alerts = hazardAlerts(notes, positions);
|
|
284
|
+
assert.deepEqual(
|
|
285
|
+
alerts.map((a) => a.noteId),
|
|
286
|
+
["near", "area"],
|
|
287
|
+
);
|
|
288
|
+
assert.equal(alerts[0].hoursFromNow, 1);
|
|
289
|
+
assert.equal(alerts[0].distanceNm, 1.2); // 0.02° lon at lat 1
|
|
290
|
+
assert.equal(alerts[1].distanceNm, 0); // Containment
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("pointInPolygon ray casting", async () => {
|
|
294
|
+
const { pointInPolygon } = await simPromise;
|
|
295
|
+
const square = [
|
|
296
|
+
[0, 1],
|
|
297
|
+
[1, 1],
|
|
298
|
+
[1, 2],
|
|
299
|
+
[0, 2],
|
|
300
|
+
[0, 1],
|
|
301
|
+
];
|
|
302
|
+
assert.equal(pointInPolygon(1.5, 0.5, square), true);
|
|
303
|
+
assert.equal(pointInPolygon(1.5, 1.5, square), false);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("simulatePassage", () => {
|
|
308
|
+
test("eta percentiles ordered, energy summarized, exceptions filtered", async () => {
|
|
309
|
+
const { filterExceptions, simulatePassage } = await simPromise;
|
|
310
|
+
const startTime = new Date("2026-06-21T06:00:00Z");
|
|
311
|
+
const result = simulatePassage({
|
|
312
|
+
payload: buildPayload(),
|
|
313
|
+
startTime,
|
|
314
|
+
matrix: MATRIX,
|
|
315
|
+
energyHourly: Array.from({ length: 30 }, (_, h) => ({
|
|
316
|
+
timestamp: new Date(Date.UTC(2026, 5, 21, 6 + h)).toISOString(),
|
|
317
|
+
solarWh: 500,
|
|
318
|
+
loadWh: 600,
|
|
319
|
+
})),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const p10 = new Date(result.eta.p10).getTime();
|
|
323
|
+
const p50 = new Date(result.eta.p50).getTime();
|
|
324
|
+
const p90 = new Date(result.eta.p90).getTime();
|
|
325
|
+
assert.ok(p10 <= p50 && p50 <= p90, `${p10} <= ${p50} <= ${p90}`);
|
|
326
|
+
|
|
327
|
+
// 24 h × (500 − 600) Wh
|
|
328
|
+
assert.equal(result.energy.netSolar24h, 12);
|
|
329
|
+
assert.equal(result.energy.netBalance24h, -2.4);
|
|
330
|
+
|
|
331
|
+
const exceptions = filterExceptions(result);
|
|
332
|
+
assert.ok(exceptions.next24h.comfortBlocks.length <= 24);
|
|
333
|
+
assert.ok(exceptions.next24h.sailChanges.length >= 1);
|
|
334
|
+
assert.equal(exceptions.next24h.solarYieldKwh, 12);
|
|
335
|
+
assert.equal(exceptions.next24h.energyDeficitAlert, true);
|
|
336
|
+
assert.equal(exceptions.passageSummary.etaP50, result.eta.p50);
|
|
337
|
+
assert.equal(
|
|
338
|
+
exceptions.passageSummary.totalMotorHours,
|
|
339
|
+
result.motoringHours,
|
|
340
|
+
);
|
|
341
|
+
assert.ok(exceptions.passageSummary.macroSeaAnomalies.length === 0);
|
|
342
|
+
assert.ok(exceptions.passageSummary.convectiveWarnings.length === 0);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("convective warnings surface in the passage summary", async () => {
|
|
346
|
+
const { filterExceptions, simulatePassage } = await simPromise;
|
|
347
|
+
const result = simulatePassage({
|
|
348
|
+
payload: buildPayload({ convectiveHour: 7 }),
|
|
349
|
+
startTime: new Date("2026-06-21T06:00:00Z"),
|
|
350
|
+
});
|
|
351
|
+
const exceptions = filterExceptions(result);
|
|
352
|
+
assert.ok(exceptions.passageSummary.convectiveWarnings.length >= 1);
|
|
353
|
+
assert.equal(exceptions.passageSummary.convectiveWarnings[0].cape, 1800);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("planned tack merges into sailEvents, sorted, and reaches the summary (doc #5)", async () => {
|
|
357
|
+
const { filterExceptions, simulatePassage } = await simPromise;
|
|
358
|
+
// Steady beat north on starboard tack (TWA +45); at hour 12 the
|
|
359
|
+
// wind veers to −45 and the plan's tack side flips between
|
|
360
|
+
// adjacent hours — the tack the crew has to do.
|
|
361
|
+
const track = sampleRoutePoints([
|
|
362
|
+
[0, 0],
|
|
363
|
+
[0, 3],
|
|
364
|
+
]);
|
|
365
|
+
const payload = {
|
|
366
|
+
metadata: {
|
|
367
|
+
fetchedAt: new Date(Date.UTC(2026, 5, 21, 0)).toISOString(),
|
|
368
|
+
source: "api",
|
|
369
|
+
},
|
|
370
|
+
waypoints: track.map((w) => ({
|
|
371
|
+
...w,
|
|
372
|
+
forecasts: Array.from({ length: 72 }, (_, h) => ({
|
|
373
|
+
timestamp: new Date(Date.UTC(2026, 5, 21, h)).toISOString(),
|
|
374
|
+
surface: { tws: 10, twd: h < 12 ? 45 : -45, mslp: 1013, gust: 15 },
|
|
375
|
+
marine: {
|
|
376
|
+
hsCombined: 1.5,
|
|
377
|
+
tpCombined: 8,
|
|
378
|
+
dirCombined: 45,
|
|
379
|
+
hsSwell: 1,
|
|
380
|
+
tpSwell: 9,
|
|
381
|
+
dirSwell: 50,
|
|
382
|
+
hsWindSea: 0.5,
|
|
383
|
+
tpWindSea: 5,
|
|
384
|
+
dirWindSea: 40,
|
|
385
|
+
},
|
|
386
|
+
upperAir: { cape: 100, kIndex: 20 },
|
|
387
|
+
current: { drift: 0, set: 0 },
|
|
388
|
+
})),
|
|
389
|
+
})),
|
|
390
|
+
};
|
|
391
|
+
const result = simulatePassage({
|
|
392
|
+
payload,
|
|
393
|
+
startTime: new Date("2026-06-21T06:00:00Z"),
|
|
394
|
+
});
|
|
395
|
+
const maneuvers = result.sailEvents.filter((e) => e.maneuver);
|
|
396
|
+
assert.ok(maneuvers.length >= 1, "at least one maneuver detected");
|
|
397
|
+
const tack = maneuvers.find((e) => e.maneuver === "tack");
|
|
398
|
+
assert.ok(tack, "a tack is detected at the wind shift");
|
|
399
|
+
assert.equal(tack.fromTack, "starboard");
|
|
400
|
+
assert.equal(tack.toTack, "port");
|
|
401
|
+
assert.ok(tack.sailState.endsWith("@port"), tack.sailState);
|
|
402
|
+
assert.ok(tack.lat != null && tack.lon != null);
|
|
403
|
+
assert.ok(tack.distanceFromStartNm > 0);
|
|
404
|
+
assert.ok(tack.twsAtManeuver != null);
|
|
405
|
+
|
|
406
|
+
// Merged queue stays time-sorted
|
|
407
|
+
const hours = result.sailEvents.map((e) => e.hoursFromNow);
|
|
408
|
+
for (let i = 1; i < hours.length; i++) {
|
|
409
|
+
assert.ok(hours[i - 1] <= hours[i], "sailEvents sorted");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const exceptions = filterExceptions(result);
|
|
413
|
+
assert.ok(exceptions.passageSummary.sailChanges.length >= 1);
|
|
414
|
+
assert.ok(
|
|
415
|
+
exceptions.passageSummary.sailChanges.some((e) => e.maneuver === "tack"),
|
|
416
|
+
"maneuvers reach the strategic summary",
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe("polar lookup", () => {
|
|
422
|
+
test("canonical table: bilinear interpolation and TWA folding", async () => {
|
|
423
|
+
const { polarSpeedKnots } = await polarPromise;
|
|
424
|
+
assert.equal(polarSpeedKnots(null, 10, 0), 0); // In irons
|
|
425
|
+
const stw = polarSpeedKnots(null, 10, (45 * Math.PI) / 180);
|
|
426
|
+
assert.ok(stw > 5 && stw < 6.5, `stw ${stw}`);
|
|
427
|
+
// Symmetric across the bow
|
|
428
|
+
assert.equal(polarSpeedKnots(null, 10, (-45 * Math.PI) / 180), stw);
|
|
429
|
+
// Grid nodes pass through (within the kn→m/s roundtrip)
|
|
430
|
+
approx(polarSpeedKnots(null, 10, Math.PI / 3), 6.4, 1e-3);
|
|
431
|
+
// Out-of-grid TWS clamps to hull speed
|
|
432
|
+
approx(polarSpeedKnots(null, 99, Math.PI / 2), 8.5, 1e-3);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("light wind scales toward zero, performance factor derates", async () => {
|
|
436
|
+
const { polarSpeedKnots } = await polarPromise;
|
|
437
|
+
// Below the table's 4 kn floor: half the wind, half the speed
|
|
438
|
+
const full = polarSpeedKnots(null, 4, Math.PI / 2);
|
|
439
|
+
const half = polarSpeedKnots(null, 2, Math.PI / 2);
|
|
440
|
+
approx(half, full / 2, 1e-3);
|
|
441
|
+
const derated = polarSpeedKnots(null, 10, Math.PI / 2, {
|
|
442
|
+
performanceFactor: 0.8,
|
|
443
|
+
});
|
|
444
|
+
approx(derated, polarSpeedKnots(null, 10, Math.PI / 2) * 0.8, 1e-3);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("malformed table yields zero instead of sailing on fiction", async () => {
|
|
448
|
+
const { isInterpolatableTable, polarSpeedKnots } = await polarPromise;
|
|
449
|
+
assert.equal(isInterpolatableTable(null), false);
|
|
450
|
+
assert.equal(
|
|
451
|
+
isInterpolatableTable({
|
|
452
|
+
axes: { tws: [5], twa: [1] },
|
|
453
|
+
values: { boatSpeedMatrix: [[1, 2]] },
|
|
454
|
+
}),
|
|
455
|
+
false,
|
|
456
|
+
);
|
|
457
|
+
assert.equal(polarSpeedKnots({ axes: {} }, 10, 1), 0);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("bearing helper", async () => {
|
|
461
|
+
const { bearingRad } = await polarPromise;
|
|
462
|
+
const north = bearingRad(0, 0, 1, 0);
|
|
463
|
+
approx(north, 0, 1e-9);
|
|
464
|
+
const east = bearingRad(0, 0, 0, 1);
|
|
465
|
+
approx(east, Math.PI / 2, 1e-9);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
describe("antimeridian", () => {
|
|
470
|
+
// Tonga → Opua crosses 180°; the geometry must take the short arc
|
|
471
|
+
const TONGA_TO_OPUA = [
|
|
472
|
+
[-175.2, -21.1],
|
|
473
|
+
[-179.0, -25.0],
|
|
474
|
+
[179.0, -30.0],
|
|
475
|
+
[174.3, -35.3],
|
|
476
|
+
];
|
|
477
|
+
|
|
478
|
+
test("distanceNm takes the short way across 180", async () => {
|
|
479
|
+
const { distanceNm } = await simPromise;
|
|
480
|
+
// 2° of longitude at 18.5S ≈ 113.3 nm, not ~20400 nm
|
|
481
|
+
const nm = distanceNm(-18.5, -179, -18.5, 179);
|
|
482
|
+
assert.ok(nm > 110 && nm < 117, `nm ${nm}`);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("route distance and sampling stay on the short arc", async () => {
|
|
486
|
+
const { routeDistanceNm } = require("../plugin/fetch-engine.js");
|
|
487
|
+
const { sampleRoutePoints } = require("../plugin/fetch-engine.js");
|
|
488
|
+
const total = routeDistanceNm(TONGA_TO_OPUA);
|
|
489
|
+
// Real passage ≈ 1100 nm; the long way would be ~23000
|
|
490
|
+
assert.ok(total > 950 && total < 1250, `total ${total}`);
|
|
491
|
+
|
|
492
|
+
const track = sampleRoutePoints(TONGA_TO_OPUA, 60);
|
|
493
|
+
assert.ok(track.length > 15, `waypoints ${track.length}`);
|
|
494
|
+
// Strict: every sampled lon stays on the Pacific side of the
|
|
495
|
+
// seam — none swept the long way through the 0±140°E band
|
|
496
|
+
for (const w of track) {
|
|
497
|
+
const inWrongHalf = w.lon > -35 && w.lon < 165;
|
|
498
|
+
assert.equal(inWrongHalf, false, `lon ${w.lon} swept the long way`);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("simulation crosses 180 without teleporting", async () => {
|
|
503
|
+
const { simulateRun } = await simPromise;
|
|
504
|
+
// Route leg across the seam: 1° lon apart across 180 at −20 lat
|
|
505
|
+
const payload = {
|
|
506
|
+
metadata: {},
|
|
507
|
+
waypoints: [
|
|
508
|
+
{ lat: -20, lon: 179, distanceFromStartNm: 0 },
|
|
509
|
+
{ lat: -20.5, lon: -179, distanceFromStartNm: 60 },
|
|
510
|
+
],
|
|
511
|
+
forecasts: undefined,
|
|
512
|
+
};
|
|
513
|
+
payload.waypoints = payload.waypoints.map((w) => ({
|
|
514
|
+
...w,
|
|
515
|
+
forecasts: Array.from({ length: 24 }, (_, h) => ({
|
|
516
|
+
timestamp: new Date(Date.UTC(2026, 5, 21, h)).toISOString(),
|
|
517
|
+
surface: { tws: 10, twd: 90, mslp: 1013, gust: 15 },
|
|
518
|
+
marine: { hsCombined: 1.5, tpCombined: 8, dirCombined: 45 },
|
|
519
|
+
upperAir: { cape: 100, kIndex: 20 },
|
|
520
|
+
current: { drift: 0, set: 0 },
|
|
521
|
+
})),
|
|
522
|
+
}));
|
|
523
|
+
const run = simulateRun({
|
|
524
|
+
payload,
|
|
525
|
+
startTime: new Date("2026-06-21T06:00:00Z"),
|
|
526
|
+
config: {},
|
|
527
|
+
});
|
|
528
|
+
assert.ok(run.etaHours < 14 * 24, `eta ${run.etaHours}`);
|
|
529
|
+
const lastLon = run.hourly[run.hourly.length - 1].lon;
|
|
530
|
+
// Arrival on the −180 side of the seam
|
|
531
|
+
assert.ok(lastLon < 0, `last lon ${lastLon}`);
|
|
532
|
+
});
|
|
533
|
+
});
|