@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,592 @@
1
+ /**
2
+ * Sereno comfort scale & monohull motion math (SPEC §5.2).
3
+ *
4
+ * Shared between the routing web worker (browser) and the backtest CLI
5
+ * (server), so this module is a plain ES module with no imports: the
6
+ * webapp loads it directly from the plugin's static directory, and Node
7
+ * consumes the same file with `import()`.
8
+ *
9
+ * The comfort model rates a passage hour on the Sereno scale
10
+ * (Champagne > Easy > Coffee > Rough > Sick) from two independent
11
+ * vectors, the worse of which sets the level:
12
+ *
13
+ * 1. **Wind over the deck** — apparent wind speed bands derived from
14
+ * reef points (SPEC §5.2 vector 1).
15
+ * 2. **Motion** — RMS vertical acceleration from the wave encounter
16
+ * period, adjusted for heel-induced roll and hull-length pitching
17
+ * resonance (SPEC §5.2 vector 2), rated against the comfort bands of
18
+ * ISO 2631-1.
19
+ *
20
+ * The physics follows SV Sabado's `passage-weather` planner
21
+ * (https://github.com/sailing12388/passage-weather, MIT), adapted for a
22
+ * monohull: the catamaran beam-roll and bridgedeck-slam factors are
23
+ * replaced by a heel multiplier and a waterline pitching resonance
24
+ * term. Not calibrated against a logged passage until the backtest CLI
25
+ * (SPEC §7) tunes k_heel/k_pitch against the vessel's own history.
26
+ *
27
+ * Conventions:
28
+ * - wind speeds and boat speeds in knots, angles in radians unless a
29
+ * parameter name says otherwise;
30
+ * - true wind angle is signed, 0 = wind from dead ahead, ±π = from
31
+ * dead astern (`environment.wind.angleTrue` convention);
32
+ * - heading and wave directions are radians, true;
33
+ * - wave direction parameters come in the *travel* convention
34
+ * (direction the waves move toward). Signal K and forecast APIs use
35
+ * the *from* convention — convert once with {@link travelDirection}.
36
+ *
37
+ * @module sereno-physics
38
+ */
39
+
40
+ /**
41
+ * Gravitational acceleration (m/s²).
42
+ */
43
+ export const G = 9.81;
44
+
45
+ /**
46
+ * Meters per second per knot.
47
+ */
48
+ export const KN_TO_MS = 0.514444;
49
+
50
+ /**
51
+ * Comfort tiers, best first. The index in this array is the severity
52
+ * rank used when combining vectors.
53
+ */
54
+ export const COMFORT_TIERS = ["champagne", "easy", "coffee", "rough", "sick"];
55
+
56
+ /**
57
+ * One-line meaning per comfort tier, display order best first, with
58
+ * the band lines that drop the tier (mirrors AWS_BANDS_KNOTS and
59
+ * AZ_BANDS_MS2). For the webapp explainer.
60
+ */
61
+ export const COMFORT_SCALE_INFO = [
62
+ {
63
+ tier: "champagne",
64
+ maxAws: 12,
65
+ maxAz: 0.15,
66
+ text: "Barely any motion — champagne stays in the glass.",
67
+ },
68
+ {
69
+ tier: "easy",
70
+ maxAws: 18,
71
+ maxAz: 0.315,
72
+ text: "Comfortable: moving around below is easy.",
73
+ },
74
+ {
75
+ tier: "coffee",
76
+ maxAws: 23,
77
+ maxAz: 0.63,
78
+ text: "You can still hold a hot coffee without wearing it.",
79
+ },
80
+ {
81
+ tier: "rough",
82
+ maxAws: 33,
83
+ maxAz: 1.25,
84
+ text: "One hand for the boat — loose objects go flying.",
85
+ },
86
+ {
87
+ tier: "sick",
88
+ maxAws: null,
89
+ maxAz: null,
90
+ text: "Beyond rough: seasickness very likely — think shelter or a course change.",
91
+ },
92
+ ];
93
+
94
+ /**
95
+ * Apparent wind speed (knots) at or above which each tier is lost
96
+ * (Champagne, Easy, Coffee, Rough). SPEC §5.2 vector 1.
97
+ */
98
+ export const AWS_BANDS_KNOTS = [12, 18, 23, 33];
99
+
100
+ /**
101
+ * RMS vertical acceleration (m/s²) at or above which each tier is lost
102
+ * (Champagne, Easy, Coffee, Rough). SPEC §5.2 vector 2, ISO 2631-1.
103
+ */
104
+ export const AZ_BANDS_MS2 = [0.15, 0.315, 0.63, 1.25];
105
+
106
+ /**
107
+ * Heel angle (degrees) reached at 25 kt apparent wind on a beam reach
108
+ * (SPEC §5.2: φ_max).
109
+ */
110
+ export const HEEL_MAX_DEG = 25;
111
+
112
+ /**
113
+ * Ratio threshold of peak period to significant wave height below
114
+ * which a sea is considered anomalously steep (SPEC §6.2
115
+ * `steepnessRatio < 3.28`).
116
+ */
117
+ export const STEEPNESS_RATIO_THRESHOLD = 3.28;
118
+
119
+ /**
120
+ * Sun altitude (degrees) below which it counts as night for sailing
121
+ * decisions. The night regime is really bounded by the watch changes,
122
+ * not by a twilight line: offshore the crew reefs down deeper at the
123
+ * evening watch change (usually the one near sunset) than the
124
+ * conditions alone require — both watchkeepers are awake then, so
125
+ * sail changes are easy, windvane steering benefits, and the night
126
+ * adds a margin for the squall you never saw in the dark. The
127
+ * regime ends at the morning watch change. Watch changes are only
128
+ * partly recorded, so sunset (altitude 0°) stands in for the evening
129
+ * flip. The morning side runs late (the day bucket starts at
130
+ * sunrise, before the actual shake-out); the threshold is a parameter
131
+ * so the backtest CLI can calibrate the boundary from the log once we
132
+ * know when the shakes really happen. The learned matrix keeps the
133
+ * day and night buckets separate either way.
134
+ */
135
+ export const NIGHT_SUN_ALTITUDE_DEG = 0;
136
+
137
+ /**
138
+ * Floor for the encounter-period stretch factor. A boat surfing along
139
+ * with a wave train it overtakes would otherwise drive Te to zero and
140
+ * the acceleration to infinity; the floor caps the stretch. Value from
141
+ * passage-weather's comfort model.
142
+ */
143
+ export const ENCOUNTER_FACTOR_FLOOR = 0.15;
144
+
145
+ /**
146
+ * Converts a direction from the meteorological *from* convention (the
147
+ * direction the sea/wind comes from, Signal K and forecast API
148
+ * convention) to the *travel* convention used by the encounter period
149
+ * math.
150
+ *
151
+ * @param {number} fromDirectionRad - Direction the waves come from (rad)
152
+ * @returns {number} Direction the waves travel toward (rad)
153
+ */
154
+ export function travelDirection(fromDirectionRad) {
155
+ const travel = fromDirectionRad + Math.PI;
156
+ return ((travel % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
157
+ }
158
+
159
+ /**
160
+ * Severity rank of a comfort tier (0 best … 4 worst).
161
+ *
162
+ * @param {string} tier - One of {@link COMFORT_TIERS}
163
+ * @returns {number} Severity rank
164
+ */
165
+ export function comfortSeverity(tier) {
166
+ const index = COMFORT_TIERS.indexOf(tier);
167
+ return index < 0 ? COMFORT_TIERS.length - 1 : index;
168
+ }
169
+
170
+ /**
171
+ * The worse of two comfort tiers.
172
+ *
173
+ * @param {string} a
174
+ * @param {string} b
175
+ * @returns {string}
176
+ */
177
+ export function worseComfort(a, b) {
178
+ return comfortSeverity(a) >= comfortSeverity(b) ? a : b;
179
+ }
180
+
181
+ /**
182
+ * Rates an apparent wind speed on the Sereno scale (SPEC §5.2
183
+ * vector 1 bands).
184
+ *
185
+ * @param {number} awsKnots - Apparent wind speed
186
+ * @returns {string} Comfort tier
187
+ */
188
+ export function rateApparentWind(awsKnots) {
189
+ for (let i = 0; i < AWS_BANDS_KNOTS.length; i++) {
190
+ if (awsKnots < AWS_BANDS_KNOTS[i]) {
191
+ return COMFORT_TIERS[i];
192
+ }
193
+ }
194
+ return COMFORT_TIERS[COMFORT_TIERS.length - 1];
195
+ }
196
+
197
+ /**
198
+ * Rates an RMS vertical acceleration on the Sereno scale (SPEC §5.2
199
+ * vector 2 bands, ISO 2631-1).
200
+ *
201
+ * @param {number} azMs2 - RMS vertical acceleration (m/s²)
202
+ * @returns {string} Comfort tier
203
+ */
204
+ export function rateAcceleration(azMs2) {
205
+ for (let i = 0; i < AZ_BANDS_MS2.length; i++) {
206
+ if (azMs2 < AZ_BANDS_MS2[i]) {
207
+ return COMFORT_TIERS[i];
208
+ }
209
+ }
210
+ return COMFORT_TIERS[COMFORT_TIERS.length - 1];
211
+ }
212
+
213
+ /**
214
+ * Apparent wind speed over the deck (SPEC §5.2 vector 1). TWA is
215
+ * signed (0 = wind from ahead), so head winds add and following winds
216
+ * subtract.
217
+ *
218
+ * @param {object} conditions
219
+ * @param {number} conditions.twsKnots - True wind speed
220
+ * @param {number} conditions.stwKnots - Speed through water
221
+ * @param {number} conditions.twaRad - True wind angle (rad, signed)
222
+ * @returns {number} Apparent wind speed (knots)
223
+ */
224
+ export function apparentWindSpeedKnots({ twsKnots, stwKnots, twaRad }) {
225
+ const { tws, stw } = { tws: twsKnots, stw: stwKnots };
226
+ return Math.sqrt(tws * tws + stw * stw + 2 * tws * stw * Math.cos(twaRad));
227
+ }
228
+
229
+ /**
230
+ * Deep-water wavelength of a wave train (L = g·T²/2π, about 1.56·T²).
231
+ *
232
+ * @param {number} tpSeconds - Peak period (s)
233
+ * @returns {number} Wavelength (m)
234
+ */
235
+ export function waveLengthMeters(tpSeconds) {
236
+ return (G * tpSeconds * tpSeconds) / (2 * Math.PI);
237
+ }
238
+
239
+ /**
240
+ * Deep-water phase speed of a wave train (c = g·T/2π).
241
+ *
242
+ * @param {number} tpSeconds - Peak period (s)
243
+ * @returns {number} Phase speed (m/s)
244
+ */
245
+ export function wavePhaseSpeedMs(tpSeconds) {
246
+ return (G * tpSeconds) / (2 * Math.PI);
247
+ }
248
+
249
+ /**
250
+ * Wave encounter period (SPEC §5.2 vector 2 step 1): how often the
251
+ * moving boat meets crests of a wave train. Waves met on the bow
252
+ * arrive faster (Te < Tp), waves from astern slower (Te > Tp).
253
+ *
254
+ * Uses the SPEC formula with two guards adopted from passage-weather:
255
+ * the stretch factor magnitude is floored at
256
+ * {@link ENCOUNTER_FACTOR_FLOOR} so a boat overtaking (surfing) a
257
+ * slower train cannot drive Te to zero, and the signed factor is
258
+ * folded with abs() — a negative denominator only means the boat
259
+ * overtakes the train, which is a very long encounter period, not a
260
+ * physical singularity.
261
+ *
262
+ * @param {object} params
263
+ * @param {number} params.tpSeconds - Wave peak period Tp (s)
264
+ * @param {number} params.sogKnots - Boat speed over ground
265
+ * @param {number} params.waveTravelDirectionRad - Direction the waves
266
+ * travel toward (rad, true; see {@link travelDirection})
267
+ * @param {number} params.headingRad - Vessel heading (rad, true)
268
+ * @returns {number} Encounter period Te (s), always positive
269
+ */
270
+ export function encounterPeriodSeconds({
271
+ tpSeconds,
272
+ sogKnots,
273
+ waveTravelDirectionRad,
274
+ headingRad,
275
+ }) {
276
+ const phaseSpeed = wavePhaseSpeedMs(tpSeconds);
277
+ const stretch =
278
+ 1 -
279
+ ((sogKnots * KN_TO_MS) / phaseSpeed) *
280
+ Math.cos(waveTravelDirectionRad - headingRad);
281
+ return tpSeconds / Math.max(Math.abs(stretch), ENCOUNTER_FACTOR_FLOOR);
282
+ }
283
+
284
+ /**
285
+ * Steepness ratio of a sea state: peak period over significant wave
286
+ * height. Values below {@link STEEPNESS_RATIO_THRESHOLD} mark an
287
+ * anomalously steep, wind-driven sea (SPEC §6.2 macro anomaly filter).
288
+ *
289
+ * @param {number} tpSeconds - Peak period (s)
290
+ * @param {number} hsMeters - Significant wave height (m)
291
+ * @returns {number} Tp/Hs ratio, Infinity for a flat sea
292
+ */
293
+ export function steepnessRatio(tpSeconds, hsMeters) {
294
+ if (hsMeters <= 0) {
295
+ return Infinity;
296
+ }
297
+ return tpSeconds / hsMeters;
298
+ }
299
+
300
+ /**
301
+ * Estimated heel angle (SPEC §5.2 vector 2 step 3). Peaks on a beam
302
+ * reach (|sin TWA| = 1), vanishes upwind and downwind, and scales
303
+ * linearly with apparent wind around the 25 kt reference.
304
+ *
305
+ * @param {object} params
306
+ * @param {number} params.awsKnots - Apparent wind speed
307
+ * @param {number} params.twaRad - True wind angle (rad, signed)
308
+ * @returns {number} Heel angle magnitude (degrees, 0..25)
309
+ */
310
+ export function heelAngleDeg({ awsKnots, twaRad }) {
311
+ const phiDeg = HEEL_MAX_DEG * (awsKnots / 25.0) * Math.abs(Math.sin(twaRad));
312
+ return Math.min(phiDeg, HEEL_MAX_DEG);
313
+ }
314
+
315
+ /**
316
+ * Heel multiplier of the vertical acceleration (SPEC §5.2 vector 2
317
+ * step 3): heel-induced roll bias on top of the vertical motion.
318
+ *
319
+ * @param {number} heelDeg - Heel angle magnitude (degrees)
320
+ * @param {number} kHeel - Heeling acceleration multiplier constant
321
+ * @returns {number} Multiplier (≥ 1)
322
+ */
323
+ export function heelMultiplier(heelDeg, kHeel) {
324
+ return 1.0 + kHeel * Math.abs(Math.sin((heelDeg * Math.PI) / 180));
325
+ }
326
+
327
+ /**
328
+ * Pitching resonance multiplier (SPEC §5.2 vector 2 step 4). Two
329
+ * terms on top of unity:
330
+ *
331
+ * - a Gaussian centered where the wave length matches the waterline
332
+ * (the hull pitches with the contour at resonant length);
333
+ * - a steepness penalty that grows as the sea gets steeper than the
334
+ * Tp/Hs = 3.28 reference.
335
+ *
336
+ * @param {object} params
337
+ * @param {number} params.tpSeconds - Combined peak period (s)
338
+ * @param {number} params.hsMeters - Combined significant height (m)
339
+ * @param {number} params.waterlineLengthM - Vessel waterline length (m)
340
+ * @param {number} kPitch - Pitching acceleration multiplier constant
341
+ * @returns {number} Multiplier (≥ 1)
342
+ */
343
+ export function pitchMultiplier(
344
+ { tpSeconds, hsMeters, waterlineLengthM },
345
+ kPitch,
346
+ ) {
347
+ const wavelength = waveLengthMeters(tpSeconds);
348
+ const resonance = Math.exp(
349
+ -(((wavelength - waterlineLengthM) / waterlineLengthM) ** 2),
350
+ );
351
+ const ratio = hsMeters > 0 ? tpSeconds / hsMeters : Infinity;
352
+ const steepness = Math.max(
353
+ 0,
354
+ (STEEPNESS_RATIO_THRESHOLD - ratio) / STEEPNESS_RATIO_THRESHOLD,
355
+ );
356
+ return 1.0 + kPitch * resonance + steepness;
357
+ }
358
+
359
+ /**
360
+ * Solar altitude (degrees) for an instant and position, using the
361
+ * low-precision Astronomical Almanac approximation (good to a few
362
+ * hundredths of a degree — far more than day/night bucketing needs).
363
+ *
364
+ * Shared so the server backfill can bucket historical sail events by
365
+ * night and the routing worker can bucket simulated hours the same
366
+ * way.
367
+ *
368
+ * @param {Date} date - Instant
369
+ * @param {number} latDeg - Latitude (degrees, north positive)
370
+ * @param {number} lonDeg - Longitude (degrees, east positive)
371
+ * @returns {number} Sun altitude above the horizon (degrees; negative
372
+ * below)
373
+ */
374
+ export function sunAltitudeDeg(date, latDeg, lonDeg) {
375
+ // Days since J2000.0
376
+ const n = date.getTime() / 86400000 + 2440587.5 - 2451545.0;
377
+
378
+ const meanLongitude = (280.46 + 0.9856474 * n) * (Math.PI / 180);
379
+ const meanAnomaly = ((357.528 + 0.9856003 * n) % 360) * (Math.PI / 180);
380
+ const eclipticLongitude =
381
+ meanLongitude +
382
+ ((1.915 * Math.PI) / 180) * Math.sin(meanAnomaly) +
383
+ ((0.02 * Math.PI) / 180) * Math.sin(2 * meanAnomaly);
384
+ const obliquity = (23.439 - 0.0000004 * n) * (Math.PI / 180);
385
+
386
+ const declination = Math.asin(
387
+ Math.sin(obliquity) * Math.sin(eclipticLongitude),
388
+ );
389
+ const rightAscension = Math.atan2(
390
+ Math.cos(obliquity) * Math.sin(eclipticLongitude),
391
+ Math.cos(eclipticLongitude),
392
+ );
393
+
394
+ // Greenwich mean sidereal time → local hour angle
395
+ const gmstDeg = (280.46061837 + 360.98564736629 * n) % 360;
396
+ let hourAngleDeg =
397
+ (gmstDeg + lonDeg - (rightAscension * 180) / Math.PI) % 360;
398
+ if (hourAngleDeg > 180) {
399
+ hourAngleDeg -= 360;
400
+ } else if (hourAngleDeg < -180) {
401
+ hourAngleDeg += 360;
402
+ }
403
+
404
+ const latRad = (latDeg * Math.PI) / 180;
405
+ const hourAngle = (hourAngleDeg * Math.PI) / 180;
406
+ const altitude = Math.asin(
407
+ Math.sin(latRad) * Math.sin(declination) +
408
+ Math.cos(latRad) * Math.cos(declination) * Math.cos(hourAngle),
409
+ );
410
+ return (altitude * 180) / Math.PI;
411
+ }
412
+
413
+ /**
414
+ * Whether it is night for sailing decisions at an instant and
415
+ * position (sun altitude below {@link NIGHT_SUN_ALTITUDE_DEG}).
416
+ *
417
+ * @param {Date} date - Instant
418
+ * @param {number} latDeg - Latitude (degrees)
419
+ * @param {number} lonDeg - Longitude (degrees)
420
+ * @param {number} [thresholdDeg] - Altitude threshold (degrees;
421
+ * default {@link NIGHT_SUN_ALTITUDE_DEG}, sunset)
422
+ * @returns {boolean}
423
+ */
424
+ export function isNight(
425
+ date,
426
+ latDeg,
427
+ lonDeg,
428
+ thresholdDeg = NIGHT_SUN_ALTITUDE_DEG,
429
+ ) {
430
+ return sunAltitudeDeg(date, latDeg, lonDeg) < thresholdDeg;
431
+ }
432
+
433
+ /**
434
+ * Monohull vertical acceleration assessment (SPEC §5.2 vector 2).
435
+ *
436
+ * @param {object} sea
437
+ * @param {number} sea.hsMeters - Combined significant wave height (m)
438
+ * @param {number} sea.tpSeconds - Combined peak period (s)
439
+ * @param {number} sea.waveTravelDirectionRad - Direction the waves
440
+ * travel toward (rad, true)
441
+ * @param {object} vessel
442
+ * @param {number} vessel.sogKnots - Speed over ground
443
+ * @param {number} vessel.headingRad - Vessel heading (rad, true)
444
+ * @param {number} vessel.waterlineLengthM - Waterline length (m)
445
+ * @param {number} vessel.kHeel - Heeling multiplier constant
446
+ * @param {number} vessel.kPitch - Pitching multiplier constant
447
+ * @param {object} wind
448
+ * @param {number} wind.twsKnots - True wind speed
449
+ * @param {number} wind.twaRad - True wind angle (rad, signed)
450
+ * @returns {{encounterPeriod: number, base: number, heel: number,
451
+ * pitch: number, heelDeg: number, value: number}} Acceleration
452
+ * breakdown; `value` is the effective RMS vertical acceleration
453
+ * (m/s²)
454
+ */
455
+ export function verticalAcceleration(sea, vessel, wind) {
456
+ const awsKnots = apparentWindSpeedKnots({
457
+ twsKnots: wind.twsKnots,
458
+ stwKnots: vessel.sogKnots,
459
+ twaRad: wind.twaRad,
460
+ });
461
+ const heelDeg = heelAngleDeg({ awsKnots, twaRad: wind.twaRad });
462
+
463
+ if (sea.hsMeters <= 0 || sea.tpSeconds <= 0) {
464
+ return {
465
+ encounterPeriod: Infinity,
466
+ base: 0,
467
+ heel: heelMultiplier(heelDeg, vessel.kHeel),
468
+ pitch: pitchMultiplier(
469
+ {
470
+ tpSeconds: sea.tpSeconds,
471
+ hsMeters: sea.hsMeters,
472
+ waterlineLengthM: vessel.waterlineLengthM,
473
+ },
474
+ vessel.kPitch,
475
+ ),
476
+ heelDeg,
477
+ value: 0,
478
+ };
479
+ }
480
+
481
+ const encounterPeriod = encounterPeriodSeconds({
482
+ tpSeconds: sea.tpSeconds,
483
+ sogKnots: vessel.sogKnots,
484
+ waveTravelDirectionRad: sea.waveTravelDirectionRad,
485
+ headingRad: vessel.headingRad,
486
+ });
487
+ // RMS elevation of a sinusoidal sea is Hs/4; RMS acceleration of the
488
+ // riding vessel follows the sea at the encounter frequency.
489
+ const base =
490
+ ((4 * Math.PI * Math.PI) / (encounterPeriod * encounterPeriod)) *
491
+ (sea.hsMeters / 2);
492
+ const heel = heelMultiplier(heelDeg, vessel.kHeel);
493
+ const pitch = pitchMultiplier(
494
+ {
495
+ tpSeconds: sea.tpSeconds,
496
+ hsMeters: sea.hsMeters,
497
+ waterlineLengthM: vessel.waterlineLengthM,
498
+ },
499
+ vessel.kPitch,
500
+ );
501
+ return {
502
+ encounterPeriod,
503
+ base,
504
+ heel,
505
+ pitch,
506
+ heelDeg,
507
+ value: base * heel * pitch,
508
+ };
509
+ }
510
+
511
+ /**
512
+ * Full Sereno comfort assessment for one passage hour (SPEC §5.2):
513
+ * apparent wind rating and motion rating, plus the combined level
514
+ * (the worse of the two).
515
+ *
516
+ * @param {object} sea - See {@link verticalAcceleration} `sea`
517
+ * @param {object} vessel - See {@link verticalAcceleration} `vessel`
518
+ * @param {object} wind - See {@link verticalAcceleration} `wind`
519
+ * @returns {{awsKnots: number, awsComfort: string, acceleration: object,
520
+ * motionComfort: string, comfort: string}}
521
+ */
522
+ export function serenoComfort(sea, vessel, wind) {
523
+ const awsKnots = apparentWindSpeedKnots({
524
+ twsKnots: wind.twsKnots,
525
+ stwKnots: vessel.sogKnots,
526
+ twaRad: wind.twaRad,
527
+ });
528
+ const acceleration = verticalAcceleration(sea, vessel, wind);
529
+ const awsComfort = rateApparentWind(awsKnots);
530
+ const motionComfort = rateAcceleration(acceleration.value);
531
+ return {
532
+ awsKnots,
533
+ awsComfort,
534
+ acceleration,
535
+ motionComfort,
536
+ comfort: worseComfort(awsComfort, motionComfort),
537
+ };
538
+ }
539
+
540
+ /**
541
+ * Index of the bin edge a value belongs to: the greatest edge `<=`
542
+ * value, clamped to the last bin. Mirrors the server-side helper of
543
+ * the same name in `plugin/sqlite-db.js` — duplicated because the
544
+ * browser cannot import from the plugin tree.
545
+ *
546
+ * @param {number} value - Observed quantity
547
+ * @param {number[]} bins - Sorted ascending bin edges
548
+ * @returns {number} Bin index into `bins`
549
+ */
550
+ export function binIndexFor(value, bins) {
551
+ let index = 0;
552
+ for (let i = 1; i < bins.length; i++) {
553
+ if (value >= bins[i]) {
554
+ index = i;
555
+ }
556
+ }
557
+ return index;
558
+ }
559
+
560
+ /**
561
+ * Finds the learned preferred sail state for wind conditions and time
562
+ * of day (SPEC §3.2 matrix lookup, extended with the day/night
563
+ * bucket: the crew reefs deeper at night than the conditions require,
564
+ * and the matrix keeps both behaviors).
565
+ *
566
+ * @param {object} matrix - Learned sail preference matrix
567
+ * (`SailPreferenceMatrix`, see `plugin/sqlite-db.js`)
568
+ * @param {number} twsKnots - True wind speed
569
+ * @param {number} twaDeg - True wind angle magnitude (degrees, 0..180)
570
+ * @param {boolean} [night=false] - Night bucket
571
+ * @returns {{preferredSailState: string, minTwsGustTrigger: number,
572
+ * samplesCount: number, night: boolean}|null} Matrix cell, or null
573
+ * when the bin was never observed
574
+ */
575
+ export function suggestSailState(matrix, twsKnots, twaDeg, night = false) {
576
+ if (!matrix || !Array.isArray(matrix.matrix)) {
577
+ return null;
578
+ }
579
+ // TWA bins cover 0..180; fold the signed angle onto that range.
580
+ const folded = Math.abs(((twaDeg % 360) + 360) % 360);
581
+ const twaFolded = folded > 180 ? 360 - folded : folded;
582
+ const twsBin = binIndexFor(twsKnots, matrix.twsBinsKnots);
583
+ const twaBin = binIndexFor(twaFolded, matrix.twaBinsDegrees);
584
+ return (
585
+ matrix.matrix.find(
586
+ (cell) =>
587
+ cell.twsBin === twsBin &&
588
+ cell.twaBin === twaBin &&
589
+ Boolean(cell.night) === night,
590
+ ) || null
591
+ );
592
+ }