@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.
Files changed (86) hide show
  1. package/.editorconfig +5 -0
  2. package/.github/workflows/publish.yml +34 -0
  3. package/.github/workflows/signalk-ci.yml +11 -0
  4. package/.github/workflows/test.yml +21 -0
  5. package/CHANGELOG.md +423 -0
  6. package/README.md +86 -0
  7. package/SPEC.md +488 -0
  8. package/bin/backfill-report.js +218 -0
  9. package/bin/backtest-cli.js +132 -0
  10. package/biome.json +6 -0
  11. package/doc/here-brief.png +0 -0
  12. package/package.json +48 -0
  13. package/plugin/backtest.js +742 -0
  14. package/plugin/brief-ext.js +173 -0
  15. package/plugin/bulletin-engine.js +746 -0
  16. package/plugin/bulletin-source.js +150 -0
  17. package/plugin/celestial-source.js +417 -0
  18. package/plugin/fetch-engine.js +717 -0
  19. package/plugin/history-backfill.js +540 -0
  20. package/plugin/index.js +1479 -0
  21. package/plugin/logbook-source.js +463 -0
  22. package/plugin/notes-publisher.js +228 -0
  23. package/plugin/notes-store.js +241 -0
  24. package/plugin/raster-convert.js +168 -0
  25. package/plugin/sails-configuration.js +111 -0
  26. package/plugin/spool-watcher.js +218 -0
  27. package/plugin/sqlite-db.js +378 -0
  28. package/plugin/state-machine.js +249 -0
  29. package/plugin/statustilesexamples.js +101 -0
  30. package/plugin/synoptic-map.json +45 -0
  31. package/plugin/synoptic-source.js +227 -0
  32. package/plugin/zone-source.js +339 -0
  33. package/public/app.js +18 -0
  34. package/public/brief-ext-model.js +64 -0
  35. package/public/brief-ext-widget.html +14 -0
  36. package/public/brief-ext-widget.js +294 -0
  37. package/public/components/backfill-controls.js +100 -0
  38. package/public/components/comfort-info.js +94 -0
  39. package/public/components/conditions-here.js +234 -0
  40. package/public/components/horizon-sparkline.js +77 -0
  41. package/public/components/models.mjs +372 -0
  42. package/public/components/passage-outlook.js +368 -0
  43. package/public/components/sk-api.js +146 -0
  44. package/public/components/sk-base-css.js +177 -0
  45. package/public/components/strategic-outlook.js +248 -0
  46. package/public/components/synoptic-chart.js +48 -0
  47. package/public/components/tactical-dashboard.js +172 -0
  48. package/public/css/visuals.css +292 -0
  49. package/public/gmdss-zones-min.json +287 -0
  50. package/public/icon.png +0 -0
  51. package/public/index.html +13 -0
  52. package/public/polar.mjs +303 -0
  53. package/public/route-sim.mjs +750 -0
  54. package/public/sereno-physics.mjs +592 -0
  55. package/public/tack-gybe.js +174 -0
  56. package/public/vendor/plotterext-bus/LICENSE +21 -0
  57. package/public/vendor/plotterext-bus/README.md +17 -0
  58. package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
  59. package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
  60. package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
  61. package/public/vendor/plotterext-bus/extension.js +29 -0
  62. package/public/vendor/plotterext-bus/host.js +28 -0
  63. package/public/vendor/utif/LICENSE +21 -0
  64. package/public/vendor/utif/UTIF.js +1171 -0
  65. package/public/worker.js +35 -0
  66. package/status-tiles-examples.json +55 -0
  67. package/tests/backtest.test.js +461 -0
  68. package/tests/brief-ext.test.js +194 -0
  69. package/tests/bulletin-engine.test.js +361 -0
  70. package/tests/celestial-source.test.js +265 -0
  71. package/tests/fetch-engine.test.js +297 -0
  72. package/tests/history-backfill.test.js +341 -0
  73. package/tests/logbook-source.test.js +324 -0
  74. package/tests/notes-publisher.test.js +161 -0
  75. package/tests/notes-store.test.js +138 -0
  76. package/tests/openmeteo-mock.js +100 -0
  77. package/tests/plugin.test.js +1344 -0
  78. package/tests/route-sim.test.js +533 -0
  79. package/tests/sereno-physics.test.js +333 -0
  80. package/tests/sqlite-db.test.js +164 -0
  81. package/tests/state-machine.test.js +182 -0
  82. package/tests/statustilesexamples.test.js +86 -0
  83. package/tests/synoptic-source.test.js +195 -0
  84. package/tests/tack-gybe.test.js +211 -0
  85. package/tests/webapp.test.js +257 -0
  86. package/tests/zone-source.test.js +226 -0
@@ -0,0 +1,750 @@
1
+ /**
2
+ * Step-forward isochrone passage simulation (SPEC §5.1).
3
+ *
4
+ * Sails the route one hour at a time: interpolate the weather at the
5
+ * boat's position, pick speed through water (polars under sail,
6
+ * motor or drift below the motoring wind threshold), add the surface
7
+ * current as a vector, advance the position, and evaluate the
8
+ * Sereno comfort of the hour. Along the way it collects sail-change
9
+ * suggestions from the learned preference matrix (day/night bucketed),
10
+ * hazard proximity alerts (SPEC §5.3), steep-sea and convective
11
+ * anomalies, motoring hours and fuel.
12
+ *
13
+ * The weather payload carries deterministic forecasts, so ETA
14
+ * percentiles come from a three-run wind perturbation (−10 %, nominal,
15
+ * +10 % TWS) — a poor man's ensemble until ensemble members are
16
+ * fetched. Comfort and anomalies are reported from the nominal run.
17
+ *
18
+ * This module is a plain ES module so the web worker (browser) and
19
+ * the tests (Node, via dynamic import) execute the exact same code.
20
+ *
21
+ * @module route-sim
22
+ */
23
+
24
+ import {
25
+ bearingRad,
26
+ DEFAULT_POLAR_TABLE,
27
+ parsePerformanceFactor,
28
+ polarSpeedKnots,
29
+ } from "./polar.mjs";
30
+ import {
31
+ isNight,
32
+ serenoComfort,
33
+ steepnessRatio,
34
+ suggestSailState,
35
+ travelDirection,
36
+ } from "./sereno-physics.mjs";
37
+ import { detectManeuvers } from "./tack-gybe.js";
38
+
39
+ /**
40
+ * Simulation time step (hours).
41
+ */
42
+ export const STEP_HOURS = 1.0;
43
+
44
+ /**
45
+ * Hazard alert radius around a note (SPEC §5.3).
46
+ */
47
+ export const HAZARD_RADIUS_NM = 5.0;
48
+
49
+ /**
50
+ * Simulation defaults, mirroring the plugin configuration defaults
51
+ * (SPEC §2.1).
52
+ */
53
+ export const SIMULATION_DEFAULTS = {
54
+ motoring_tws_threshold: 3.5,
55
+ drift_mode_enabled: true,
56
+ waterline_length_m: 9.4,
57
+ k_heel: 0.35,
58
+ k_pitch: 0.4,
59
+ };
60
+
61
+ /**
62
+ * Fuel burn of the auxiliary at its passage speed (gal/h), used when
63
+ * drift mode is off and the wind is below the motoring threshold.
64
+ */
65
+ export const MOTOR_FUEL_GAL_PER_HOUR = 0.8;
66
+
67
+ /**
68
+ * Motor passage speed (knots) when drift mode is off.
69
+ */
70
+ export const MOTOR_SPEED_KNOTS = 4.5;
71
+
72
+ /**
73
+ * Wind perturbation factors of the pseudo-ensemble (SPEC §6.2 ETA
74
+ * percentiles).
75
+ */
76
+ export const ETA_FACTORS = [0.9, 1.0, 1.1];
77
+
78
+ const DEG = Math.PI / 180;
79
+ const EARTH_RADIUS_NM = 3440.065;
80
+
81
+ /**
82
+ * Normalizes an angle to [−π, π).
83
+ *
84
+ * @param {number} rad
85
+ * @returns {number}
86
+ */
87
+ function normalizeAngle(rad) {
88
+ let a = rad % (2 * Math.PI);
89
+ if (a >= Math.PI) {
90
+ a -= 2 * Math.PI;
91
+ } else if (a < -Math.PI) {
92
+ a += 2 * Math.PI;
93
+ }
94
+ return a;
95
+ }
96
+
97
+ /**
98
+ * Great-circle distance between two positions (nm).
99
+ *
100
+ * @param {number} lat1 - Degrees
101
+ * @param {number} lon1 - Degrees
102
+ * @param {number} lat2 - Degrees
103
+ * @param {number} lon2 - Degrees
104
+ * @returns {number}
105
+ */
106
+ export function distanceNm(lat1, lon1, lat2, lon2) {
107
+ const φ1 = lat1 * DEG;
108
+ const φ2 = lat2 * DEG;
109
+ const dφ = (lat2 - lat1) * DEG;
110
+ const dλ = (lon2 - lon1) * DEG;
111
+ const a =
112
+ Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2;
113
+ return 2 * EARTH_RADIUS_NM * Math.asin(Math.min(1, Math.sqrt(a)));
114
+ }
115
+
116
+ /**
117
+ * Destination position given a start, a bearing and a distance
118
+ * (great-circle).
119
+ *
120
+ * @param {number} lat - Degrees
121
+ * @param {number} lon - Degrees
122
+ * @param {number} bearing - Radians, true
123
+ * @param {number} distNm
124
+ * @returns {{lat: number, lon: number}}
125
+ */
126
+ export function destinationPoint(lat, lon, bearing, distNm) {
127
+ const δ = distNm / EARTH_RADIUS_NM;
128
+ const φ1 = lat * DEG;
129
+ const λ1 = lon * DEG;
130
+ const θ = bearing;
131
+ const sinφ2 =
132
+ Math.sin(φ1) * Math.cos(δ) + Math.cos(φ1) * Math.sin(δ) * Math.cos(θ);
133
+ const φ2 = Math.asin(Math.min(1, Math.max(-1, sinφ2)));
134
+ const λ2 =
135
+ λ1 +
136
+ Math.atan2(
137
+ Math.sin(θ) * Math.sin(δ) * Math.cos(φ1),
138
+ Math.cos(δ) - Math.sin(φ1) * sinφ2,
139
+ );
140
+ let lonDeg = (λ2 / DEG) % 360;
141
+ if (lonDeg > 180) {
142
+ lonDeg -= 360;
143
+ } else if (lonDeg < -180) {
144
+ lonDeg += 360;
145
+ }
146
+ return { lat: φ2 / DEG, lon: lonDeg };
147
+ }
148
+
149
+ /**
150
+ * Interpolates an hourly forecast series at an arbitrary instant.
151
+ * Angles interpolate along the shortest arc; missing values stay
152
+ * null. Outside the forecast range the nearest step is used (clamped,
153
+ * flagged via `clamped`).
154
+ *
155
+ * @param {Array<object>} forecasts - TimeStepForecast list of one
156
+ * payload waypoint
157
+ * @param {Date} timestamp
158
+ * @returns {object|null} Interpolated forecast step
159
+ */
160
+ export function weatherAt(forecasts, timestamp) {
161
+ if (!Array.isArray(forecasts) || forecasts.length === 0) {
162
+ return null;
163
+ }
164
+ const steps = forecasts
165
+ .map((step) => ({ step, t: new Date(step.timestamp).getTime() }))
166
+ .filter((s) => !Number.isNaN(s.t))
167
+ .sort((a, b) => a.t - b.t);
168
+ if (steps.length === 0) {
169
+ return null;
170
+ }
171
+ const t = timestamp.getTime();
172
+ let a = steps[0];
173
+ let b = steps[steps.length - 1];
174
+ for (let i = 0; i < steps.length - 1; i++) {
175
+ if (t >= steps[i].t && t <= steps[i + 1].t) {
176
+ a = steps[i];
177
+ b = steps[i + 1];
178
+ break;
179
+ }
180
+ }
181
+ if (t <= steps[0].t) {
182
+ a = b = steps[0];
183
+ } else if (t >= steps[steps.length - 1].t) {
184
+ a = b = steps[steps.length - 1];
185
+ }
186
+
187
+ const span = b.t - a.t;
188
+ const f = span > 0 ? (t - a.t) / span : 0;
189
+ const mix = (lo, hi, wrap) => {
190
+ if (typeof lo !== "number" || !Number.isFinite(lo)) {
191
+ return typeof hi === "number" ? hi : null;
192
+ }
193
+ if (typeof hi !== "number" || !Number.isFinite(hi)) {
194
+ return lo;
195
+ }
196
+ if (wrap) {
197
+ return (((lo + f * (((hi - lo + 540) % 360) - 180)) % 360) + 360) % 360;
198
+ }
199
+ return lo + f * (hi - lo);
200
+ };
201
+
202
+ const sa = a.step?.surface ?? {};
203
+ const sb = b.step?.surface ?? {};
204
+ const ma = a.step?.marine ?? {};
205
+ const mb = b.step?.marine ?? {};
206
+ const ua = a.step?.upperAir ?? {};
207
+ const ub = b.step?.upperAir ?? {};
208
+ const ca = a.step?.current ?? {};
209
+ const cb = b.step?.current ?? {};
210
+
211
+ return {
212
+ clamped: t < steps[0].t || t > steps[steps.length - 1].t,
213
+ surface: {
214
+ tws: mix(sa.tws, sb.tws),
215
+ twd: mix(sa.twd, sb.twd, true),
216
+ mslp: mix(sa.mslp, sb.mslp),
217
+ gust: mix(sa.gust, sb.gust),
218
+ },
219
+ marine: {
220
+ hsCombined: mix(ma.hsCombined, mb.hsCombined),
221
+ tpCombined: mix(ma.tpCombined, mb.tpCombined),
222
+ dirCombined: mix(ma.dirCombined, mb.dirCombined, true),
223
+ hsSwell: mix(ma.hsSwell, mb.hsSwell),
224
+ tpSwell: mix(ma.tpSwell, mb.tpSwell),
225
+ dirSwell: mix(ma.dirSwell, mb.dirSwell, true),
226
+ hsWindSea: mix(ma.hsWindSea, mb.hsWindSea),
227
+ tpWindSea: mix(ma.tpWindSea, mb.tpWindSea),
228
+ dirWindSea: mix(ma.dirWindSea, mb.dirWindSea, true),
229
+ },
230
+ upperAir: {
231
+ cape: mix(ua.cape, ub.cape),
232
+ kIndex: mix(ua.kIndex, ub.kIndex),
233
+ rh700: mix(ua.rh700, ub.rh700),
234
+ wind850kts: mix(ua.wind850kts, ub.wind850kts),
235
+ },
236
+ current: {
237
+ drift: mix(ca.drift, cb.drift),
238
+ set: mix(ca.set, cb.set, true),
239
+ },
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Finds the payload waypoint nearest to a position.
245
+ *
246
+ * @param {Array<object>} payloadWaypoints
247
+ * @param {number} lat
248
+ * @param {number} lon
249
+ * @param {number} [hintIndex] - Start searching from this index (the
250
+ * vessel moves forward, so the previous nearest stays nearest)
251
+ * @returns {{index: number, distanceNm: number}}
252
+ */
253
+ export function nearestWaypoint(payloadWaypoints, lat, lon, hintIndex = 0) {
254
+ let best = { index: 0, distanceNm: Infinity };
255
+ for (let i = hintIndex; i < payloadWaypoints.length; i++) {
256
+ const w = payloadWaypoints[i];
257
+ const d = distanceNm(lat, lon, w.lat, w.lon);
258
+ if (d < best.distanceNm) {
259
+ best = { index: i, distanceNm: d };
260
+ }
261
+ }
262
+ return best;
263
+ }
264
+
265
+ /**
266
+ * Ray-casting point-in-polygon test (SPEC §5.3).
267
+ *
268
+ * @param {number} lat
269
+ * @param {number} lon
270
+ * @param {number[][]} ring - [[lon, lat], ...] closed or open ring
271
+ * @returns {boolean}
272
+ */
273
+ export function pointInPolygon(lat, lon, ring) {
274
+ let inside = false;
275
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
276
+ const xi = ring[i][0];
277
+ const yi = ring[i][1];
278
+ const xj = ring[j][0];
279
+ const yj = ring[j][1];
280
+ if (
281
+ yi > lat !== yj > lat &&
282
+ lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
283
+ ) {
284
+ inside = !inside;
285
+ }
286
+ }
287
+ return inside;
288
+ }
289
+
290
+ /**
291
+ * Hazard proximity alerts for the hourly positions (SPEC §5.3):
292
+ * polygon notes alert on containment, point notes on distance below
293
+ * the alert radius.
294
+ *
295
+ * @param {Array<{id: string, description?: string, feature: object>}|
296
+ * Array<{id: string, description?: string, position: {latitude: number, longitude: number}}>} notes
297
+ * @param {Array<{hoursFromNow: number, timestamp: string, lat: number, lon: number}>} positions
298
+ * @param {number} [radiusNm]
299
+ * @returns {Array<object>} Hazard alerts
300
+ */
301
+ export function hazardAlerts(notes, positions, radiusNm = HAZARD_RADIUS_NM) {
302
+ const alerts = [];
303
+ if (!Array.isArray(notes)) {
304
+ return alerts;
305
+ }
306
+ for (const note of notes) {
307
+ const ring =
308
+ note?.feature?.geometry?.type === "Polygon"
309
+ ? note.feature.geometry.coordinates[0]
310
+ : null;
311
+ const point =
312
+ note?.position ??
313
+ (note?.feature?.geometry?.type === "Point"
314
+ ? {
315
+ latitude: note.feature.geometry.coordinates[1],
316
+ longitude: note.feature.geometry.coordinates[0],
317
+ }
318
+ : null);
319
+ for (const pos of positions) {
320
+ let hit = false;
321
+ let distance = null;
322
+ if (ring) {
323
+ hit = pointInPolygon(pos.lat, pos.lon, ring);
324
+ } else if (point) {
325
+ distance = distanceNm(
326
+ pos.lat,
327
+ pos.lon,
328
+ point.latitude,
329
+ point.longitude,
330
+ );
331
+ hit = distance < radiusNm;
332
+ }
333
+ if (hit) {
334
+ alerts.push({
335
+ hoursFromNow: pos.hoursFromNow,
336
+ timestamp: pos.timestamp,
337
+ noteId: note.id ?? null,
338
+ description: note.description ?? null,
339
+ distanceNm: distance != null ? Math.round(distance * 10) / 10 : 0,
340
+ });
341
+ break; // One alert per note
342
+ }
343
+ }
344
+ }
345
+ return alerts.sort((a, b) => a.hoursFromNow - b.hoursFromNow);
346
+ }
347
+
348
+ /**
349
+ * Runs the step-forward simulation once.
350
+ *
351
+ * @param {object} params
352
+ * @param {object} params.payload - UnifiedWeatherPayload (its
353
+ * waypoints double as the track nodes)
354
+ * @param {object} [params.config] - Plugin configuration subset
355
+ * @param {object} [params.matrix] - Learned sail preference matrix
356
+ * @param {object} [params.polar] - Canonical `polars` resource table
357
+ * (null → built-in default)
358
+ * @param {number} [params.performanceFactor] - Crew derating factor
359
+ * from `polars.performanceFactor`
360
+ * @param {Array} [params.notes] - Hazard notes
361
+ * @param {Date} [params.startTime] - Simulation start
362
+ * @param {number} [params.twsFactor] - Wind multiplier of this run
363
+ * @param {number} [params.maxHours] - Safety stop
364
+ * @returns {object} One run's result
365
+ */
366
+ export function simulateRun({
367
+ payload,
368
+ config = {},
369
+ matrix = null,
370
+ polar = null,
371
+ performanceFactor,
372
+ notes = [],
373
+ startTime = new Date(),
374
+ twsFactor = 1.0,
375
+ maxHours = 14 * 24,
376
+ }) {
377
+ const cfg = { ...SIMULATION_DEFAULTS, ...config };
378
+ const pf = parsePerformanceFactor(performanceFactor);
379
+ const track = payload?.waypoints ?? [];
380
+ if (track.length < 2) {
381
+ throw new Error("Payload needs at least two waypoints to simulate");
382
+ }
383
+
384
+ let pos = { lat: track[0].lat, lon: track[0].lon };
385
+ let targetIndex = 1;
386
+ let nearest = 0;
387
+ let sailState = null;
388
+ let t = new Date(startTime.getTime());
389
+ let hours = 0;
390
+ let motoringHours = 0;
391
+ let fuelGal = 0;
392
+
393
+ const hourly = [];
394
+ const sailEvents = [];
395
+ const positions = [];
396
+ const seaStateAnomalies = [];
397
+ const upperAirAnomalies = [];
398
+ let arrivedHours = null;
399
+ let distanceMadeGoodNm = 0;
400
+
401
+ while (hours < maxHours && arrivedHours == null) {
402
+ const target = track[targetIndex];
403
+ const heading = bearingRad(pos.lat, pos.lon, target.lat, target.lon);
404
+ const weather = weatherAt(track[nearest].forecasts, t);
405
+ const tws =
406
+ weather?.surface?.tws != null ? weather.surface.tws * twsFactor : null;
407
+ const twaRad =
408
+ weather?.surface?.twd != null
409
+ ? normalizeAngle(weather.surface.twd * DEG - heading)
410
+ : 0;
411
+
412
+ // Speed choice (SPEC §5.1 decision tree)
413
+ let stw = 0;
414
+ let fuelRate = 0;
415
+ let motoring = false;
416
+ if (tws != null && tws >= cfg.motoring_tws_threshold) {
417
+ stw = polarSpeedKnots(polar ?? DEFAULT_POLAR_TABLE, tws, twaRad, {
418
+ performanceFactor: pf,
419
+ });
420
+ } else if (cfg.drift_mode_enabled) {
421
+ stw = 0; // Drift with zero fuel, riding the current
422
+ fuelRate = 0;
423
+ } else {
424
+ stw = MOTOR_SPEED_KNOTS;
425
+ fuelRate = MOTOR_FUEL_GAL_PER_HOUR;
426
+ motoring = true;
427
+ }
428
+
429
+ // SOG vector = boat vector + current vector
430
+ const drift = weather?.current?.drift ?? 0;
431
+ const setDeg = weather?.current?.set ?? 0;
432
+ const boatN = stw * Math.cos(heading);
433
+ const boatE = stw * Math.sin(heading);
434
+ const curN = drift * Math.cos(setDeg * DEG);
435
+ const curE = drift * Math.sin(setDeg * DEG);
436
+ const sog = Math.sqrt((boatN + curN) ** 2 + (boatE + curE) ** 2);
437
+ const sogDir = Math.atan2(boatE + curE, boatN + curN);
438
+
439
+ // Advance the position by one step
440
+ const remaining = distanceNm(pos.lat, pos.lon, target.lat, target.lon);
441
+ const stepDistance = sog * STEP_HOURS;
442
+ let arrivedNow = false;
443
+ if (sog > 0.05 && stepDistance >= remaining) {
444
+ // Arrive during this step: partial hour
445
+ const hoursNeeded = Math.max(remaining / sog, 0);
446
+ hours += hoursNeeded;
447
+ t = new Date(t.getTime() + hoursNeeded * 3600000);
448
+ pos = { lat: target.lat, lon: target.lon };
449
+ distanceMadeGoodNm += remaining;
450
+ arrivedNow = true;
451
+ } else {
452
+ pos = destinationPoint(pos.lat, pos.lon, sogDir, stepDistance);
453
+ hours += STEP_HOURS;
454
+ t = new Date(t.getTime() + STEP_HOURS * 3600000);
455
+ distanceMadeGoodNm += stepDistance;
456
+ }
457
+ if (motoring) {
458
+ motoringHours += STEP_HOURS;
459
+ fuelGal += fuelRate * STEP_HOURS;
460
+ }
461
+
462
+ // Comfort of the hour (SPEC §5.2)
463
+ const hs = weather?.marine?.hsCombined;
464
+ const tp = weather?.marine?.tpCombined;
465
+ const waveFrom = weather?.marine?.dirCombined;
466
+ const comfort = serenoComfort(
467
+ {
468
+ hsMeters: hs ?? 0,
469
+ tpSeconds: tp ?? 0,
470
+ waveTravelDirectionRad: travelDirection((waveFrom ?? 0) * DEG),
471
+ },
472
+ {
473
+ sogKnots: sog,
474
+ headingRad: heading,
475
+ waterlineLengthM: cfg.waterline_length_m,
476
+ kHeel: cfg.k_heel,
477
+ kPitch: cfg.k_pitch,
478
+ },
479
+ { twsKnots: tws ?? 0, twaRad },
480
+ );
481
+
482
+ // Learned sail preference for these conditions
483
+ const night = isNight(t, pos.lat, pos.lon);
484
+ if (matrix) {
485
+ const suggested = suggestSailState(
486
+ matrix,
487
+ tws ?? 0,
488
+ Math.abs(((twaRad / DEG + 540) % 360) - 180),
489
+ night,
490
+ );
491
+ if (suggested && suggested.preferredSailState !== sailState) {
492
+ sailState = suggested.preferredSailState;
493
+ sailEvents.push({
494
+ hoursFromNow: Math.round(hours * 10) / 10,
495
+ timestamp: t.toISOString(),
496
+ sailState,
497
+ night,
498
+ });
499
+ }
500
+ }
501
+
502
+ hourly.push({
503
+ hoursFromNow: Math.round(hours * 10) / 10,
504
+ timestamp: t.toISOString(),
505
+ lat: pos.lat,
506
+ lon: pos.lon,
507
+ distanceFromStartNm: Math.round(distanceMadeGoodNm * 10) / 10,
508
+ headingDeg: Math.round(((heading / DEG) % 360) * 10) / 10,
509
+ twdDeg: weather?.surface?.twd ?? null,
510
+ awsKnots: comfort.awsKnots,
511
+ azMs2: comfort.acceleration.value,
512
+ comfortLevel: comfort.comfort,
513
+ twsKnots: tws,
514
+ stwKnots: stw,
515
+ sogKnots: sog,
516
+ sailState,
517
+ motoring,
518
+ night,
519
+ });
520
+
521
+ // Anomalies
522
+ if (hs != null && tp != null) {
523
+ const ratio = steepnessRatio(tp, hs);
524
+ if (ratio < 3.28) {
525
+ seaStateAnomalies.push({
526
+ hoursFromNow: Math.round(hours * 10) / 10,
527
+ timestamp: t.toISOString(),
528
+ steepnessRatio: Math.round(ratio * 100) / 100,
529
+ hsMeters: hs,
530
+ tpSeconds: tp,
531
+ });
532
+ }
533
+ }
534
+ const cape = weather?.upperAir?.cape;
535
+ const kIndexValue = weather?.upperAir?.kIndex;
536
+ if (
537
+ (cape != null && cape > 1000) ||
538
+ (kIndexValue != null && kIndexValue > 28)
539
+ ) {
540
+ upperAirAnomalies.push({
541
+ hoursFromNow: Math.round(hours * 10) / 10,
542
+ timestamp: t.toISOString(),
543
+ cape,
544
+ kIndex: kIndexValue,
545
+ });
546
+ }
547
+
548
+ // Waypoint progress
549
+ if (arrivedNow) {
550
+ if (targetIndex === track.length - 1) {
551
+ arrivedHours = hours;
552
+ break;
553
+ }
554
+ targetIndex = Math.min(targetIndex + 1, track.length - 1);
555
+ }
556
+ nearest = nearestWaypoint(track, pos.lat, pos.lon, nearest).index;
557
+ const nextRemaining = distanceNm(
558
+ pos.lat,
559
+ pos.lon,
560
+ track[targetIndex].lat,
561
+ track[targetIndex].lon,
562
+ );
563
+ if (nextRemaining < 1 && targetIndex === track.length - 1) {
564
+ // Within a mile of the destination: close enough to call it
565
+ arrivedHours = hours;
566
+ break;
567
+ }
568
+ if (nextRemaining < 1) {
569
+ targetIndex = Math.min(targetIndex + 1, track.length - 1);
570
+ }
571
+ positions.push({
572
+ hoursFromNow: Math.round(hours * 10) / 10,
573
+ timestamp: t.toISOString(),
574
+ lat: pos.lat,
575
+ lon: pos.lon,
576
+ });
577
+ }
578
+
579
+ if (arrivedHours == null) {
580
+ arrivedHours = hours; // Never arrived: report the max horizon
581
+ }
582
+
583
+ return {
584
+ etaHours: arrivedHours,
585
+ eta: new Date(startTime.getTime() + arrivedHours * 3600000).toISOString(),
586
+ motoringHours,
587
+ fuelGal: Math.round(fuelGal * 100) / 100,
588
+ hourly,
589
+ sailEvents,
590
+ positions,
591
+ seaStateAnomalies,
592
+ upperAirAnomalies,
593
+ hazardAlerts: hazardAlerts(notes, positions),
594
+ };
595
+ }
596
+
597
+ /**
598
+ * Aggregates the optional energy forecast into the 24-hour net
599
+ * figures the tactical dashboard needs (SPEC §6.2).
600
+ *
601
+ * @param {Array<{timestamp: string, solarWh?: number, loadWh?: number}>|
602
+ * null} energyHourly
603
+ * @param {Date} startTime
604
+ * @returns {{netSolar24h: number|null, netBalance24h: number|null}}
605
+ */
606
+ function energySummary(energyHourly, startTime) {
607
+ if (!Array.isArray(energyHourly) || energyHourly.length === 0) {
608
+ return { netSolar24h: null, netBalance24h: null };
609
+ }
610
+ const end = startTime.getTime() + 24 * 3600000;
611
+ let solar = 0;
612
+ let balance = 0;
613
+ let seen = 0;
614
+ for (const hour of energyHourly) {
615
+ const t = new Date(hour.timestamp).getTime();
616
+ // Half-open 24h window: [start, start + 24h)
617
+ if (Number.isNaN(t) || t < startTime.getTime() || t >= end) {
618
+ continue;
619
+ }
620
+ const solarWh = typeof hour.solarWh === "number" ? hour.solarWh : 0;
621
+ const loadWh = typeof hour.loadWh === "number" ? hour.loadWh : 0;
622
+ solar += solarWh;
623
+ balance += solarWh - loadWh;
624
+ seen++;
625
+ }
626
+ if (seen === 0) {
627
+ return { netSolar24h: null, netBalance24h: null };
628
+ }
629
+ return {
630
+ netSolar24h: Math.round(solar) / 1000, // kWh
631
+ netBalance24h: Math.round(balance) / 1000, // kWh
632
+ };
633
+ }
634
+
635
+ /**
636
+ * Full passage simulation: the nominal run plus wind-perturbed runs
637
+ * for the ETA percentiles, assembled into the shape the webapp's
638
+ * `filterExceptions` consumes (SPEC §6.2).
639
+ *
640
+ * @param {object} params
641
+ * @param {object} params.payload - UnifiedWeatherPayload
642
+ * @param {object} [params.config] - Plugin configuration subset
643
+ * @param {object} [params.matrix] - Learned sail preference matrix
644
+ * @param {object} [params.polar] - Polar table
645
+ * @param {Array} [params.notes] - Hazard notes
646
+ * @param {Array<{timestamp: string, solarWh?: number, loadWh?: number}>} [params.energyHourly]
647
+ * @param {Date} [params.startTime]
648
+ * @returns {object} simulationResult
649
+ */
650
+ export function simulatePassage({
651
+ payload,
652
+ config,
653
+ matrix,
654
+ polar,
655
+ performanceFactor,
656
+ notes,
657
+ energyHourly,
658
+ startTime = new Date(),
659
+ }) {
660
+ const runs = ETA_FACTORS.map((twsFactor) =>
661
+ simulateRun({
662
+ payload,
663
+ config,
664
+ matrix,
665
+ polar,
666
+ performanceFactor,
667
+ notes,
668
+ startTime,
669
+ twsFactor,
670
+ }),
671
+ );
672
+ const base = runs.find((r) => r !== null);
673
+ const nominal = runs[ETA_FACTORS.indexOf(1.0)] ?? base;
674
+
675
+ const etas = runs.map((run) => run.etaHours).sort((a, b) => a - b);
676
+ const pct = (p) =>
677
+ new Date(
678
+ startTime.getTime() +
679
+ etas[Math.min(etas.length - 1, Math.round(p * (etas.length - 1)))] *
680
+ 3600000,
681
+ ).toISOString();
682
+
683
+ return {
684
+ eta: {
685
+ p10: pct(0),
686
+ p50: pct(0.5),
687
+ p90: pct(1),
688
+ },
689
+ motoringHours: nominal.motoringHours,
690
+ fuelConsumptionGal: nominal.fuelGal,
691
+ hourlyComfort: nominal.hourly,
692
+ // Tacks and gybes the plan implies, merged into the sail-change
693
+ // queue alongside the recommendation-driven changes (work doc #5)
694
+ sailEvents: [
695
+ ...(nominal.sailEvents ?? []),
696
+ ...detectManeuvers(nominal.hourly),
697
+ ].sort((x, y) => x.hoursFromNow - y.hoursFromNow),
698
+ hazardAlerts: nominal.hazardAlerts,
699
+ seaStateAnomalies: nominal.seaStateAnomalies,
700
+ upperAirAnomalies: nominal.upperAirAnomalies,
701
+ energy: energySummary(energyHourly, startTime),
702
+ runs: runs.map((run, i) => ({
703
+ twsFactor: ETA_FACTORS[i],
704
+ etaHours: run.etaHours,
705
+ motoringHours: run.motoringHours,
706
+ fuelGal: run.fuelGal,
707
+ })),
708
+ };
709
+ }
710
+
711
+ /**
712
+ * Exception-based display filtering (SPEC §6.2): passes raw
713
+ * simulation output through to the exception views the UI renders.
714
+ *
715
+ * @param {object} simulationResult - From {@link simulatePassage}
716
+ * @returns {object}
717
+ */
718
+ export function filterExceptions(simulationResult) {
719
+ return {
720
+ next24h: {
721
+ comfortBlocks: (simulationResult.hourlyComfort ?? []).slice(0, 24),
722
+ sailChanges: (simulationResult.sailEvents ?? []).filter(
723
+ (e) => e.hoursFromNow <= 24,
724
+ ),
725
+ hazards: (simulationResult.hazardAlerts ?? []).filter(
726
+ (h) => h.hoursFromNow <= 24,
727
+ ),
728
+ solarYieldKwh: simulationResult.energy?.netSolar24h ?? null,
729
+ energyDeficitAlert:
730
+ simulationResult.energy?.netBalance24h != null &&
731
+ simulationResult.energy.netBalance24h < 0,
732
+ },
733
+ passageSummary: {
734
+ etaP10: simulationResult.eta?.p10 ?? null,
735
+ etaP50: simulationResult.eta?.p50 ?? null,
736
+ etaP90: simulationResult.eta?.p90 ?? null,
737
+ totalMotorHours: simulationResult.motoringHours ?? 0,
738
+ totalFuelGal: simulationResult.fuelConsumptionGal ?? 0,
739
+ // Whole-route sail-work queue: recommendations plus the
740
+ // tacks/gybes the plan implies (work doc #5)
741
+ sailChanges: simulationResult.sailEvents ?? [],
742
+ macroSeaAnomalies: (simulationResult.seaStateAnomalies ?? []).filter(
743
+ (a) => a.steepnessRatio < 3.28,
744
+ ),
745
+ convectiveWarnings: (simulationResult.upperAirAnomalies ?? []).filter(
746
+ (u) => u.cape > 1000 || u.kIndex > 28,
747
+ ),
748
+ },
749
+ };
750
+ }