@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
package/SPEC.md ADDED
@@ -0,0 +1,488 @@
1
+ # Passage Outlook Signal K Plugin & Webapp
2
+
3
+ ## 1. System Overview & Module Architecture
4
+
5
+ ### 1.1 Directory & File Layout
6
+
7
+ ```
8
+ signalk-passage-outlook/
9
+ ├── package.json
10
+ ├── index.js # Signal K Plugin Entrypoint & Lifecycle Manager
11
+ ├── schema.json # Signal K Plugin Configuration Schema
12
+ ├── lib/
13
+ │ ├── state-machine.js # Connection & Navigation State Machine
14
+ │ ├── fetch-engine.js # Multi-Endpoint Weather & METAREA Aggregator
15
+ │ ├── spool-watcher.js # File Spool Watcher (VARA HF / inReach Fallback)
16
+ │ ├── history-backfill.js # Logbook & History API Sail Preference Backfill
17
+ │ ├── sqlite-db.js # SQLite Engine (WAL Mode & Matrix Store)
18
+ │ └── sereno-physics.js # Shared Sereno Comfort & Monohull Motion Math
19
+ ├── public/
20
+ │ ├── index.html # Main Application Shell
21
+ │ ├── app.js # Web Component Registry & App Controller
22
+ │ ├── worker.js # Dedicated Isochrone Routing & Physics Web Worker
23
+ │ ├── css/
24
+ │ │ └── visuals.css # Signal K Tactical Sci-Fi Palette (#003399 / #ffcc00)
25
+ │ └── components/
26
+ │ ├── passage-outlook.js # Root Component Engine
27
+ │ ├── tactical-dashboard.js # Screen 1: 24h Exception Dashboard
28
+ │ ├── strategic-outlook.js # Screen 2: Passage Summary & METAREA Text
29
+ │ └── horizon-sparkline.js # Color-Coded Comfort Block SVG Generator
30
+ └── bin/
31
+ └── backtest-cli.js # History API Model Calibration & Tuning CLI
32
+
33
+ ```
34
+
35
+ ### 1.2 License & Dependency Constraints
36
+
37
+ * **License:** All code must be strictly compatible with **EUPL-1.2**.
38
+ * **Zero External Runtime Dependencies (Client):** The `public/` webapp must run on native ES Modules, Vanilla Web Components, and native Web Workers. No external frameworks (e.g., React, Vue, Tailwind) or charting libraries are permitted.
39
+ * **Server Runtime:** Node.js v20+ utilizing built-in `node:sqlite` (or `better-sqlite3`), native `fs/promises`, and native `fetch`.
40
+
41
+ ---
42
+
43
+ ## 2. Signal K Server Plugin Engine (`index.js`, `lib/state-machine.js`)
44
+
45
+ ### 2.1 Plugin Configuration Schema (`schema.json`)
46
+
47
+ ```json
48
+ {
49
+ "type": "object",
50
+ "properties": {
51
+ "motoring_tws_threshold": {
52
+ "type": "number",
53
+ "title": "Motoring TWS Threshold (knots)",
54
+ "default": 3.5
55
+ },
56
+ "drift_mode_enabled": {
57
+ "type": "boolean",
58
+ "title": "Enable Drift Mode (Zero Fuel / Current Drift)",
59
+ "default": true
60
+ },
61
+ "waterline_length_m": {
62
+ "type": "number",
63
+ "title": "Waterline Length (meters)",
64
+ "default": 9.4
65
+ },
66
+ "spool_directory": {
67
+ "type": "string",
68
+ "title": "Local GRIB/Text Ingestion Directory",
69
+ "default": "/home/node/.signalk/spool/passage-outlook"
70
+ },
71
+ "k_heel": {
72
+ "type": "number",
73
+ "title": "Heeling Acceleration Multiplier Constant",
74
+ "default": 0.35
75
+ },
76
+ "k_pitch": {
77
+ "type": "number",
78
+ "title": "Pitching Acceleration Multiplier Constant",
79
+ "default": 0.40
80
+ }
81
+ }
82
+ }
83
+
84
+ ```
85
+
86
+ ### 2.2 Connection & Execution State Machine
87
+
88
+ The plugin monitors `network.internet.state`, `navigation.state`, and `electrical.batteries.house.capacity.stateOfCharge` via the Signal K Delta subscription API.
89
+
90
+ ```
91
+ +-----------------------------------+
92
+ | OFFLINE |
93
+ | (Watch Spool Directory for GRIBs) |
94
+ +-----------------+-----------------+
95
+ |
96
+ network.internet.state == 'online' | 'metered'
97
+ |
98
+ v
99
+ +-----------------------------------+
100
+ | TRIGGER_ONESHOT |
101
+ | (Fetch Surface, Marine, CAPE) |
102
+ +-----------------+-----------------+
103
+ |
104
+ +---------------------+---------------------+
105
+ | |
106
+ network.internet.state == 'metered' network.internet.state == 'online'
107
+ OR navigation.state == 'sailing' AND navigation.state in ['moored', 'anchored']
108
+ | AND SoC > 0.95
109
+ v v
110
+ +-----------------------+ +-----------------------+
111
+ | STANDBY_OFFSHORE | | PERSISTENT_CRON |
112
+ | (Wait for next window| | (Fetch at 02, 08, 14,|
113
+ | or spool file) | | 20:00 UTC runs) |
114
+ +-----------------------+ +-----------------------+
115
+
116
+ ```
117
+
118
+ * **Cron Schedule Rules:** Runs at `02:15`, `08:15`, `14:15`, and `20:15` UTC (15 minutes after major global ensemble publication windows).
119
+ * **Execution Guard:** Checks if `navigation.state === 'moored' | 'anchored'`. If `navigation.state === 'sailing'`, cron timers are disabled and data fetches are strictly tied to single explicit transitions of `network.internet.state` to `online` or `metered`.
120
+
121
+ ---
122
+
123
+ ## 3. Data Schemas & Structural Interfaces
124
+
125
+ ### 3.1 Unified Weather Payload (`UnifiedWeatherPayload`)
126
+
127
+ Passed from backend fetch engine to frontend IndexedDB and Web Worker:
128
+
129
+ ```typescript
130
+ interface UnifiedWeatherPayload {
131
+ metadata: {
132
+ fetchedAt: string; // ISO timestamp
133
+ source: 'api' | 'spool';
134
+ models: string[]; // e.g., ['ECMWF-HRES', 'GFS']
135
+ };
136
+ waypoints: {
137
+ lat: number;
138
+ lon: number;
139
+ distanceFromStartNm: number;
140
+ forecasts: TimeStepForecast[];
141
+ }[];
142
+ metareaBulletin?: {
143
+ header: string;
144
+ issuedAt: string;
145
+ bulletinText: string;
146
+ };
147
+ }
148
+
149
+ interface TimeStepForecast {
150
+ timestamp: string; // ISO timestamp
151
+ surface: {
152
+ tws: number; // knots
153
+ twd: number; // degrees true
154
+ mslp: number; // hPa
155
+ };
156
+ marine: {
157
+ hsCombined: number; // meters
158
+ tpCombined: number; // seconds
159
+ dirCombined: number; // degrees true
160
+ hsSwell: number; // meters
161
+ tpSwell: number; // seconds
162
+ dirSwell: number; // degrees true
163
+ hsWindSea: number; // meters
164
+ tpWindSea: number; // seconds
165
+ dirWindSea: number; // degrees true
166
+ };
167
+ upperAir: {
168
+ cape: number; // J/kg
169
+ kIndex: number; // scalar
170
+ precipitableWater: number; // mm
171
+ rh700: number; // percentage (0-100)
172
+ wind850kts: number; // knots
173
+ };
174
+ current: {
175
+ drift: number; // knots
176
+ set: number; // degrees true
177
+ };
178
+ }
179
+
180
+ ```
181
+
182
+ ### 3.2 Learned Sail Preference Matrix (`SailPreferenceMatrix`)
183
+
184
+ Stored in SQLite and loaded into memory:
185
+
186
+ ```typescript
187
+ interface SailPreferenceMatrix {
188
+ twsBinsKnots: number[]; // [0, 5, 10, 15, 20, 25, 30, 35, 40]
189
+ twaBinsDegrees: number[]; // [0, 30, 60, 90, 120, 150, 180]
190
+ matrix: {
191
+ twsBin: number;
192
+ twaBin: number;
193
+ preferredSailState: string; // e.g., "MAIN_FULL_GENOA_100", "MAIN_REEF_1_GENOA_100"
194
+ minTwsGustTrigger: number;
195
+ samplesCount: number;
196
+ }[];
197
+ }
198
+
199
+ ```
200
+
201
+ ---
202
+
203
+ ## 4. History API Backfill & SQLite Matrix Engine (`lib/sqlite-db.js`, `lib/history-backfill.js`)
204
+
205
+ ### 4.1 SQLite Schema & Initialization
206
+
207
+ Database path: `userDataDir/passage-outlook.sqlite`
208
+
209
+ ```sql
210
+ PRAGMA journal_mode = WAL;
211
+ PRAGMA synchronous = NORMAL;
212
+
213
+ CREATE TABLE IF NOT EXISTS logbook_sail_events (
214
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
215
+ timestamp TEXT NOT NULL,
216
+ event_type TEXT NOT NULL, -- 'REEF_INCREASE', 'REEF_DECREASE', 'SAIL_CHANGE'
217
+ sail_state TEXT NOT NULL, -- e.g., 'REEF_1_GENOA'
218
+ notes TEXT
219
+ );
220
+
221
+ CREATE TABLE IF NOT EXISTS wind_history_cache (
222
+ timestamp TEXT PRIMARY KEY,
223
+ tws_avg REAL NOT NULL,
224
+ tws_peak REAL NOT NULL,
225
+ twa_avg REAL NOT NULL
226
+ );
227
+
228
+ CREATE TABLE IF NOT EXISTS learned_sail_matrix (
229
+ tws_bin INTEGER NOT NULL,
230
+ twa_bin INTEGER NOT NULL,
231
+ preferred_sail TEXT NOT NULL,
232
+ avg_tws_trigger REAL NOT NULL,
233
+ peak_gust_trigger REAL NOT NULL,
234
+ sample_count INTEGER NOT NULL,
235
+ PRIMARY KEY (tws_bin, twa_bin)
236
+ );
237
+
238
+ ```
239
+
240
+ ### 4.2 History API Extraction Algorithm
241
+
242
+ For each logbook event recorded at $t_{\text{event}}$:
243
+
244
+ 1. Query Signal K History API at `/signalk/v2/api/history/values`:
245
+ * Path: `environment.wind.speedTrue` & `environment.wind.angleTrue`
246
+ * Time Window: $[t_{\text{event}} - 15\text{ min}, t_{\text{event}}]$
247
+
248
+
249
+ 2. Calculate:
250
+
251
+ $$\text{TWS}_{\text{avg}} = \frac{1}{N} \sum_{i=1}^{N} \text{TWS}_i$$
252
+
253
+
254
+ $$\text{TWS}_{\text{peak}} = \max(\text{TWS}_1, \text{TWS}_2, \dots, \text{TWS}_N)$$
255
+
256
+
257
+ $$\text{TWA}_{\text{avg}} = \operatorname{atan2}\left(\frac{1}{N}\sum \sin(\text{TWA}_i), \frac{1}{N}\sum \cos(\text{TWA}_i)\right)$$
258
+
259
+
260
+ 3. Update `learned_sail_matrix` bin $(b_{\text{TWS}}, b_{\text{TWA}})$ using Exponential Moving Average ($\alpha = 0.2$):
261
+
262
+ $$\text{Trigger}_{\text{new}} = (1 - \alpha) \cdot \text{Trigger}_{\text{old}} + \alpha \cdot \text{TWS}_{\text{peak}}$$
263
+
264
+
265
+
266
+ ---
267
+
268
+ ## 5. Monohull Sereno Physics & Routing Engine
269
+
270
+ ### 5.1 Step-Forward Isochrone Simulation Loop
271
+
272
+ Executed inside `public/worker.js` for time steps $\Delta t = 1.0\text{ hour}$:
273
+
274
+ ```
275
+ [ Start Step Step i: t = t_i ]
276
+ |
277
+ [ Interpolate Weather Vector ]
278
+ |
279
+ +-------------------------+-------------------------+
280
+ | |
281
+ [ TWS >= motoring_tws_threshold ] [ TWS < motoring_tws_threshold ]
282
+ | |
283
+ v v
284
+ [ Lookup STW in Polars ] [ Check drift_mode_enabled ]
285
+ | |
286
+ | +-----------------+-----------------+
287
+ | | |
288
+ | [ Mode == true ] [ Mode == false ]
289
+ | | |
290
+ | v v
291
+ | STW = 0.0 kt STW = 4.5 kt
292
+ | Fuel = 0.0 gal/h Fuel = 0.8 gal/h
293
+ | | |
294
+ +---------------------------------+-----------------------------------+
295
+ |
296
+ [ Calculate SOG Vector ]
297
+ V_SOG = V_STW(heading) + V_current
298
+ |
299
+ [ Advance Position: P_i -> P_{i+1} ]
300
+ |
301
+ [ Evaluate Monohull Sereno Comfort ]
302
+
303
+ ```
304
+
305
+ ### 5.2 Monohull Sereno Comfort Index Formulation
306
+
307
+ #### Vector 1: Apparent Wind Speed Rating
308
+
309
+ $$AWS = \sqrt{TWS^2 + STW^2 + 2 \cdot TWS \cdot STW \cdot \cos(TWA)}$$
310
+
311
+ * $AWS < 12\text{ kt} \implies \mathbf{Champagne}$
312
+ * $12\text{ kt} \le AWS < 18\text{ kt} \implies \mathbf{Easy}$
313
+ * $18\text{ kt} \le AWS < 23\text{ kt} \implies \mathbf{Coffee}$
314
+ * $23\text{ kt} \le AWS < 33\text{ kt} \implies \mathbf{Rough}$
315
+ * $AWS \ge 33\text{ kt} \implies \mathbf{Sick}$
316
+
317
+ #### Vector 2: Monohull Vertical Acceleration ($a_z$)
318
+
319
+ 1. **Wave Encounter Period ($T_e$):**
320
+
321
+ $$T_e = \frac{T_{p,\text{combined}}}{1 - \left(\frac{2\pi \cdot SOG \cdot 0.5144}{9.81 \cdot T_{p,\text{combined}}}\right) \cos(\alpha_{\text{wave}} - \text{Heading})}$$
322
+
323
+
324
+ 2. **Base Acceleration ($a_{z,\text{base}}$):**
325
+
326
+ $$a_{z,\text{base}} = \frac{4\pi^2}{T_e^2} \cdot \frac{H_{s,\text{combined}}}{2}$$
327
+
328
+
329
+ 3. **Heel Angle Estimation ($\phi$) & Heel Multiplier ($\mu_{\text{heel}}$):**
330
+
331
+ $$\phi = \phi_{\text{max}} \cdot \left(\frac{AWS}{25.0}\right) \cdot \sin(TWA), \quad \text{where } \phi_{\text{max}} = 25^\circ \text{ (0.436 rad)}$$
332
+
333
+
334
+ $$\mu_{\text{heel}} = 1.0 + k_{\text{heel}} \cdot \vert{}\sin\phi\vert{} \quad (k_{\text{heel}} = 0.35)$$
335
+
336
+
337
+ 4. **Waterline Pitching Resonance ($\mu_{\text{pitch}}$):**
338
+ Given vessel $L_{\text{wl}} = 9.4\text{ m}$ and wave length $L_{\text{wave}} = \frac{9.81 \cdot T_{p,\text{combined}}^2}{2\pi}$:
339
+
340
+ $$\mu_{\text{pitch}} = 1.0 + k_{\text{pitch}} \cdot \exp\left(-\left(\frac{L_{\text{wave}} - L_{\text{wl}}}{L_{\text{wl}}}\right)^2\right) + \max\left(0, \frac{3.28 - (T_{p,\text{combined}} / H_{s,\text{combined}})}{3.28}\right)$$
341
+
342
+
343
+ 5. **Effective Acceleration & ISO Rating:**
344
+
345
+ $$a_z = a_{z,\text{base}} \cdot \mu_{\text{heel}} \cdot \mu_{\text{pitch}}$$
346
+
347
+
348
+ * $a_z < 0.15\text{ m/s}^2 \implies \mathbf{Champagne}$
349
+ * $0.15 \le a_z < 0.315\text{ m/s}^2 \implies \mathbf{Easy}$
350
+ * $0.315 \le a_z < 0.630\text{ m/s}^2 \implies \mathbf{Coffee}$
351
+ * $0.630 \le a_z < 1.250\text{ m/s}^2 \implies \mathbf{Rough}$
352
+ * $a_z \ge 1.250\text{ m/s}^2 \implies \mathbf{Sick}$
353
+
354
+
355
+
356
+ ### 5.3 Hazard Proximity Ray-Casting Algorithm
357
+
358
+ For each 1-hour route segment $(P_i, P_{i+1})$ and each active note in `resources.notes`:
359
+
360
+ 1. **Time-To-Target Calculation:** $t_{\text{intercept}} = t_0 + i \cdot \Delta t$.
361
+ 2. **Bounding Box Filter:** Expand note boundary by $r = 5.0\text{ nm}$.
362
+ 3. **Ray Casting (Point-In-Polygon):**
363
+ If note geometry is a polygon $V_1, V_2, \dots, V_k$, test whether interpolated point $P_{\text{interp}}(\tau) = (1-\tau)P_i + \tau P_{i+1}$ intersects the polygon:
364
+
365
+ $$\text{IntersectCount} = \sum_{j=1}^{k} \text{RayCrossesSegment}(P_{\text{interp}}, V_j, V_{j+1})$$
366
+
367
+
368
+ 4. If $\text{IntersectCount} \pmod 2 \neq 0$ or distance to point position $d < r$, generate a **Proximity Alert** attached to time window $t_{\text{intercept}}$.
369
+
370
+ ---
371
+
372
+ ## 6. Client Web Components & UI Architecture
373
+
374
+ ### 6.1 Design System Tokens (`public/css/visuals.css`)
375
+
376
+ ```css
377
+ :root {
378
+ --color-bg: #003399;
379
+ --color-fg: #ffcc00;
380
+ --color-champagne: #00ffcc;
381
+ --color-easy: #00ff00;
382
+ --color-coffee: #ffcc00;
383
+ --color-rough: #ff6600;
384
+ --color-sick: #ff0000;
385
+ --font-family: 'Courier New', Courier, monospace;
386
+ }
387
+
388
+ body {
389
+ background-color: var(--color-bg);
390
+ color: var(--color-fg);
391
+ font-family: var(--font-family);
392
+ margin: 0;
393
+ padding: 8px;
394
+ }
395
+
396
+ ```
397
+
398
+ ### 6.2 Exception-Based Display Filtering Engine
399
+
400
+ Before passing Web Worker output to UI elements, pass raw calculations through `filterExceptions()`:
401
+
402
+ ```javascript
403
+ function filterExceptions(simulationResult) {
404
+ return {
405
+ next24h: {
406
+ comfortBlocks: simulationResult.hourlyComfort.slice(0, 24),
407
+ sailChanges: simulationResult.sailEvents.filter(e => e.hoursFromNow <= 24),
408
+ hazards: simulationResult.hazardAlerts.filter(h => h.hoursFromNow <= 24),
409
+ solarYieldKwh: simulationResult.energy.netSolar24h,
410
+ energyDeficitAlert: simulationResult.energy.netBalance24h < 0
411
+ },
412
+ passageSummary: {
413
+ etaP10: simulationResult.eta.p10,
414
+ etaP50: simulationResult.eta.p50,
415
+ etaP90: simulationResult.eta.p90,
416
+ totalMotorHours: simulationResult.motoringHours,
417
+ totalFuelGal: simulationResult.fuelConsumptionGal,
418
+ macroSeaAnomalies: simulationResult.seaStateAnomalies.filter(a => a.steepnessRatio < 3.28),
419
+ convectiveWarnings: simulationResult.upperAirAnomalies.filter(u => u.cape > 1000 || u.kIndex > 28)
420
+ }
421
+ };
422
+ }
423
+
424
+ ```
425
+
426
+ ### 6.3 Custom Elements Definition
427
+
428
+ #### `<passage-outlook>` (App Root)
429
+
430
+ Component Shell. Manages WebSocket connection to Signal K, reads IndexedDB cache, posts payload to `worker.js`, and renders `<tactical-dashboard>` or `<strategic-outlook>` based on tab selection.
431
+
432
+ #### `<tactical-dashboard>` (Screen 1: Next 24h)
433
+
434
+ Displays:
435
+
436
+ 1. `<horizon-sparkline>` SVG element.
437
+ 2. Sail action cards **only** if `sailChanges.length > 0`.
438
+ 3. Energy balance warning **only** if `energyDeficitAlert === true`.
439
+ 4. Hazard alert banners **only** if `hazards.length > 0`.
440
+
441
+ #### `<strategic-outlook>` (Screen 2: Passage Summary)
442
+
443
+ Displays:
444
+
445
+ 1. ETA Range Table (P10, P50, P90, Motor Hours, Fuel).
446
+ 2. Macro Sea State warnings block.
447
+ 3. Convective Warning block (CAPE / K-Index).
448
+ 4. Raw METAREA text bulletin container.
449
+
450
+ #### `<horizon-sparkline>` (SVG Renderer)
451
+
452
+ Renders a 24-column SVG bar chart where bar background color maps to `comfortLevel` (Champagne, Easy, Coffee, Rough, Sick) and bar height represents $AWS$.
453
+
454
+ ---
455
+
456
+ ## 7. Backtest CLI Module (`bin/backtest-cli.js`)
457
+
458
+ ### 7.1 Command Line Interface
459
+
460
+ ```bash
461
+ node bin/backtest-cli.js \
462
+ --history-url http://localhost:3000/signalk/v2/api/history \
463
+ --start 2026-08-01T00:00:00Z \
464
+ --end 2026-08-03T12:00:00Z \
465
+ --output ./backtest-report.json
466
+
467
+ ```
468
+
469
+ ### 7.2 Calibration & Optimization Engine
470
+
471
+ 1. **Extract Telemetry:** Queries historical `navigation.attitude` ($\text{roll}, \text{pitch}$) at 1 Hz over 15-minute sliding windows.
472
+ 2. **Reconstruct Acceleration:**
473
+
474
+ $$a_{z,\text{measured}} = \sqrt{\operatorname{Var}\left(\frac{d^2\text{pitch}}{dt^2} \cdot 3.5\right) + \operatorname{Var}\left(\frac{d^2\text{roll}}{dt^2} \cdot 1.5\right) + 9.81^2 \cdot \operatorname{Var}(\sin\text{roll})}$$
475
+
476
+
477
+ 3. **Loss Function (Mean Absolute Error):**
478
+
479
+ $$\mathcal{L}(k_{\text{heel}}, k_{\text{pitch}}) = \frac{1}{N}\sum_{i=1}^{N} \left\vert{} a_{z,\text{predicted}}^{(i)}(k_{\text{heel}}, k_{\text{pitch}}) - a_{z,\text{measured}}^{(i)} \right\vert{}$$
480
+
481
+
482
+ 4. **Optimization Routine:** Executes Nelder-Mead simplex optimization to find optimal $(k_{\text{heel}}, k_{\text{pitch}})$.
483
+ 5. **Output Confusion Matrix:**
484
+ Prints a $5 \times 5$ classification matrix comparing predicted vs. actual Sereno Comfort tiers across all evaluated passage windows.
485
+
486
+ ## Implementation
487
+
488
+ Biome-checked JavaScript with JsDoc type annotations. Webapp as per `signalk-visuals.md` context document.
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Logbook backfill & sail usage report.
4
+ *
5
+ * Runs the SPEC §4.2 sail preference backfill over a
6
+ * `signalk-logbook` store using the wind snapshots written in the log
7
+ * entries (no History API needed — usable ashore against a copy of
8
+ * the log repository), then reports:
9
+ *
10
+ * 1. the learned sail preference matrix (SPEC §3.2);
11
+ * 2. the conditions actually experienced per sail combination, next
12
+ * to the whole-sail wind limits configured in
13
+ * `@signalk/sailsconfiguration` (m/s converted to knots; the
14
+ * configuration has no limits per reefed configuration).
15
+ *
16
+ * Usage:
17
+ *
18
+ * node bin/backfill-report.js [--dir <logbook-dir>] [--sails <json>]
19
+ * [--from <iso>] [--to <iso>] [--db <sqlite-file>] [--json]
20
+ *
21
+ * Defaults: `--dir ~/.signalk/plugin-config-data/signalk-logbook`,
22
+ * `--sails ~/.signalk/plugin-config-data/sailsconfiguration.json`.
23
+ *
24
+ * Once on board, re-run the backfill through the plugin's
25
+ * `POST /api/backfill?source=history&baseUrl=<server>` route to learn
26
+ * from full-resolution History API wind instead of log snapshots.
27
+ *
28
+ * @file backfill-report.js
29
+ */
30
+
31
+ const { mkdtempSync } = require("node:fs");
32
+ const { homedir } = require("node:os");
33
+ const { join } = require("node:path");
34
+
35
+ const { PassageDatabase } = require("../plugin/sqlite-db.js");
36
+ const {
37
+ readLogbookEntries,
38
+ readLogbookSailEvents,
39
+ } = require("../plugin/logbook-source.js");
40
+ const {
41
+ backfillSailEvents,
42
+ createLogbookWindStats,
43
+ summarizeSailUsage,
44
+ } = require("../plugin/history-backfill.js");
45
+ const {
46
+ readSailsConfiguration,
47
+ sailsMatchingStateKey,
48
+ } = require("../plugin/sails-configuration.js");
49
+
50
+ const SIGNALK_DIR = join(homedir(), ".signalk", "plugin-config-data");
51
+
52
+ /**
53
+ * Parses argv into a `{flag: value}` map (`--flag value`, boolean
54
+ * flags without value become true).
55
+ *
56
+ * @param {string[]} argv
57
+ * @returns {Record<string, string|boolean>}
58
+ */
59
+ function parseArgs(argv) {
60
+ const args = {};
61
+ for (let i = 0; i < argv.length; i++) {
62
+ const flag = argv[i];
63
+ if (!flag.startsWith("--")) {
64
+ continue;
65
+ }
66
+ const key = flag.slice(2);
67
+ const next = argv[i + 1];
68
+ if (next != null && !next.startsWith("--")) {
69
+ args[key] = next;
70
+ i++;
71
+ } else {
72
+ args[key] = true;
73
+ }
74
+ }
75
+ return args;
76
+ }
77
+
78
+ function main() {
79
+ const args = parseArgs(process.argv.slice(2));
80
+ const logbookDir =
81
+ typeof args.dir === "string"
82
+ ? args.dir
83
+ : join(SIGNALK_DIR, "signalk-logbook");
84
+ const sailsFile =
85
+ typeof args.sails === "string"
86
+ ? args.sails
87
+ : join(SIGNALK_DIR, "sailsconfiguration.json");
88
+ const from = typeof args.from === "string" ? args.from : undefined;
89
+ const to = typeof args.to === "string" ? args.to : undefined;
90
+ const asJson = args.json === true;
91
+
92
+ const dataDir = mkdtempSync(
93
+ join(process.env.TMPDIR || "/tmp", "passage-backfill-"),
94
+ );
95
+ const db = new PassageDatabase(dataDir);
96
+
97
+ return Promise.all([
98
+ readLogbookEntries(logbookDir),
99
+ readSailsConfiguration(sailsFile),
100
+ ])
101
+ .then(async ([entries, sails]) => {
102
+ const knownSailKeys =
103
+ sails.length > 0
104
+ ? new Set(sails.map((sail) => sail.nameKey))
105
+ : undefined;
106
+ const events = await readLogbookSailEvents({
107
+ dir: logbookDir,
108
+ from,
109
+ to,
110
+ knownSailKeys,
111
+ });
112
+ const summary = await backfillSailEvents({
113
+ db,
114
+ events,
115
+ getWindStats: createLogbookWindStats(entries),
116
+ });
117
+ const matrix = db.getSailPreferenceMatrix();
118
+ const usage = summarizeSailUsage(
119
+ db.getSailEvents({ limit: 10000 }),
120
+ db.getWindHistory("0000-01-01T00:00:00Z", "9999-12-31T23:59:59Z"),
121
+ );
122
+ return {
123
+ logbook: logbookDir,
124
+ entries: entries.length,
125
+ events: events.length,
126
+ summary,
127
+ matrix,
128
+ usage,
129
+ sails,
130
+ };
131
+ })
132
+ .then((report) => {
133
+ db.close();
134
+ if (asJson) {
135
+ console.log(JSON.stringify(report, null, 2));
136
+ return;
137
+ }
138
+ printReport(report);
139
+ })
140
+ .catch((error) => {
141
+ db.close();
142
+ console.error(`backfill-report: ${error.message}`);
143
+ process.exitCode = 1;
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Annotates a sail state with the configured wind limits of the sails
149
+ * it mentions, in knots.
150
+ *
151
+ * @param {Array} sails - From readSailsConfiguration
152
+ * @param {string} sailState
153
+ * @returns {string} e.g. `Main 2..? kn, Genoa 1 ?..? kn`
154
+ */
155
+ function configuredLimits(sails, sailState) {
156
+ const matched = sailsMatchingStateKey(sails, sailState);
157
+ if (matched.length === 0) {
158
+ return "";
159
+ }
160
+ return matched
161
+ .map((sail) => {
162
+ const min =
163
+ sail.minimumWindKnots != null ? sail.minimumWindKnots.toFixed(1) : "?";
164
+ const max =
165
+ sail.maximumWindKnots != null ? sail.maximumWindKnots.toFixed(1) : "?";
166
+ return `${sail.name} ${min}–${max} kn`;
167
+ })
168
+ .join(", ");
169
+ }
170
+
171
+ /**
172
+ * @param {object} report
173
+ */
174
+ function printReport(report) {
175
+ const { entries, events, summary, matrix, usage, sails } = report;
176
+ console.log(`Logbook: ${report.logbook}`);
177
+ console.log(
178
+ `Entries: ${entries}, sail events in window: ${events}, learned now: ` +
179
+ `${summary.learned} (cached: ${summary.skippedCached}, no wind: ${summary.skippedNoData})`,
180
+ );
181
+
182
+ console.log("\nConditions per sail combination (learned from logbook wind):");
183
+ if (usage.length === 0) {
184
+ console.log(" (no events with wind data)");
185
+ }
186
+ for (const row of usage) {
187
+ const types = Object.entries(row.eventTypes)
188
+ .map(([type, count]) => `${type}×${count}`)
189
+ .join(" ");
190
+ console.log(
191
+ ` ${row.sailState}${row.night ? " [night]" : " [day]"}\n` +
192
+ ` samples: ${row.samples} (${types})\n` +
193
+ ` TWS avg ${row.twsAvgMin}–${row.twsAvgMax} kn (mean ${row.twsAvgMean}), ` +
194
+ `peak up to ${row.twsPeakMax} kn\n` +
195
+ ` TWA ${row.twaMin}–${row.twaMax}° (mean ${row.twaMean})`,
196
+ );
197
+ const limits = configuredLimits(sails, row.sailState);
198
+ if (limits) {
199
+ console.log(` configured whole-sail limits: ${limits}`);
200
+ }
201
+ }
202
+
203
+ console.log("\nLearned sail preference matrix (bins with data):");
204
+ if (matrix.matrix.length === 0) {
205
+ console.log(" (no bins learned)");
206
+ }
207
+ for (const cell of matrix.matrix) {
208
+ console.log(
209
+ ` TWS ${matrix.twsBinsKnots[cell.twsBin]}–${matrix.twsBinsKnots[cell.twsBin + 1] ?? "∞"} kn, ` +
210
+ `TWA ${matrix.twaBinsDegrees[cell.twaBin]}–${matrix.twaBinsDegrees[cell.twaBin + 1] ?? 180}°, ` +
211
+ `${cell.night ? "night" : "day"}: ` +
212
+ `${cell.preferredSailState} ` +
213
+ `(trigger ${cell.minTwsGustTrigger.toFixed(1)} kn, n=${cell.samplesCount})`,
214
+ );
215
+ }
216
+ }
217
+
218
+ main();