@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,540 @@
1
+ /**
2
+ * Sail preference backfill engine (SPEC §4.2).
3
+ *
4
+ * For each logbook sail event the engine gathers the wind conditions
5
+ * of the 15-minute window ending at the event, condenses them into
6
+ * TWS average / TWS peak / circular-mean TWA, caches them, and folds
7
+ * the observation into the learned sail preference matrix with an
8
+ * exponential moving average (α = 0.2).
9
+ *
10
+ * Wind sources are pluggable through a `getWindStats(event)` callback:
11
+ *
12
+ * - {@link createLogbookWindStats} reads the wind snapshots the crew
13
+ * wrote into the logbook entries themselves (the event entry and any
14
+ * adjacent entries in the window, e.g. hourly entries). This is the
15
+ * default and needs no History API — usable anywhere, including
16
+ * ashore with a copy of the log repository.
17
+ * - {@link createHistoryWindStats} queries the Signal K History API
18
+ * (`environment.wind.speedTrue` / `environment.wind.angleTrue`) for
19
+ * the window. This is the SPEC §4.2 source of record and is meant
20
+ * for the on-board backfill run where the full-resolution history is
21
+ * present.
22
+ *
23
+ * Backfills are idempotent: an event whose timestamp already has a
24
+ * `wind_history_cache` row is skipped, so re-running over overlapping
25
+ * windows never double-counts samples in the EMA.
26
+ *
27
+ * @file history-backfill.js
28
+ */
29
+
30
+ const { twsBinIndex, twaBinIndex, emaUpdate } = require("./sqlite-db.js");
31
+
32
+ /**
33
+ * Lazy handle on the shared physics module (ESM, imported from this
34
+ * CJS module on first use) for the day/night bucket.
35
+ *
36
+ * @type {Promise<typeof import("../public/sereno-physics.mjs")>|null}
37
+ */
38
+ let physicsPromise = null;
39
+
40
+ /**
41
+ * @returns {Promise<typeof import("../public/sereno-physics.mjs")>}
42
+ */
43
+ function loadPhysics() {
44
+ if (!physicsPromise) {
45
+ physicsPromise = import("../public/sereno-physics.mjs");
46
+ }
47
+ return physicsPromise;
48
+ }
49
+
50
+ /**
51
+ * Wind window ending at each sail event (SPEC §4.2).
52
+ */
53
+ const WINDOW_MINUTES = 15;
54
+
55
+ /**
56
+ * Signal K paths queried from the History API.
57
+ */
58
+ const WIND_PATHS = ["environment.wind.speedTrue", "environment.wind.angleTrue"];
59
+
60
+ /**
61
+ * History API sample resolution (seconds).
62
+ */
63
+ const HISTORY_RESOLUTION_SECONDS = 10;
64
+
65
+ /**
66
+ * Meters per second per knot (History API values are SI).
67
+ */
68
+ const MS_TO_KNOTS = 1.943844;
69
+
70
+ /**
71
+ * Parses a timestamp that carries no explicit offset as UTC (the
72
+ * History API returns naive ISO strings).
73
+ *
74
+ * @param {string} time - ISO 8601 timestamp
75
+ * @returns {Date}
76
+ */
77
+ function parseUtcTimestamp(time) {
78
+ const hasOffset = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(time);
79
+ return new Date(hasOffset ? time : `${time}Z`);
80
+ }
81
+
82
+ /**
83
+ * Folds a true wind direction in degrees onto the 0..180 TWA magnitude
84
+ * range of the learned matrix bins.
85
+ *
86
+ * @param {number} degrees - True wind direction (degrees)
87
+ * @returns {number} |TWA| in 0..180 degrees
88
+ */
89
+ function foldTwaDegrees(degrees) {
90
+ const normalized = ((degrees % 360) + 360) % 360;
91
+ return normalized > 180 ? 360 - normalized : normalized;
92
+ }
93
+
94
+ /**
95
+ * Circular mean of wind angles given in degrees, folded to 0..180.
96
+ *
97
+ * @param {number[]} degreesList - Folded angles (0..180 degrees)
98
+ * @returns {number} Circular mean (0..180 degrees)
99
+ */
100
+ function circularMeanDegrees(degreesList) {
101
+ let sinSum = 0;
102
+ let cosSum = 0;
103
+ for (const degrees of degreesList) {
104
+ const rad = (degrees * Math.PI) / 180;
105
+ sinSum += Math.sin(rad);
106
+ cosSum += Math.cos(rad);
107
+ }
108
+ const mean = (Math.atan2(sinSum, cosSum) * 180) / Math.PI;
109
+ return foldTwaDegrees(mean);
110
+ }
111
+
112
+ /**
113
+ * Wind statistics of a sail event's window (SPEC §4.2 step 2).
114
+ *
115
+ * @typedef {object} WindWindowStats
116
+ * @property {number} samples - Number of wind observations
117
+ * @property {number} twsAvg - Mean true wind speed (kn)
118
+ * @property {number} twsPeak - Peak true wind speed (kn)
119
+ * @property {number} twaAvg - Circular mean true wind angle (deg,
120
+ * 0..180)
121
+ */
122
+
123
+ /**
124
+ * Builds a `getWindStats(event)` function that derives the window wind
125
+ * statistics from the wind snapshots written in the logbook entries
126
+ * (human-friendly units: knots, degrees true). All entries carrying a
127
+ * numeric `wind.speed` within `[t − window, t]` contribute — the sail
128
+ * event's own entry and any adjacent entries such as the hourly
129
+ * automatic entries.
130
+ *
131
+ * @param {Array<{datetime: string|Date, wind?: {speed?: number, direction?: number}}>|
132
+ * Array<{datetime: string|Date, text?: string, wind?: {speed?: number, direction?: number}}>} entries
133
+ * All logbook entries (not just sail events)
134
+ * @param {number} [windowMinutes] - Window length (default
135
+ * {@link WINDOW_MINUTES})
136
+ * @returns {(event: {timestamp: string}) => WindWindowStats|null}
137
+ */
138
+ function createLogbookWindStats(entries, windowMinutes = WINDOW_MINUTES) {
139
+ // Pre-index the wind-carrying entries once, sorted by time.
140
+ const windEntries = entries
141
+ .map((entry) => ({
142
+ time: new Date(entry.datetime).getTime(),
143
+ twsKnots:
144
+ entry.wind && typeof entry.wind.speed === "number"
145
+ ? entry.wind.speed
146
+ : null,
147
+ twaDeg:
148
+ entry.wind && typeof entry.wind.direction === "number"
149
+ ? foldTwaDegrees(entry.wind.direction)
150
+ : null,
151
+ }))
152
+ .filter((e) => Number.isFinite(e.time) && e.twsKnots != null)
153
+ .sort((a, b) => a.time - b.time);
154
+
155
+ return (event) => {
156
+ const endMs = new Date(event.timestamp).getTime();
157
+ const startMs = endMs - windowMinutes * 60 * 1000;
158
+ const speeds = [];
159
+ const angles = [];
160
+ for (const entry of windEntries) {
161
+ if (entry.time < startMs || entry.time > endMs) {
162
+ continue;
163
+ }
164
+ speeds.push(entry.twsKnots);
165
+ if (entry.twaDeg != null) {
166
+ angles.push(entry.twaDeg);
167
+ }
168
+ }
169
+ if (speeds.length === 0) {
170
+ return null;
171
+ }
172
+ return {
173
+ samples: speeds.length,
174
+ twsAvg: speeds.reduce((a, b) => a + b, 0) / speeds.length,
175
+ twsPeak: Math.max(...speeds),
176
+ twaAvg: angles.length > 0 ? circularMeanDegrees(angles) : 0,
177
+ };
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Queries the Signal K History API (SPEC §4.2 step 1). Numeric wind
183
+ * paths need no aggregate-method suffix.
184
+ *
185
+ * @param {object} params
186
+ * @param {string} params.baseUrl - Signal K server base URL
187
+ * @param {string} [params.token] - Bearer token (defaults to the
188
+ * SIGNALK_TOKEN env var)
189
+ * @param {string} [params.provider] - History provider ID
190
+ * @param {Date} params.from - Window start
191
+ * @param {Date} params.to - Window end
192
+ * @param {string[]} [params.paths] - Paths to query (default
193
+ * {@link WIND_PATHS})
194
+ * @param {number} [params.resolution] - Resolution in seconds
195
+ * @param {typeof fetch} [params.fetchImpl] - Fetch implementation (tests)
196
+ * @returns {Promise<object>} History API `/values` response
197
+ */
198
+ async function queryHistory({
199
+ baseUrl,
200
+ token = process.env.SIGNALK_TOKEN,
201
+ provider,
202
+ from,
203
+ to,
204
+ paths = WIND_PATHS,
205
+ resolution = HISTORY_RESOLUTION_SECONDS,
206
+ fetchImpl = fetch,
207
+ }) {
208
+ const url = new URL("/signalk/v2/api/history/values", baseUrl);
209
+ url.searchParams.set("paths", paths.join(","));
210
+ url.searchParams.set("from", from.toISOString());
211
+ url.searchParams.set("to", to.toISOString());
212
+ url.searchParams.set("resolution", String(resolution));
213
+ if (provider) {
214
+ url.searchParams.set("provider", provider);
215
+ }
216
+ const headers = { Accept: "application/json" };
217
+ if (token) {
218
+ headers.Authorization = `Bearer ${token}`;
219
+ }
220
+ const response = await fetchImpl(url, { headers });
221
+ if (!response.ok) {
222
+ throw new Error(
223
+ `History API returned ${response.status}: ${response.statusText}`,
224
+ );
225
+ }
226
+ return response.json();
227
+ }
228
+
229
+ /**
230
+ * Extracts wind window statistics from a History API `/values`
231
+ * response (SPEC §4.2 steps 1–2). Values are SI: speedTrue in m/s,
232
+ * angleTrue in radians.
233
+ *
234
+ * @param {object} params
235
+ * @param {object} params.historyData - History API response
236
+ * @param {Date} params.from - Window start (inclusive)
237
+ * @param {Date} params.to - Window end (inclusive)
238
+ * @returns {WindWindowStats|null} Null when the window has no wind data
239
+ */
240
+ function extractWindWindowStats({ historyData, from, to }) {
241
+ const columns = new Map();
242
+ for (const [index, column] of (historyData.values || []).entries()) {
243
+ if (column.path != null && !columns.has(column.path)) {
244
+ columns.set(column.path, index + 1);
245
+ }
246
+ }
247
+ const speedColumn = columns.get("environment.wind.speedTrue");
248
+ const angleColumn = columns.get("environment.wind.angleTrue");
249
+ if (speedColumn == null) {
250
+ return null;
251
+ }
252
+
253
+ const fromMs = from.getTime();
254
+ const toMs = to.getTime();
255
+ const speeds = [];
256
+ const angles = [];
257
+ for (const row of historyData.data || []) {
258
+ const time = parseUtcTimestamp(row[0]).getTime();
259
+ if (time < fromMs || time > toMs) {
260
+ continue;
261
+ }
262
+ const speedMs = row[speedColumn];
263
+ if (typeof speedMs !== "number" || !Number.isFinite(speedMs)) {
264
+ continue;
265
+ }
266
+ speeds.push(speedMs * MS_TO_KNOTS);
267
+ if (angleColumn != null) {
268
+ const angleRad = row[angleColumn];
269
+ if (typeof angleRad === "number" && Number.isFinite(angleRad)) {
270
+ angles.push(foldTwaDegrees((angleRad * 180) / Math.PI));
271
+ }
272
+ }
273
+ }
274
+ if (speeds.length === 0) {
275
+ return null;
276
+ }
277
+ return {
278
+ samples: speeds.length,
279
+ twsAvg: speeds.reduce((a, b) => a + b, 0) / speeds.length,
280
+ twsPeak: Math.max(...speeds),
281
+ twaAvg: angles.length > 0 ? circularMeanDegrees(angles) : 0,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Builds a `getWindStats(event)` function backed by the Signal K
287
+ * History API (the SPEC §4.2 source of record, for the on-board run).
288
+ * Each event triggers one window query.
289
+ *
290
+ * @param {object} [options]
291
+ * @param {string} options.baseUrl - Signal K server base URL
292
+ * @param {string} [options.token] - Bearer token (defaults to the
293
+ * SIGNALK_TOKEN env var)
294
+ * @param {string} [options.provider] - History provider ID
295
+ * @param {number} [options.windowMinutes] - Window length (default
296
+ * {@link WINDOW_MINUTES})
297
+ * @param {number} [options.resolution] - Resolution in seconds
298
+ * @param {typeof fetch} [options.fetchImpl] - Fetch implementation (tests)
299
+ * @returns {(event: {timestamp: string}) => Promise<WindWindowStats|null>}
300
+ */
301
+ function createHistoryWindStats({
302
+ baseUrl,
303
+ token,
304
+ provider,
305
+ windowMinutes = WINDOW_MINUTES,
306
+ resolution,
307
+ fetchImpl,
308
+ } = {}) {
309
+ return async (event) => {
310
+ const to = new Date(event.timestamp);
311
+ const from = new Date(to.getTime() - windowMinutes * 60 * 1000);
312
+ const historyData = await queryHistory({
313
+ baseUrl,
314
+ token,
315
+ provider,
316
+ from,
317
+ to,
318
+ resolution,
319
+ fetchImpl,
320
+ });
321
+ return extractWindWindowStats({ historyData, from, to });
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Folds one sail event observation into the learned matrix
327
+ * (SPEC §4.2 step 3) and caches the window statistics.
328
+ *
329
+ * The matrix bins are indexed by the window's average TWS and TWA plus
330
+ * the day/night bucket — the crew reefs deeper at night than the
331
+ * conditions alone require, and the matrix keeps both behaviors.
332
+ * Both trigger columns learn through the α = 0.2 EMA: the average
333
+ * trigger from the window's mean wind, the peak/gust trigger from the
334
+ * window's peak wind. The preferred sail of a bin is the most recent
335
+ * observation.
336
+ *
337
+ * @param {object} params
338
+ * @param {import("./sqlite-db.js").PassageDatabase} params.db
339
+ * @param {{timestamp: string, eventType: string, sailState: string, night?: boolean, notes?: string}} params.event
340
+ * @param {WindWindowStats} params.stats
341
+ * @param {boolean} [params.night] - Day/night bucket of the event
342
+ * (defaults to the event's own flag, else day)
343
+ * @returns {{twsBin: number, twaBin: number, night: boolean,
344
+ * preferredSail: string, avgTwsTrigger: number, peakGustTrigger:
345
+ * number, sampleCount: number}}
346
+ */
347
+ function applySailEvent({ db, event, stats, night = event.night ?? false }) {
348
+ db.upsertWindHistory({
349
+ timestamp: event.timestamp,
350
+ twsAvg: stats.twsAvg,
351
+ twsPeak: stats.twsPeak,
352
+ twaAvg: stats.twaAvg,
353
+ });
354
+
355
+ const twsBin = twsBinIndex(stats.twsAvg);
356
+ const twaBin = twaBinIndex(stats.twaAvg);
357
+ const existing = db
358
+ .getMatrixBins()
359
+ .find(
360
+ (bin) =>
361
+ bin.twsBin === twsBin && bin.twaBin === twaBin && bin.night === night,
362
+ );
363
+
364
+ const cell = {
365
+ twsBin,
366
+ twaBin,
367
+ night,
368
+ preferredSail: event.sailState,
369
+ avgTwsTrigger: emaUpdate(existing?.avgTwsTrigger ?? null, stats.twsAvg),
370
+ peakGustTrigger: emaUpdate(
371
+ existing?.peakGustTrigger ?? null,
372
+ stats.twsPeak,
373
+ ),
374
+ sampleCount: (existing?.sampleCount ?? 0) + 1,
375
+ };
376
+ db.upsertMatrixBin(cell);
377
+ return cell;
378
+ }
379
+
380
+ /**
381
+ * Runs the backfill over a list of sail events (SPEC §4.2). Events are
382
+ * processed chronologically; an event already present in
383
+ * `wind_history_cache` is skipped so re-runs are idempotent. Learned
384
+ * events are also recorded into `logbook_sail_events`.
385
+ *
386
+ * Each event lands in the day or night matrix bucket: events with a
387
+ * position get the bucket from the sun altitude at the event (below
388
+ * the end of civil twilight is night — past dusk, squalls stop being
389
+ * visible and the crew reefs deeper); events without a position are
390
+ * treated as day.
391
+ *
392
+ * @param {object} params
393
+ * @param {import("./sqlite-db.js").PassageDatabase} params.db
394
+ * @param {Array<{timestamp: string, eventType: string, sailState: string, position?: {latitude: number, longitude: number}, night?: boolean, notes?: string}>} params.events
395
+ * @param {(event: {timestamp: string}) => WindWindowStats|null|Promise<WindWindowStats|null>} params.getWindStats
396
+ * Wind source (see {@link createLogbookWindStats} and
397
+ * {@link createHistoryWindStats})
398
+ * @returns {Promise<{total: number, learned: number, skippedCached: number,
399
+ * skippedNoData: number, cells: object[]}>}
400
+ */
401
+ async function backfillSailEvents({ db, events, getWindStats }) {
402
+ const { isNight } = await loadPhysics();
403
+ const sorted = [...events].sort(
404
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
405
+ );
406
+
407
+ let learned = 0;
408
+ let skippedCached = 0;
409
+ let skippedNoData = 0;
410
+ const cells = [];
411
+
412
+ for (const event of sorted) {
413
+ if (!event.timestamp || Number.isNaN(new Date(event.timestamp).getTime())) {
414
+ skippedNoData++;
415
+ continue;
416
+ }
417
+ const cached = db.getWindHistory(event.timestamp, event.timestamp);
418
+ if (cached.length > 0) {
419
+ skippedCached++;
420
+ continue;
421
+ }
422
+ const stats = await getWindStats(event);
423
+ if (!stats) {
424
+ skippedNoData++;
425
+ continue;
426
+ }
427
+ const night =
428
+ event.night ??
429
+ (event.position
430
+ ? isNight(
431
+ new Date(event.timestamp),
432
+ event.position.latitude,
433
+ event.position.longitude,
434
+ )
435
+ : false);
436
+ cells.push(applySailEvent({ db, event, stats, night }));
437
+ db.recordSailEvent({
438
+ timestamp: event.timestamp,
439
+ eventType: event.eventType,
440
+ sailState: event.sailState,
441
+ night,
442
+ notes: event.notes,
443
+ });
444
+ learned++;
445
+ }
446
+
447
+ return {
448
+ total: sorted.length,
449
+ learned,
450
+ skippedCached,
451
+ skippedNoData,
452
+ cells,
453
+ };
454
+ }
455
+
456
+ const round1 = (value) => Math.round(value * 10) / 10;
457
+
458
+ /**
459
+ * Summarizes the sailing conditions per sail combination from the
460
+ * learned events and their cached wind statistics. Answers "in what
461
+ * conditions do we fly this setup?" — the raw material behind the
462
+ * learned matrix, and the comparison baseline for the configured
463
+ * whole-sail wind limits of `@signalk/sailsconfiguration`.
464
+ *
465
+ * @param {Array<{timestamp: string, eventType: string, sailState: string}>} events
466
+ * Learned sail events (e.g. `db.getSailEvents()` output or the
467
+ * logbook events that were backfilled)
468
+ * @param {Array<{timestamp: string, twsAvg: number, twsPeak: number, twaAvg: number}>} windRows
469
+ * Cached wind window statistics (`db.getWindHistory()` output)
470
+ * @returns {Array<{sailState: string, samples: number,
471
+ * eventTypes: Object<string, number>, twsAvgMin: number,
472
+ * twsAvgMean: number, twsAvgMax: number, twsPeakMax: number,
473
+ * twaMin: number, twaMean: number, twaMax: number}>}
474
+ * One row per sail state, ordered by mean TWS ascending
475
+ */
476
+ function summarizeSailUsage(events, windRows) {
477
+ const statsByTimestamp = new Map(windRows.map((row) => [row.timestamp, row]));
478
+ const groups = new Map();
479
+ for (const event of events) {
480
+ const stats = statsByTimestamp.get(event.timestamp);
481
+ if (!stats) {
482
+ continue;
483
+ }
484
+ const night = Boolean(event.night);
485
+ const key = `${event.sailState}|${night ? "night" : "day"}`;
486
+ let group = groups.get(key);
487
+ if (!group) {
488
+ group = {
489
+ sailState: event.sailState,
490
+ night,
491
+ samples: 0,
492
+ eventTypes: {},
493
+ twsAvg: [],
494
+ twsPeak: [],
495
+ twa: [],
496
+ };
497
+ groups.set(key, group);
498
+ }
499
+ group.samples++;
500
+ group.eventTypes[event.eventType] =
501
+ (group.eventTypes[event.eventType] ?? 0) + 1;
502
+ group.twsAvg.push(stats.twsAvg);
503
+ group.twsPeak.push(stats.twsPeak);
504
+ group.twa.push(stats.twaAvg);
505
+ }
506
+
507
+ const mean = (values) => values.reduce((a, b) => a + b, 0) / values.length;
508
+ return Array.from(groups.values())
509
+ .map((group) => ({
510
+ sailState: group.sailState,
511
+ night: group.night,
512
+ samples: group.samples,
513
+ eventTypes: group.eventTypes,
514
+ twsAvgMin: round1(Math.min(...group.twsAvg)),
515
+ twsAvgMean: round1(mean(group.twsAvg)),
516
+ twsAvgMax: round1(Math.max(...group.twsAvg)),
517
+ twsPeakMax: round1(Math.max(...group.twsPeak)),
518
+ twaMin: round1(Math.min(...group.twa)),
519
+ twaMean: round1(mean(group.twa)),
520
+ twaMax: round1(Math.max(...group.twa)),
521
+ }))
522
+ .sort((a, b) => a.twsAvgMean - b.twsAvgMean);
523
+ }
524
+
525
+ module.exports = {
526
+ WINDOW_MINUTES,
527
+ WIND_PATHS,
528
+ HISTORY_RESOLUTION_SECONDS,
529
+ MS_TO_KNOTS,
530
+ parseUtcTimestamp,
531
+ foldTwaDegrees,
532
+ circularMeanDegrees,
533
+ createLogbookWindStats,
534
+ createHistoryWindStats,
535
+ queryHistory,
536
+ extractWindWindowStats,
537
+ applySailEvent,
538
+ backfillSailEvents,
539
+ summarizeSailUsage,
540
+ };