@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,742 @@
1
+ /**
2
+ * Backtest & calibration engine (SPEC §7).
3
+ *
4
+ * Replays the vessel's own history through the Sereno motion model:
5
+ * attitude telemetry (roll, pitch) is reconstructed into a measured
6
+ * RMS vertical acceleration per sliding window, the model's predicted
7
+ * acceleration is computed for the same windows, and a Nelder-Mead
8
+ * simplex tunes (k_heel, k_pitch) to minimize the mean absolute
9
+ * error. A 5×5 confusion matrix compares the predicted vs measured
10
+ * Sereno comfort tiers at the tuned parameters.
11
+ *
12
+ * History contract (matches the signalk-polar-tools replays and the
13
+ * server's History API):
14
+ *
15
+ * - `GET /signalk/v2/api/history/values?paths=&from=&to=&resolution=`
16
+ * replies `{values: [{path, method}], data: [[ts, cell, ...]]}` in
17
+ * request column order; `null` cells mean no data in the bucket.
18
+ * - `navigation.attitude` is only recorded as its component paths
19
+ * (`navigation.attitude.roll` / `.pitch`), so those are queried
20
+ * directly, with `:average` methods on numeric paths.
21
+ * - Stored resolution is ~10 s on this installation, not the SPEC's
22
+ * aspirational 1 Hz: the discrete second derivative uses the actual
23
+ * sample spacing, so coarser history just attenuates the high
24
+ * frequency content (reported as `resolutionSeconds`).
25
+ *
26
+ * When no wave height/period history exists (common), the sea state
27
+ * is approximated from apparent wind with the fully-developed
28
+ * Pierson-Moskowitz relations (hs = 0.0246·U², tp = 0.725·U) —
29
+ * documented pessimism for a wind-driven sea, not a swell claim.
30
+ *
31
+ * Pure logic plus one injectable `fetchImpl` — unit-testable without
32
+ * a server.
33
+ *
34
+ * @file backtest.js
35
+ */
36
+
37
+ /** History paths queried for a backtest run, with column methods. */
38
+ const HISTORY_PATHS = [
39
+ "navigation.attitude.roll:average",
40
+ "navigation.attitude.pitch:average",
41
+ "navigation.speedThroughWater:average",
42
+ "navigation.headingTrue:average",
43
+ "environment.wind.speedApparent:average",
44
+ "environment.wind.angleApparent:average",
45
+ "environment.water.wave.height:average",
46
+ "environment.water.wave.period:average",
47
+ "environment.water.wave.directionTrue:average",
48
+ "environment.water.swell.height:average",
49
+ "environment.water.swell.period:average",
50
+ ];
51
+
52
+ /** Default history resolution (seconds): what this server stores. */
53
+ const DEFAULT_RESOLUTION_SECONDS = 10;
54
+
55
+ /** Minimum samples in a window for a usable second derivative. */
56
+ const MIN_WINDOW_SAMPLES = 4;
57
+
58
+ /** Default waterline length (m) — Lille Ø, matches plugin defaults. */
59
+ const DEFAULT_WATERLINE_M = 9.4;
60
+
61
+ const KN_TO_MS = 0.514444;
62
+
63
+ let physicsModule = null;
64
+
65
+ /**
66
+ * Loads the shared physics module (ESM in `public/`, consumed from
67
+ * CJS via dynamic import — same pattern as the history backfill).
68
+ *
69
+ * @returns {Promise<object>} sereno-physics module
70
+ */
71
+ async function loadPhysics() {
72
+ if (!physicsModule) {
73
+ physicsModule = await import("../public/sereno-physics.mjs");
74
+ }
75
+ return physicsModule;
76
+ }
77
+
78
+ /**
79
+ * Parses a `/values` response into a Map of bare path → column index.
80
+ * The API reports each column's method in `values[].method`, stripped
81
+ * from `values[].path`.
82
+ *
83
+ * @param {object} historyData - History API `/values` response
84
+ * @returns {Map<string, number>} Bare path → column index (0-based
85
+ * within the row, the timestamp being column −1)
86
+ */
87
+ function parseColumns(historyData) {
88
+ const columns = new Map();
89
+ (historyData?.values ?? []).forEach((spec, index) => {
90
+ if (spec?.path) {
91
+ columns.set(String(spec.path), index);
92
+ }
93
+ });
94
+ return columns;
95
+ }
96
+
97
+ /**
98
+ * Reads one numeric cell from a data row. The timestamp occupies
99
+ * `row[0]`; the queried columns start at `row[1]` (the same
100
+ * `row[col + 1]` offset as the replay tools).
101
+ *
102
+ * @param {Array} row - `[timestamp, cell0, ...]`
103
+ * @param {number|null} column - Column index, null when unqueried
104
+ * @returns {number|null}
105
+ */
106
+ function cellNumber(row, column) {
107
+ if (column == null || !Array.isArray(row)) {
108
+ return null;
109
+ }
110
+ const raw = row[column + 1];
111
+ if (raw == null) {
112
+ return null;
113
+ }
114
+ const value =
115
+ typeof raw === "object" && !Array.isArray(raw) ? raw.value : raw;
116
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
117
+ }
118
+
119
+ /**
120
+ * Merges the queried history into a chronological sample series.
121
+ * Missing values stay null — later steps tolerate gaps.
122
+ *
123
+ * @param {object} historyData - History API `/values` response
124
+ * @returns {Array<{tMs: number, rollRad: number|null, pitchRad: number|null,
125
+ * stwKnots: number|null, headingRad: number|null, awsKnots: number|null,
126
+ * awaRad: number|null, hsMeters: number|null, tpSeconds: number|null,
127
+ * waveTravelRad: number|null}>}
128
+ */
129
+ function buildSamples(historyData) {
130
+ const columns = parseColumns(historyData);
131
+ const col = (path) => columns.get(path) ?? null;
132
+ const rollC = col("navigation.attitude.roll");
133
+ const pitchC = col("navigation.attitude.pitch");
134
+ const stwC = col("navigation.speedThroughWater");
135
+ const headingC = col("navigation.headingTrue");
136
+ const awsC = col("environment.wind.speedApparent");
137
+ const awaC = col("environment.wind.angleApparent");
138
+ const waveH = col("environment.water.wave.height");
139
+ const waveP = col("environment.water.wave.period");
140
+ const waveD = col("environment.water.wave.directionTrue");
141
+ const swellH = col("environment.water.swell.height");
142
+ const swellP = col("environment.water.swell.period");
143
+
144
+ const samples = [];
145
+ for (const row of historyData?.data ?? []) {
146
+ if (!Array.isArray(row) || row.length < 2) {
147
+ continue;
148
+ }
149
+ const tMs = Date.parse(row[0]);
150
+ if (Number.isNaN(tMs)) {
151
+ continue;
152
+ }
153
+ const waveHeight = cellNumber(row, waveH);
154
+ const swellHeight = cellNumber(row, swellH);
155
+ const hs =
156
+ waveHeight != null
157
+ ? waveHeight
158
+ : swellHeight != null
159
+ ? Math.max(swellHeight, 0.3) // wind sea floor when only swell logged
160
+ : null;
161
+ const wavePeriod = cellNumber(row, waveP);
162
+ const swellPeriod = cellNumber(row, swellP);
163
+ const stwMs = cellNumber(row, stwC);
164
+ const awsMs = cellNumber(row, awsC);
165
+ samples.push({
166
+ tMs,
167
+ rollRad: cellNumber(row, rollC),
168
+ pitchRad: cellNumber(row, pitchC),
169
+ stwKnots: stwMs == null ? null : stwMs / KN_TO_MS,
170
+ headingRad: cellNumber(row, headingC),
171
+ // History stores SI (m/s); the model works in knots
172
+ awsKnots: awsMs == null ? null : awsMs / KN_TO_MS,
173
+ awaRad: cellNumber(row, awaC),
174
+ hsMeters: hs,
175
+ tpSeconds: wavePeriod ?? swellPeriod,
176
+ waveTravelRad: cellNumber(row, waveD),
177
+ });
178
+ }
179
+ return samples;
180
+ }
181
+
182
+ /**
183
+ * Converts apparent wind over deck into true wind, given the boat's
184
+ * speed through water and heading (no-leeway approximation).
185
+ *
186
+ * @param {object} params
187
+ * @param {number} params.awsKnots - Apparent wind speed (kn)
188
+ * @param {number} params.awaRad - Apparent wind angle (rad, signed,
189
+ * from the bow)
190
+ * @param {number} params.stwKnots - Speed through water (kn)
191
+ * @param {number} params.headingRad - Heading (rad, true)
192
+ * @returns {{twsKnots: number, twaRad: number}} True wind (kn, rad
193
+ * signed from the bow)
194
+ */
195
+ function trueFromApparent({ awsKnots, awaRad, stwKnots, headingRad }) {
196
+ const appN = awsKnots * Math.cos(headingRad + awaRad);
197
+ const appE = awsKnots * Math.sin(headingRad + awaRad);
198
+ const boatN = stwKnots * Math.cos(headingRad);
199
+ const boatE = stwKnots * Math.sin(headingRad);
200
+ const trueN = appN - boatN;
201
+ const trueE = appE - boatE;
202
+ const tws = Math.hypot(trueN, trueE);
203
+ let twa = Math.atan2(trueE, trueN) - headingRad;
204
+ if (twa > Math.PI) {
205
+ twa -= 2 * Math.PI;
206
+ } else if (twa < -Math.PI) {
207
+ twa += 2 * Math.PI;
208
+ }
209
+ return { twsKnots: tws, twaRad: twa };
210
+ }
211
+
212
+ /**
213
+ * Approximates the sea state from apparent wind when the history has
214
+ * no wave recordings: fully-developed Pierson-Moskowitz relations
215
+ * (Carter): hs = 0.0246·U10², tp = 0.725·U10 (U10 in m/s). Waves
216
+ * assumed to oppose the apparent wind direction (wind sea).
217
+ *
218
+ * @param {number} awsKnots
219
+ * @returns {{hsMeters: number, tpSeconds: number}}
220
+ */
221
+ function waveFromWind(awsKnots) {
222
+ const u10 = awsKnots * KN_TO_MS;
223
+ return {
224
+ hsMeters: 0.0246 * u10 * u10,
225
+ tpSeconds: 0.725 * u10,
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Population variance.
231
+ *
232
+ * @param {number[]} values
233
+ * @returns {number}
234
+ */
235
+ function variance(values) {
236
+ if (values.length < 2) {
237
+ return 0;
238
+ }
239
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
240
+ return (
241
+ values.reduce((acc, v) => acc + (v - mean) * (v - mean), 0) / values.length
242
+ );
243
+ }
244
+
245
+ /**
246
+ * Reconstructs the measured RMS vertical acceleration over a sliding
247
+ * window of attitude samples (SPEC §7.2 step 2):
248
+ *
249
+ * a_z = sqrt( Var(3.5·d²pitch/dt²) + Var(1.5·d²roll/dt²)
250
+ * + g²·Var(sin roll) )
251
+ *
252
+ * Second derivatives use central differences with the samples' actual
253
+ * time spacing (history resolution is coarser than the SPEC's 1 Hz).
254
+ *
255
+ * @param {Array<{tMs: number, rollRad: number|null, pitchRad: number|null}>|
256
+ * null} windowSamples - Chronological samples inside the window
257
+ * @param {object} [params]
258
+ * @param {number} [params.pitchArmM=3.5] - Pitch lever arm from the
259
+ * pivot point
260
+ * @param {number} [params.rollArmM=1.5] - Roll lever arm
261
+ * @param {number} [params.g=9.81]
262
+ * @returns {number|null} Measured a_z (m/s²), null when too few
263
+ * attitude samples
264
+ */
265
+ function measuredAz(
266
+ windowSamples,
267
+ { pitchArmM = 3.5, rollArmM = 1.5, g = 9.81 } = {},
268
+ ) {
269
+ if (!windowSamples || windowSamples.length < MIN_WINDOW_SAMPLES) {
270
+ return null;
271
+ }
272
+ const pitchAcc = [];
273
+ const rollAcc = [];
274
+ const sinRoll = [];
275
+ for (let i = 1; i < windowSamples.length - 1; i++) {
276
+ const prev = windowSamples[i - 1];
277
+ const cur = windowSamples[i];
278
+ const next = windowSamples[i + 1];
279
+ if (
280
+ cur.rollRad == null ||
281
+ cur.pitchRad == null ||
282
+ prev.rollRad == null ||
283
+ prev.pitchRad == null ||
284
+ next.rollRad == null ||
285
+ next.pitchRad == null
286
+ ) {
287
+ continue;
288
+ }
289
+ const dtPrev = (cur.tMs - prev.tMs) / 1000;
290
+ const dtNext = (next.tMs - cur.tMs) / 1000;
291
+ if (!(dtPrev > 0) || !(dtNext > 0)) {
292
+ continue;
293
+ }
294
+ // Central second difference (handles the uneven spacing that gaps
295
+ // in the history leave behind):
296
+ // d2v/dt2 ≈ 2·[(v2−v1)/dtNext − (v1−v0)/dtPrev] / (dtPrev+dtNext)
297
+ const d2 = (v0, v1, v2) =>
298
+ (2 * ((v2 - v1) / dtNext - (v1 - v0) / dtPrev)) / (dtPrev + dtNext);
299
+ pitchAcc.push(pitchArmM * d2(prev.pitchRad, cur.pitchRad, next.pitchRad));
300
+ rollAcc.push(rollArmM * d2(prev.rollRad, cur.rollRad, next.rollRad));
301
+ sinRoll.push(Math.sin(cur.rollRad));
302
+ }
303
+ if (pitchAcc.length < MIN_WINDOW_SAMPLES - 2) {
304
+ return null;
305
+ }
306
+ const az = Math.sqrt(
307
+ variance(pitchAcc) + variance(rollAcc) + g * g * variance(sinRoll),
308
+ );
309
+ return Number.isFinite(az) ? az : null;
310
+ }
311
+
312
+ /**
313
+ * Slides a window across the samples and evaluates the measured
314
+ * acceleration (from attitude) plus the model invariants needed to
315
+ * predict it. Windows overlap by half their span.
316
+ *
317
+ * The k multipliers only enter the model multiplicatively:
318
+ *
319
+ * a_z(k) = base · (1 + k_heel·|sin(heelDeg)|) ·
320
+ * (1 + k_pitch·resonance + steepness)
321
+ *
322
+ * so each window stores the k-independent invariants and
323
+ * {@link predictedAzAt} evaluates any candidate in closed form —
324
+ * no physics calls inside the optimizer loop.
325
+ *
326
+ * A window yields an evaluation point only when the attitude
327
+ * reconstruction works AND the window carries enough context (wind +
328
+ * heading) for the model.
329
+ *
330
+ * @param {object[]} samples - Full sample series
331
+ * @param {object} [params]
332
+ * @param {number} [params.windowMinutes=15] - SPEC §7.2 window span
333
+ * @param {number} [params.waterlineLengthM] - Vessel waterline (m)
334
+ * @param {number} [params.g] - Gravity override for the measured side
335
+ * @returns {Promise<Array<{tMs: number, measuredAz: number, base: number,
336
+ * sinHeel: number, resonance: number, steepness: number}>>}
337
+ */
338
+ async function evaluationPoints(
339
+ samples,
340
+ { windowMinutes = 15, waterlineLengthM = DEFAULT_WATERLINE_M, g } = {},
341
+ ) {
342
+ const physics = await loadPhysics();
343
+ const windowMs = windowMinutes * 60000;
344
+ const stepMs = windowMs / 2;
345
+ const points = [];
346
+ if (samples.length === 0) {
347
+ return points;
348
+ }
349
+ const start = samples[0].tMs;
350
+ const end = samples[samples.length - 1].tMs;
351
+ for (let t = start; t + windowMs / 2 <= end; t += stepMs) {
352
+ const inWindow = samples.filter((s) => s.tMs >= t && s.tMs < t + windowMs);
353
+ const measured = measuredAz(inWindow, { g });
354
+ if (measured == null) {
355
+ continue;
356
+ }
357
+ // Model state: mid-window sample; wave data from the recording
358
+ // when present, PM approximation otherwise
359
+ const mid = inWindow[Math.floor(inWindow.length / 2)];
360
+ if (mid.awsKnots == null || mid.awaRad == null || mid.headingRad == null) {
361
+ continue;
362
+ }
363
+ const stw = mid.stwKnots ?? 0;
364
+ const { twsKnots, twaRad } = trueFromApparent({
365
+ awsKnots: mid.awsKnots,
366
+ awaRad: mid.awaRad,
367
+ stwKnots: stw,
368
+ headingRad: mid.headingRad,
369
+ });
370
+ const waves =
371
+ mid.hsMeters != null && mid.tpSeconds != null
372
+ ? { hsMeters: mid.hsMeters, tpSeconds: mid.tpSeconds }
373
+ : waveFromWind(mid.awsKnots);
374
+ // Unrecorded wave direction: assume wind sea running against the
375
+ // apparent wind
376
+ const waveTravelRad =
377
+ mid.waveTravelRad ??
378
+ (mid.headingRad + mid.awaRad + Math.PI) % (2 * Math.PI);
379
+
380
+ // Model invariants (k-independent parts of the §5.2 breakdown)
381
+ const awsKnots = physics.apparentWindSpeedKnots({
382
+ twsKnots,
383
+ stwKnots: stw,
384
+ twaRad,
385
+ });
386
+ const heelDeg = physics.heelAngleDeg({ awsKnots, twaRad });
387
+ const base = physics.verticalAcceleration(
388
+ {
389
+ hsMeters: waves.hsMeters,
390
+ tpSeconds: waves.tpSeconds,
391
+ waveTravelDirectionRad: waveTravelRad,
392
+ },
393
+ {
394
+ sogKnots: stw,
395
+ headingRad: mid.headingRad,
396
+ waterlineLengthM,
397
+ kHeel: 0,
398
+ kPitch: 0,
399
+ },
400
+ { twsKnots, twaRad },
401
+ ).base;
402
+ const wavelength = physics.waveLengthMeters(waves.tpSeconds);
403
+ const resonance = Math.exp(
404
+ -(((wavelength - waterlineLengthM) / waterlineLengthM) ** 2),
405
+ );
406
+ const ratio =
407
+ waves.hsMeters > 0 ? waves.tpSeconds / waves.hsMeters : Infinity;
408
+ const steepness = Math.max(
409
+ 0,
410
+ (physics.STEEPNESS_RATIO_THRESHOLD - ratio) /
411
+ physics.STEEPNESS_RATIO_THRESHOLD,
412
+ );
413
+ points.push({
414
+ tMs: t + windowMs / 2,
415
+ measuredAz: measured,
416
+ base,
417
+ sinHeel: Math.abs(Math.sin((heelDeg * Math.PI) / 180)),
418
+ resonance,
419
+ steepness,
420
+ });
421
+ }
422
+ return points;
423
+ }
424
+
425
+ /**
426
+ * Model prediction for an evaluation point at candidate multipliers.
427
+ *
428
+ * @param {{base: number, sinHeel: number, resonance: number, steepness: number}} point
429
+ * @param {number} kHeel
430
+ * @param {number} kPitch
431
+ * @returns {number} Predicted RMS vertical acceleration (m/s²)
432
+ */
433
+ function predictedAzAt(point, kHeel, kPitch) {
434
+ return (
435
+ point.base *
436
+ (1 + kHeel * point.sinHeel) *
437
+ (1 + kPitch * point.resonance + point.steepness)
438
+ );
439
+ }
440
+
441
+ /**
442
+ * Loss function (SPEC §7.2 step 3): mean absolute error between
443
+ * predicted and measured a_z across all evaluation points. Negative
444
+ * or oversized candidates are rejected with Infinity — the physics
445
+ * expects multipliers in [0, 2].
446
+ *
447
+ * @param {Array<{measuredAz: number}>} points
448
+ * @param {number} kHeel
449
+ * @param {number} kPitch
450
+ * @returns {number} MAE (m/s²), or Infinity for invalid candidates
451
+ */
452
+ function lossAt(points, kHeel, kPitch) {
453
+ if (!(kHeel >= 0) || !(kPitch >= 0) || kHeel > 2 || kPitch > 2) {
454
+ return Infinity;
455
+ }
456
+ if (points.length === 0) {
457
+ return Infinity;
458
+ }
459
+ let total = 0;
460
+ for (const point of points) {
461
+ total += Math.abs(predictedAzAt(point, kHeel, kPitch) - point.measuredAz);
462
+ }
463
+ return total / points.length;
464
+ }
465
+
466
+ /**
467
+ * Nelder-Mead simplex minimizer (SPEC §7.2 step 4). Standard
468
+ * coefficients (reflection 1, expansion 2, contraction 0.5, shrink
469
+ * 0.5) on a 2-parameter simplex.
470
+ *
471
+ * @param {(x: [number, number]) => number} f - Objective (returns Infinity
472
+ * for invalid regions)
473
+ * @param {[number, number]} x0 - Starting point
474
+ * @param {object} [opts]
475
+ * @param {number} [opts.initialStep=0.1]
476
+ * @param {number} [opts.maxIterations=200]
477
+ * @param {number} [opts.tolerance=1e-6] - Simplex size tolerance
478
+ * @returns {{x: [number, number], f: number, iterations: number}}
479
+ */
480
+ function nelderMead(
481
+ f,
482
+ x0,
483
+ { initialStep = 0.1, maxIterations = 200, tolerance = 1e-6 } = {},
484
+ ) {
485
+ // Initial simplex: x0 and both axis perturbations
486
+ const simplex = [
487
+ { x: [...x0], f: f(x0) },
488
+ { x: [x0[0] + initialStep, x0[1]], f: f([x0[0] + initialStep, x0[1]]) },
489
+ { x: [x0[0], x0[1] + initialStep], f: f([x0[0], x0[1] + initialStep]) },
490
+ ];
491
+ let iterations = 0;
492
+ for (; iterations < maxIterations; iterations++) {
493
+ simplex.sort((a, b) => a.f - b.f);
494
+ const spread = Math.abs(simplex[0].f - simplex[2].f);
495
+ const size = Math.max(
496
+ Math.abs(simplex[2].x[0] - simplex[0].x[0]),
497
+ Math.abs(simplex[2].x[1] - simplex[0].x[1]),
498
+ );
499
+ if (spread < tolerance && size < tolerance) {
500
+ break;
501
+ }
502
+ // Centroid of the best two
503
+ const cx = (simplex[0].x[0] + simplex[1].x[0]) / 2;
504
+ const cy = (simplex[0].x[1] + simplex[1].x[1]) / 2;
505
+ const reflect = [cx + (cx - simplex[2].x[0]), cy + (cy - simplex[2].x[1])];
506
+ const fr = f(reflect);
507
+ if (fr < simplex[0].f) {
508
+ // Expand
509
+ const expand = [
510
+ cx + 2 * (cx - simplex[2].x[0]),
511
+ cy + 2 * (cy - simplex[2].x[1]),
512
+ ];
513
+ const fe = f(expand);
514
+ Object.assign(
515
+ simplex[2],
516
+ fe < fr ? { x: expand, f: fe } : { x: reflect, f: fr },
517
+ );
518
+ } else if (fr < simplex[1].f) {
519
+ simplex[2] = { x: reflect, f: fr };
520
+ } else {
521
+ // Contract
522
+ const outside = [
523
+ cx + 0.5 * (simplex[2].x[0] - cx),
524
+ cy + 0.5 * (simplex[2].x[1] - cy),
525
+ ];
526
+ const fo = f(outside);
527
+ if (fo < fr) {
528
+ simplex[2] = { x: outside, f: fo };
529
+ } else {
530
+ // Shrink toward the best point
531
+ for (let i = 1; i < 3; i++) {
532
+ simplex[i] = {
533
+ x: [
534
+ simplex[0].x[0] + 0.5 * (simplex[i].x[0] - simplex[0].x[0]),
535
+ simplex[0].x[1] + 0.5 * (simplex[i].x[1] - simplex[0].x[1]),
536
+ ],
537
+ f: 0,
538
+ };
539
+ simplex[i].f = f(simplex[i].x);
540
+ }
541
+ }
542
+ }
543
+ }
544
+ simplex.sort((a, b) => a.f - b.f);
545
+ return { x: simplex[0].x, f: simplex[0].f, iterations };
546
+ }
547
+
548
+ /**
549
+ * Builds the 5×5 confusion matrix (SPEC §7.2 step 5): rows are the
550
+ * predicted Sereno tiers, columns the tiers measured from the
551
+ * reconstructed acceleration. Both classified with the same AZ band
552
+ * thresholds.
553
+ *
554
+ * @param {Array<{measuredAz: number, base: number, sinHeel: number,
555
+ * resonance: number, steepness: number}>} points
556
+ * @param {number} kHeel
557
+ * @param {number} kPitch
558
+ * @returns {Promise<{tiers: string[], matrix: number[][]}>} Matrix
559
+ * [predictedIndex][measuredIndex] of window counts
560
+ */
561
+ async function confusionMatrix(points, kHeel, kPitch) {
562
+ const physics = await loadPhysics();
563
+ const matrix = physics.COMFORT_TIERS.map(() =>
564
+ physics.COMFORT_TIERS.map(() => 0),
565
+ );
566
+ for (const point of points) {
567
+ const predicted = physics.rateAcceleration(
568
+ predictedAzAt(point, kHeel, kPitch),
569
+ );
570
+ const measured = physics.rateAcceleration(point.measuredAz);
571
+ matrix[physics.comfortSeverity(predicted)][
572
+ physics.comfortSeverity(measured)
573
+ ] += 1;
574
+ }
575
+ return { tiers: physics.COMFORT_TIERS, matrix };
576
+ }
577
+
578
+ /**
579
+ * Queries one chunk of history (same contract as the replay tools:
580
+ * `/signalk/v2/api/history/values` with `paths`/`from`/`to`/
581
+ * `resolution`).
582
+ *
583
+ * @param {object} params
584
+ * @param {string} params.baseUrl - Server base URL
585
+ * @param {string} [params.token] - Bearer token (SIGNALK_TOKEN env)
586
+ * @param {string} [params.provider] - History provider ID
587
+ * @param {Date} params.from
588
+ * @param {Date} params.to
589
+ * @param {number} params.resolution - Seconds
590
+ * @param {typeof fetch} [params.fetchImpl]
591
+ * @returns {Promise<object>} `/values` response
592
+ */
593
+ async function queryHistoryValues({
594
+ baseUrl,
595
+ token = process.env.SIGNALK_TOKEN,
596
+ provider,
597
+ from,
598
+ to,
599
+ resolution,
600
+ fetchImpl = fetch,
601
+ }) {
602
+ const url = new URL("/signalk/v2/api/history/values", baseUrl);
603
+ url.searchParams.set("paths", HISTORY_PATHS.join(","));
604
+ url.searchParams.set("from", from.toISOString());
605
+ url.searchParams.set("to", to.toISOString());
606
+ url.searchParams.set("resolution", String(resolution));
607
+ if (provider) {
608
+ url.searchParams.set("provider", provider);
609
+ }
610
+ const headers = { Accept: "application/json" };
611
+ if (token) {
612
+ headers.Authorization = `Bearer ${token}`;
613
+ }
614
+ const response = await fetchImpl(url, { headers });
615
+ if (!response.ok) {
616
+ throw new Error(
617
+ `History API returned ${response.status}: ${response.statusText}`,
618
+ );
619
+ }
620
+ return response.json();
621
+ }
622
+
623
+ /**
624
+ * Runs the full backtest (SPEC §7.2): chunked history extraction,
625
+ * sliding-window evaluation, Nelder-Mead calibration from the plugin
626
+ * defaults, and the comfort confusion matrix at the tuned parameters.
627
+ *
628
+ * @param {object} params
629
+ * @param {string} params.baseUrl - Signal K server base URL
630
+ * @param {Date} params.from - Window start
631
+ * @param {Date} params.to - Window end
632
+ * @param {object} [params.options]
633
+ * @param {number} [params.options.chunkHours=24] - History query chunk
634
+ * @param {number} [params.options.resolution] - Seconds (default 10)
635
+ * @param {number} [params.options.windowMinutes=15]
636
+ * @param {number} [params.options.waterlineLengthM]
637
+ * @param {number} [params.options.kHeel=0.35] - Starting guess
638
+ * @param {number} [params.options.kPitch=0.4] - Starting guess
639
+ * @param {string} [params.options.token]
640
+ * @param {string} [params.options.provider]
641
+ * @param {typeof fetch} [params.fetchImpl]
642
+ * @returns {Promise<object>} Report (see bin/backtest-cli.js)
643
+ */
644
+ async function runBacktest({
645
+ baseUrl,
646
+ from,
647
+ to,
648
+ options = {},
649
+ fetchImpl = fetch,
650
+ }) {
651
+ const {
652
+ chunkHours = 24,
653
+ resolution = DEFAULT_RESOLUTION_SECONDS,
654
+ windowMinutes = 15,
655
+ waterlineLengthM = options.waterline_length_m ?? DEFAULT_WATERLINE_M,
656
+ kHeel: startHeel = 0.35,
657
+ kPitch: startPitch = 0.4,
658
+ token,
659
+ provider,
660
+ } = options;
661
+
662
+ // Chunked extraction keeps each query bounded on satellite links
663
+ const samples = [];
664
+ const chunkMs = chunkHours * 3600000;
665
+ for (let t = from.getTime(); t < to.getTime(); t += chunkMs) {
666
+ const chunkTo = new Date(Math.min(t + chunkMs, to.getTime()));
667
+ const historyData = await queryHistoryValues({
668
+ baseUrl,
669
+ token,
670
+ provider,
671
+ from: new Date(t),
672
+ to: chunkTo,
673
+ resolution,
674
+ fetchImpl,
675
+ });
676
+ samples.push(...buildSamples(historyData));
677
+ }
678
+ samples.sort((a, b) => a.tMs - b.tMs);
679
+
680
+ const points = await evaluationPoints(samples, {
681
+ windowMinutes,
682
+ waterlineLengthM,
683
+ });
684
+
685
+ const startLoss = lossAt(points, startHeel, startPitch);
686
+ const optimized = nelderMead(
687
+ ([heel, pitch]) => lossAt(points, heel, pitch),
688
+ [startHeel, startPitch],
689
+ );
690
+ const [kHeel, kPitch] = optimized.x;
691
+ const tuned = points.length > 0 ? lossAt(points, kHeel, kPitch) : null;
692
+ const matrix =
693
+ points.length > 0
694
+ ? await confusionMatrix(points, kHeel, kPitch)
695
+ : { tiers: [], matrix: [] };
696
+
697
+ return {
698
+ generatedAt: new Date().toISOString(),
699
+ range: { from: from.toISOString(), to: to.toISOString() },
700
+ resolutionSeconds: resolution,
701
+ windowMinutes,
702
+ samples: samples.length,
703
+ windowsEvaluated: points.length,
704
+ start: { kHeel: startHeel, kPitch: startPitch, mae: startLoss },
705
+ tuned: {
706
+ kHeel: round4(kHeel),
707
+ kPitch: round4(kPitch),
708
+ mae: tuned == null ? null : round4(tuned),
709
+ iterations: optimized.iterations,
710
+ },
711
+ confusionMatrix: matrix,
712
+ };
713
+ }
714
+
715
+ /**
716
+ * @param {number} value
717
+ * @returns {number} Rounded to 4 decimals
718
+ */
719
+ function round4(value) {
720
+ return Math.round(value * 10000) / 10000;
721
+ }
722
+
723
+ module.exports = {
724
+ HISTORY_PATHS,
725
+ DEFAULT_RESOLUTION_SECONDS,
726
+ DEFAULT_WATERLINE_M,
727
+ MIN_WINDOW_SAMPLES,
728
+ parseColumns,
729
+ cellNumber,
730
+ buildSamples,
731
+ trueFromApparent,
732
+ waveFromWind,
733
+ variance,
734
+ measuredAz,
735
+ evaluationPoints,
736
+ predictedAzAt,
737
+ lossAt,
738
+ nelderMead,
739
+ confusionMatrix,
740
+ queryHistoryValues,
741
+ runBacktest,
742
+ };