@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,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
|
+
}
|