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