@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,333 @@
1
+ const { test, describe } = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+
4
+ const physicsPromise = import("../public/sereno-physics.mjs");
5
+ const physics = async () => physicsPromise;
6
+
7
+ const approx = (actual, expected, epsilon = 1e-6) =>
8
+ assert.ok(
9
+ Math.abs(actual - expected) < epsilon,
10
+ `expected ${actual} ≈ ${expected} (±${epsilon})`,
11
+ );
12
+
13
+ describe("comfort bands (SPEC §5.2)", () => {
14
+ test("band constants", async () => {
15
+ const p = await physics();
16
+ assert.deepEqual(p.AWS_BANDS_KNOTS, [12, 18, 23, 33]);
17
+ assert.deepEqual(p.AZ_BANDS_MS2, [0.15, 0.315, 0.63, 1.25]);
18
+ assert.deepEqual(p.COMFORT_TIERS, [
19
+ "champagne",
20
+ "easy",
21
+ "coffee",
22
+ "rough",
23
+ "sick",
24
+ ]);
25
+ assert.deepEqual(p.STEEPNESS_RATIO_THRESHOLD, 3.28);
26
+ });
27
+
28
+ test("apparent wind bands", async () => {
29
+ const { rateApparentWind } = await physics();
30
+ assert.equal(rateApparentWind(11.9), "champagne");
31
+ assert.equal(rateApparentWind(12), "easy");
32
+ assert.equal(rateApparentWind(18), "coffee");
33
+ assert.equal(rateApparentWind(23), "rough");
34
+ assert.equal(rateApparentWind(33), "sick");
35
+ });
36
+
37
+ test("acceleration bands", async () => {
38
+ const { rateAcceleration } = await physics();
39
+ assert.equal(rateAcceleration(0.149), "champagne");
40
+ assert.equal(rateAcceleration(0.15), "easy");
41
+ assert.equal(rateAcceleration(0.315), "coffee");
42
+ assert.equal(rateAcceleration(0.63), "rough");
43
+ assert.equal(rateAcceleration(1.25), "sick");
44
+ });
45
+ });
46
+
47
+ describe("apparent wind", () => {
48
+ test("head wind adds boat speed, following wind subtracts", async () => {
49
+ const { apparentWindSpeedKnots } = await physics();
50
+ // TWA 0 (dead ahead): AWS = TWS + STW
51
+ approx(
52
+ apparentWindSpeedKnots({ twsKnots: 15, stwKnots: 5, twaRad: 0 }),
53
+ 20,
54
+ );
55
+ // TWA π (dead astern): AWS = TWS − STW
56
+ approx(
57
+ apparentWindSpeedKnots({ twsKnots: 15, stwKnots: 5, twaRad: Math.PI }),
58
+ 10,
59
+ );
60
+ // Beam reach: quadrature, per the SPEC formula
61
+ approx(
62
+ apparentWindSpeedKnots({
63
+ twsKnots: 15,
64
+ stwKnots: 5,
65
+ twaRad: Math.PI / 2,
66
+ }),
67
+ Math.sqrt(15 * 15 + 5 * 5),
68
+ );
69
+ });
70
+ });
71
+
72
+ describe("wave math", () => {
73
+ test("deep-water wavelength and phase speed", async () => {
74
+ const { waveLengthMeters, wavePhaseSpeedMs } = await physics();
75
+ approx(waveLengthMeters(7), (9.81 * 49) / (2 * Math.PI));
76
+ approx(wavePhaseSpeedMs(7), (9.81 * 7) / (2 * Math.PI));
77
+ // L = c·T
78
+ approx(waveLengthMeters(5), wavePhaseSpeedMs(5) * 5);
79
+ });
80
+
81
+ test("encounter period: bow seas arrive faster, astern seas slower", async () => {
82
+ const { encounterPeriodSeconds } = await physics();
83
+ // Wave travel convention: seas from astern travel with the boat's
84
+ // heading (travel ≈ heading), seas on the bow against it.
85
+ const bow = encounterPeriodSeconds({
86
+ tpSeconds: 7,
87
+ sogKnots: 6,
88
+ waveTravelDirectionRad: Math.PI,
89
+ headingRad: 0,
90
+ });
91
+ assert.ok(bow < 7, `bow Te ${bow} < Tp`);
92
+ const astern = encounterPeriodSeconds({
93
+ tpSeconds: 7,
94
+ sogKnots: 6,
95
+ waveTravelDirectionRad: 0,
96
+ headingRad: 0,
97
+ });
98
+ assert.ok(astern > 7, `astern Te ${astern} > Tp`);
99
+ // Same train met from the opposite heading: symmetric stretch
100
+ const reversed = encounterPeriodSeconds({
101
+ tpSeconds: 7,
102
+ sogKnots: 6,
103
+ waveTravelDirectionRad: Math.PI,
104
+ headingRad: Math.PI,
105
+ });
106
+ approx(reversed, astern);
107
+ });
108
+
109
+ test("surfing a slower train cannot drive Te to zero", async () => {
110
+ const { encounterPeriodSeconds } = await physics();
111
+ // 8.2 kn boat overtakes a 3 s train it matches 90% of the phase
112
+ // speed of: stretch |1 − 0.907| = 0.093 → floored at 0.15
113
+ const te = encounterPeriodSeconds({
114
+ tpSeconds: 3,
115
+ sogKnots: 8.2,
116
+ waveTravelDirectionRad: 0,
117
+ headingRad: 0,
118
+ });
119
+ approx(te, 3 / 0.15); // factor floor
120
+ });
121
+
122
+ test("from-conversion helper", async () => {
123
+ const { travelDirection } = await physics();
124
+ approx(travelDirection(0), Math.PI);
125
+ approx(travelDirection(Math.PI), 0);
126
+ });
127
+
128
+ test("steepness ratio", async () => {
129
+ const { steepnessRatio, STEEPNESS_RATIO_THRESHOLD } = await physics();
130
+ assert.equal(steepnessRatio(3.28, 1), STEEPNESS_RATIO_THRESHOLD);
131
+ assert.equal(steepnessRatio(6, 0), Infinity);
132
+ });
133
+ });
134
+
135
+ describe("monohull corrections (SPEC §5.2 vector 2)", () => {
136
+ test("heel peaks on a beam reach and scales with apparent wind", async () => {
137
+ const { heelAngleDeg } = await physics();
138
+ approx(heelAngleDeg({ awsKnots: 25, twaRad: Math.PI / 2 }), 25);
139
+ approx(heelAngleDeg({ awsKnots: 12.5, twaRad: Math.PI / 2 }), 12.5);
140
+ approx(heelAngleDeg({ awsKnots: 25, twaRad: 0 }), 0);
141
+ approx(heelAngleDeg({ awsKnots: 25, twaRad: Math.PI }), 0);
142
+ // Never beyond φ_max even in extreme wind
143
+ approx(heelAngleDeg({ awsKnots: 60, twaRad: Math.PI / 2 }), 25);
144
+ });
145
+
146
+ test("heel multiplier", async () => {
147
+ const { heelMultiplier } = await physics();
148
+ approx(heelMultiplier(0, 0.35), 1);
149
+ approx(heelMultiplier(25, 0.35), 1 + 0.35 * Math.sin((25 * Math.PI) / 180));
150
+ });
151
+
152
+ test("pitch multiplier resonates at hull length and punishes steep seas", async () => {
153
+ const { pitchMultiplier, waveLengthMeters, STEEPNESS_RATIO_THRESHOLD } =
154
+ await physics();
155
+ // L_wave ≈ L_wl (9.4 m) → Tp ≈ 2.45 s: full resonance term
156
+ const resonantTp = Math.sqrt((2 * Math.PI * 9.4) / 9.81);
157
+ const calm = { tpSeconds: 12, hsMeters: 1, waterlineLengthM: 9.4 };
158
+ const resonant = {
159
+ tpSeconds: resonantTp,
160
+ hsMeters: 1,
161
+ waterlineLengthM: 9.4,
162
+ };
163
+ const steep = { tpSeconds: 4, hsMeters: 2.5, waterlineLengthM: 9.4 };
164
+ assert.ok(pitchMultiplier(resonant, 0.4) > pitchMultiplier(calm, 0.4));
165
+ // Tp/Hs = 1.6 → steepness term (3.28 − 1.6)/3.28 plus the residual
166
+ // resonance of the 25 m wave train
167
+ const resonance = Math.exp(-(((waveLengthMeters(4) - 9.4) / 9.4) ** 2));
168
+ approx(
169
+ pitchMultiplier(steep, 0.4),
170
+ 1 +
171
+ 0.4 * resonance +
172
+ (STEEPNESS_RATIO_THRESHOLD - 1.6) / STEEPNESS_RATIO_THRESHOLD,
173
+ 1e-9,
174
+ );
175
+ // Long gentle swell: essentially unity
176
+ assert.ok(pitchMultiplier(calm, 0.4) < 1.01);
177
+ });
178
+
179
+ test("vertical acceleration grows with head seas and sea height", async () => {
180
+ const { verticalAcceleration, heelAngleDeg, apparentWindSpeedKnots } =
181
+ await physics();
182
+ const sea = (tpSeconds, hsMeters, waveTravelDirectionRad) => ({
183
+ tpSeconds,
184
+ hsMeters,
185
+ waveTravelDirectionRad,
186
+ });
187
+ const vessel = {
188
+ sogKnots: 6,
189
+ headingRad: 0,
190
+ waterlineLengthM: 9.4,
191
+ kHeel: 0.35,
192
+ kPitch: 0.4,
193
+ };
194
+ const wind = { twsKnots: 15, twaRad: Math.PI / 2 };
195
+ const head = verticalAcceleration(sea(7, 2, Math.PI), vessel, wind);
196
+ const astern = verticalAcceleration(sea(7, 2, 0), vessel, wind);
197
+ assert.ok(head.value > astern.value);
198
+ // Proportional to Hs at fixed Te (heel/pitch identical here)
199
+ const mild = verticalAcceleration(sea(7, 1, Math.PI), vessel, wind);
200
+ assert.ok(Math.abs(head.value / mild.value - 2) < 0.05);
201
+ // Calm sea: no acceleration
202
+ approx(verticalAcceleration(sea(7, 0, 0), vessel, wind).value, 0);
203
+ // Beam reach heel comes out of the wind vector via AWS
204
+ const assessment = verticalAcceleration(sea(7, 2, 0), vessel, wind);
205
+ approx(
206
+ assessment.heelDeg,
207
+ heelAngleDeg({
208
+ awsKnots: apparentWindSpeedKnots({
209
+ twsKnots: wind.twsKnots,
210
+ stwKnots: vessel.sogKnots,
211
+ twaRad: wind.twaRad,
212
+ }),
213
+ twaRad: wind.twaRad,
214
+ }),
215
+ );
216
+ });
217
+ });
218
+
219
+ describe("serenoComfort", () => {
220
+ test("the worse vector sets the level", async () => {
221
+ const { serenoComfort } = await physics();
222
+ const vessel = {
223
+ sogKnots: 5,
224
+ headingRad: 0,
225
+ waterlineLengthM: 9.4,
226
+ kHeel: 0.35,
227
+ kPitch: 0.4,
228
+ };
229
+ // Gusty but flat: wind vector dominates (AWS ≈ 31 kt → Rough)
230
+ const gusty = serenoComfort(
231
+ { tpSeconds: 10, hsMeters: 0.5, waveTravelDirectionRad: 0 },
232
+ vessel,
233
+ { twsKnots: 26, twaRad: 0.2 },
234
+ );
235
+ assert.equal(gusty.comfort, "rough");
236
+ assert.equal(gusty.motionComfort, "champagne");
237
+ // Gentle wind but big steep head seas: motion dominates
238
+ const rough = serenoComfort(
239
+ { tpSeconds: 5, hsMeters: 3, waveTravelDirectionRad: 0 },
240
+ vessel,
241
+ { twsKnots: 6, twaRad: 0.3 },
242
+ );
243
+ assert.ok(
244
+ ["rough", "sick"].includes(rough.comfort),
245
+ `expected rough/sick, got ${rough.comfort}`,
246
+ );
247
+ // Champagne glass conditions: light following breeze, gentle swell
248
+ const idyllic = serenoComfort(
249
+ { tpSeconds: 9, hsMeters: 0.3, waveTravelDirectionRad: 0 },
250
+ vessel,
251
+ { twsKnots: 8, twaRad: 2.4 },
252
+ );
253
+ assert.equal(idyllic.comfort, "champagne");
254
+ });
255
+ });
256
+
257
+ describe("day/night bucket", () => {
258
+ test("sun altitude: equator equinox solar noon overhead, midnight below", async () => {
259
+ const { sunAltitudeDeg } = await physics();
260
+ // Solar noon at longitude 0 late March (equation of time ≈ −7 min)
261
+ const noon = sunAltitudeDeg(new Date("2026-03-20T12:07:00Z"), 0, 0);
262
+ approx(noon, 90, 1);
263
+ const midnight = sunAltitudeDeg(new Date("2026-03-21T00:07:00Z"), 0, 0);
264
+ approx(midnight, -90, 1);
265
+ });
266
+
267
+ test("Helsinki: midsummer noon is day, midnight is night", async () => {
268
+ const { sunAltitudeDeg, isNight } = await physics();
269
+ const lat = 60.17;
270
+ const lon = 24.94;
271
+ // Solar noon at 24.94°E in late June ≈ 10:21Z
272
+ const noon = sunAltitudeDeg(new Date("2026-06-21T10:21:00Z"), lat, lon);
273
+ approx(noon, 90 - lat + 23.44, 1.5);
274
+ // Solar midnight ≈ 22:21Z
275
+ const midnight = sunAltitudeDeg(new Date("2026-06-21T22:21:00Z"), lat, lon);
276
+ approx(midnight, lat + 23.44 - 90, 1.5);
277
+ assert.ok(!isNight(new Date("2026-06-21T10:21:00Z"), lat, lon));
278
+ assert.ok(isNight(new Date("2026-06-21T22:21:00Z"), lat, lon));
279
+ // Threshold parameterization: with a −10° threshold the Helsinki
280
+ // midsummer midnight (sun ≈ −6.4°) is *not* night
281
+ assert.ok(!isNight(new Date("2026-06-21T22:21:00Z"), lat, lon, -10));
282
+ });
283
+
284
+ test("default threshold is sunset", async () => {
285
+ const { NIGHT_SUN_ALTITUDE_DEG } = await physics();
286
+ assert.equal(NIGHT_SUN_ALTITUDE_DEG, 0);
287
+ });
288
+ });
289
+
290
+ describe("suggestSailState", () => {
291
+ const matrix = {
292
+ twsBinsKnots: [0, 5, 10, 15, 20, 25, 30, 35, 40],
293
+ twaBinsDegrees: [0, 30, 60, 90, 120, 150, 180],
294
+ matrix: [
295
+ {
296
+ twsBin: 3,
297
+ twaBin: 4,
298
+ night: false,
299
+ preferredSailState: "GENOA_1_MAIN",
300
+ minTwsGustTrigger: 16,
301
+ samplesCount: 2,
302
+ },
303
+ {
304
+ twsBin: 3,
305
+ twaBin: 4,
306
+ night: true,
307
+ preferredSailState: "STAYSAIL_MAIN_1_REEF",
308
+ minTwsGustTrigger: 16,
309
+ samplesCount: 3,
310
+ },
311
+ ],
312
+ };
313
+
314
+ test("picks the bin by folded TWA and day/night bucket", async () => {
315
+ const { suggestSailState } = await physics();
316
+ const day = suggestSailState(matrix, 16, 140, false);
317
+ assert.equal(day.preferredSailState, "GENOA_1_MAIN");
318
+ const night = suggestSailState(matrix, 16, 140, true);
319
+ assert.equal(night.preferredSailState, "STAYSAIL_MAIN_1_REEF");
320
+ // Signed TWA folds onto the magnitude range
321
+ assert.equal(suggestSailState(matrix, 16, -220, false).twsBin, 3);
322
+ // Unknown bin and missing matrix
323
+ assert.equal(suggestSailState(matrix, 50, 10), null);
324
+ assert.equal(suggestSailState(null, 16, 140), null);
325
+ });
326
+ });
327
+
328
+ test("binIndexFor clamps to the last bin", async () => {
329
+ const { binIndexFor } = await physics();
330
+ assert.equal(binIndexFor(0, [0, 5, 10]), 0);
331
+ assert.equal(binIndexFor(7, [0, 5, 10]), 1);
332
+ assert.equal(binIndexFor(99, [0, 5, 10]), 2);
333
+ });
@@ -0,0 +1,164 @@
1
+ const { test, describe } = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const { mkdtempSync } = require("node:fs");
4
+ const { tmpdir } = require("node:os");
5
+ const { join } = require("node:path");
6
+
7
+ const {
8
+ PassageDatabase,
9
+ TWS_BINS_KNOTS,
10
+ TWA_BINS_DEGREES,
11
+ EMA_ALPHA,
12
+ twsBinIndex,
13
+ twaBinIndex,
14
+ emaUpdate,
15
+ } = require("../plugin/sqlite-db.js");
16
+
17
+ const makeDb = (label) =>
18
+ new PassageDatabase(mkdtempSync(join(tmpdir(), `passage-${label}-`)));
19
+
20
+ describe("sqlite db", () => {
21
+ test("creates the spec schema tables", () => {
22
+ const db = makeDb("schema");
23
+ try {
24
+ const tables = db.db
25
+ .prepare(
26
+ `SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`,
27
+ )
28
+ .all()
29
+ .map((row) => row.name);
30
+ assert.ok(tables.includes("logbook_sail_events"));
31
+ assert.ok(tables.includes("wind_history_cache"));
32
+ assert.ok(tables.includes("learned_sail_matrix"));
33
+
34
+ const wal = db.db.prepare("PRAGMA journal_mode").get();
35
+ assert.equal(wal.journal_mode, "wal");
36
+ } finally {
37
+ db.close();
38
+ }
39
+ });
40
+
41
+ test("records and lists sail events", () => {
42
+ const db = makeDb("events");
43
+ try {
44
+ const id = db.recordSailEvent({
45
+ timestamp: "2026-08-01T12:00:00Z",
46
+ eventType: "REEF_INCREASE",
47
+ sailState: "REEF_1_GENOA",
48
+ notes: "gust line",
49
+ });
50
+ assert.equal(id, 1);
51
+ db.recordSailEvent({
52
+ timestamp: "2026-08-01T14:00:00Z",
53
+ eventType: "SAIL_CHANGE",
54
+ sailState: "MAIN_FULL_GENOA_100",
55
+ });
56
+
57
+ const events = db.getSailEvents();
58
+ assert.equal(events.length, 2);
59
+ assert.equal(events[0].sailState, "MAIN_FULL_GENOA_100");
60
+ assert.equal(events[0].notes, null);
61
+ assert.equal(events[1].eventType, "REEF_INCREASE");
62
+ } finally {
63
+ db.close();
64
+ }
65
+ });
66
+
67
+ test("upserts wind history keyed by timestamp", () => {
68
+ const db = makeDb("wind");
69
+ try {
70
+ db.upsertWindHistory({
71
+ timestamp: "2026-08-01T12:00:00Z",
72
+ twsAvg: 12.5,
73
+ twsPeak: 18.2,
74
+ twaAvg: 95,
75
+ });
76
+ db.upsertWindHistory({
77
+ timestamp: "2026-08-01T12:00:00Z",
78
+ twsAvg: 12.8,
79
+ twsPeak: 18.6,
80
+ twaAvg: 94,
81
+ });
82
+ db.upsertWindHistory({
83
+ timestamp: "2026-08-01T12:15:00Z",
84
+ twsAvg: 13.1,
85
+ twsPeak: 16.0,
86
+ twaAvg: 100,
87
+ });
88
+
89
+ assert.equal(
90
+ db.db.prepare("SELECT COUNT(*) AS n FROM wind_history_cache").get().n,
91
+ 2,
92
+ );
93
+ const window = db.getWindHistory(
94
+ "2026-08-01T12:00:00Z",
95
+ "2026-08-01T12:15:00Z",
96
+ );
97
+ assert.equal(window.length, 2);
98
+ assert.equal(window[0].twsAvg, 12.8);
99
+ } finally {
100
+ db.close();
101
+ }
102
+ });
103
+
104
+ test("bins and EMA follow the spec constants", () => {
105
+ assert.deepEqual(TWS_BINS_KNOTS, [0, 5, 10, 15, 20, 25, 30, 35, 40]);
106
+ assert.deepEqual(TWA_BINS_DEGREES, [0, 30, 60, 90, 120, 150, 180]);
107
+ assert.equal(EMA_ALPHA, 0.2);
108
+
109
+ assert.equal(twsBinIndex(0), 0);
110
+ assert.equal(twsBinIndex(7.5), 1);
111
+ assert.equal(twsBinIndex(22), 4);
112
+ assert.equal(twsBinIndex(42), 8);
113
+ assert.equal(twaBinIndex(15), 0);
114
+ assert.equal(twaBinIndex(120), 4);
115
+ assert.equal(twaBinIndex(200), 6);
116
+ });
117
+
118
+ test("emaUpdate applies the 0.2 learning rate", () => {
119
+ // Spec formula: (1 - 0.2) * old + 0.2 * sample.
120
+ assert.equal(emaUpdate(10, 20), 0.8 * 10 + 0.2 * 20);
121
+ assert.equal(emaUpdate(null, 17.5), 17.5);
122
+ });
123
+
124
+ test("matrix bins round-trip in the spec shape", () => {
125
+ const db = makeDb("matrix");
126
+ try {
127
+ db.upsertMatrixBin({
128
+ twsBin: twsBinIndex(12),
129
+ twaBin: twaBinIndex(95),
130
+ preferredSail: "MAIN_FULL_GENOA_100",
131
+ avgTwsTrigger: 12.5,
132
+ peakGustTrigger: 18.0,
133
+ sampleCount: 3,
134
+ });
135
+ // Rebinding the same cell replaces it instead of duplicating.
136
+ db.upsertMatrixBin({
137
+ twsBin: 2,
138
+ twaBin: 3,
139
+ preferredSail: "MAIN_REEF_1_GENOA_100",
140
+ avgTwsTrigger: 12.1,
141
+ peakGustTrigger: 17.4,
142
+ sampleCount: 4,
143
+ });
144
+
145
+ const bins = db.getMatrixBins();
146
+ assert.equal(bins.length, 1);
147
+ assert.equal(bins[0].twsBin, 2);
148
+ assert.equal(bins[0].twaBin, 3);
149
+
150
+ const matrix = db.getSailPreferenceMatrix();
151
+ assert.deepEqual(matrix.twsBinsKnots, TWS_BINS_KNOTS);
152
+ assert.deepEqual(matrix.twaBinsDegrees, TWA_BINS_DEGREES);
153
+ assert.equal(matrix.matrix.length, 1);
154
+ assert.equal(
155
+ matrix.matrix[0].preferredSailState,
156
+ "MAIN_REEF_1_GENOA_100",
157
+ );
158
+ assert.equal(matrix.matrix[0].minTwsGustTrigger, 17.4);
159
+ assert.equal(matrix.matrix[0].samplesCount, 4);
160
+ } finally {
161
+ db.close();
162
+ }
163
+ });
164
+ });
@@ -0,0 +1,182 @@
1
+ const { test, describe } = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+
4
+ const {
5
+ STATES,
6
+ CRON_HOURS_UTC,
7
+ CRON_MINUTE_UTC,
8
+ SOC_FULL_THRESHOLD,
9
+ nextCronRun,
10
+ PassageStateMachine,
11
+ } = require("../plugin/state-machine.js");
12
+
13
+ const d = (iso) => new Date(iso);
14
+
15
+ describe("state machine", () => {
16
+ test("starts OFFLINE", () => {
17
+ const machine = new PassageStateMachine();
18
+ assert.equal(machine.state, STATES.OFFLINE);
19
+ });
20
+
21
+ test("internet edge to online fires a oneshot fetch and lands in STANDBY_OFFSHORE while sailing", () => {
22
+ const machine = new PassageStateMachine();
23
+ const result = machine.update(
24
+ { internetState: "online", navigationState: "sailing" },
25
+ d("2026-08-01T10:00:00Z"),
26
+ );
27
+ assert.equal(result.fetch, "oneshot");
28
+ assert.equal(result.state, STATES.STANDBY_OFFSHORE);
29
+ assert.equal(machine.state, STATES.STANDBY_OFFSHORE);
30
+ });
31
+
32
+ test("online + moored + full battery lands in PERSISTENT_CRON", () => {
33
+ const machine = new PassageStateMachine();
34
+ const result = machine.update(
35
+ {
36
+ internetState: "online",
37
+ navigationState: "moored",
38
+ soc: 0.98,
39
+ },
40
+ d("2026-08-01T10:00:00Z"),
41
+ );
42
+ assert.equal(result.fetch, "oneshot");
43
+ assert.equal(result.state, STATES.PERSISTENT_CRON);
44
+ });
45
+
46
+ test("low or unknown state of charge keeps the vessel in STANDBY_OFFSHORE", () => {
47
+ const machine = new PassageStateMachine();
48
+ machine.update(
49
+ { internetState: "online", navigationState: "anchored", soc: 0.5 },
50
+ d("2026-08-01T10:00:00Z"),
51
+ );
52
+ assert.equal(machine.state, STATES.STANDBY_OFFSHORE);
53
+
54
+ const unknown = new PassageStateMachine();
55
+ unknown.update(
56
+ { internetState: "online", navigationState: "anchored", soc: null },
57
+ d("2026-08-01T10:00:00Z"),
58
+ );
59
+ assert.equal(unknown.state, STATES.STANDBY_OFFSHORE);
60
+ });
61
+
62
+ test("soc threshold matches the spec constant", () => {
63
+ assert.equal(SOC_FULL_THRESHOLD, 0.95);
64
+ });
65
+
66
+ test("stable observations do not refetch", () => {
67
+ const machine = new PassageStateMachine();
68
+ machine.update(
69
+ { internetState: "online", navigationState: "sailing" },
70
+ d("2026-08-01T10:00:00Z"),
71
+ );
72
+ const again = machine.update(
73
+ { internetState: "online", navigationState: "sailing" },
74
+ d("2026-08-01T10:00:01Z"),
75
+ );
76
+ assert.equal(again.fetch, null);
77
+ assert.equal(again.transitioned, false);
78
+ });
79
+
80
+ test("online to metered flip is a new explicit transition and refetches", () => {
81
+ const machine = new PassageStateMachine();
82
+ machine.update(
83
+ { internetState: "online", navigationState: "sailing" },
84
+ d("2026-08-01T10:00:00Z"),
85
+ );
86
+ const result = machine.update(
87
+ { internetState: "metered", navigationState: "sailing" },
88
+ d("2026-08-01T10:05:00Z"),
89
+ );
90
+ assert.equal(result.fetch, "oneshot");
91
+ });
92
+
93
+ test("losing the internet connection returns to OFFLINE", () => {
94
+ const machine = new PassageStateMachine();
95
+ machine.update(
96
+ { internetState: "online", navigationState: "moored", soc: 1 },
97
+ d("2026-08-01T10:00:00Z"),
98
+ );
99
+ const result = machine.update(
100
+ { internetState: "offline", navigationState: "moored", soc: 1 },
101
+ d("2026-08-01T10:05:00Z"),
102
+ );
103
+ assert.equal(result.state, STATES.OFFLINE);
104
+ assert.equal(machine.scheduledCronRun, null);
105
+ });
106
+
107
+ test("nextCronRun returns the four UTC publication windows", () => {
108
+ assert.deepEqual(CRON_HOURS_UTC, [2, 8, 14, 20]);
109
+ assert.equal(CRON_MINUTE_UTC, 15);
110
+
111
+ // Before the first window: same day 02:15.
112
+ assert.equal(
113
+ nextCronRun(d("2026-08-01T01:00:00Z")).toISOString(),
114
+ "2026-08-01T02:15:00.000Z",
115
+ );
116
+ // Just after a window: the next one the same day.
117
+ assert.equal(
118
+ nextCronRun(d("2026-08-01T02:15:00Z")).toISOString(),
119
+ "2026-08-01T08:15:00.000Z",
120
+ );
121
+ // After the last window: wraps to 02:15 the following day.
122
+ assert.equal(
123
+ nextCronRun(d("2026-08-01T21:00:00Z")).toISOString(),
124
+ "2026-08-02T02:15:00.000Z",
125
+ );
126
+ });
127
+
128
+ test("cron fires once per window in PERSISTENT_CRON", () => {
129
+ const machine = new PassageStateMachine();
130
+ machine.update(
131
+ { internetState: "online", navigationState: "moored", soc: 1 },
132
+ d("2026-08-01T10:00:00Z"),
133
+ );
134
+ assert.equal(
135
+ machine.scheduledCronRun?.toISOString(),
136
+ "2026-08-01T14:15:00.000Z",
137
+ );
138
+
139
+ const early = machine.tick(d("2026-08-01T14:14:00Z"));
140
+ assert.equal(early.fetch, null);
141
+
142
+ const due = machine.tick(d("2026-08-01T14:15:30Z"));
143
+ assert.equal(due.fetch, "cron");
144
+ assert.equal(due.cronWindow.toISOString(), "2026-08-01T14:15:00.000Z");
145
+
146
+ // Already handled this window: no double fire.
147
+ assert.equal(machine.tick(d("2026-08-01T14:16:00Z")).fetch, null);
148
+ });
149
+
150
+ test("cron never fires outside PERSISTENT_CRON (execution guard)", () => {
151
+ const machine = new PassageStateMachine();
152
+ // Sailing: STANDBY_OFFSHORE, cron disabled even with internet up.
153
+ machine.update(
154
+ { internetState: "online", navigationState: "sailing", soc: 1 },
155
+ d("2026-08-01T10:00:00Z"),
156
+ );
157
+ assert.equal(machine.state, STATES.STANDBY_OFFSHORE);
158
+ assert.equal(machine.scheduledCronRun, null);
159
+ assert.equal(machine.tick(d("2026-08-01T14:20:00Z")).fetch, null);
160
+ });
161
+
162
+ test("leaving and re-entering PERSISTENT_CRON reschedules the next window", () => {
163
+ const machine = new PassageStateMachine();
164
+ machine.update(
165
+ { internetState: "online", navigationState: "moored", soc: 1 },
166
+ d("2026-08-01T10:00:00Z"),
167
+ );
168
+ machine.update(
169
+ { internetState: "online", navigationState: "sailing", soc: 1 },
170
+ d("2026-08-01T11:00:00Z"),
171
+ );
172
+ const result = machine.update(
173
+ { internetState: "online", navigationState: "anchored", soc: 1 },
174
+ d("2026-08-01T15:00:00Z"),
175
+ );
176
+ assert.equal(result.state, STATES.PERSISTENT_CRON);
177
+ assert.equal(
178
+ machine.scheduledCronRun?.toISOString(),
179
+ "2026-08-01T20:15:00.000Z",
180
+ );
181
+ });
182
+ });