@meri-imperiumi/signalk-passage-briefing 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +5 -0
- package/.github/workflows/publish.yml +34 -0
- package/.github/workflows/signalk-ci.yml +11 -0
- package/.github/workflows/test.yml +21 -0
- package/CHANGELOG.md +423 -0
- package/README.md +86 -0
- package/SPEC.md +488 -0
- package/bin/backfill-report.js +218 -0
- package/bin/backtest-cli.js +132 -0
- package/biome.json +6 -0
- package/doc/here-brief.png +0 -0
- package/package.json +48 -0
- package/plugin/backtest.js +742 -0
- package/plugin/brief-ext.js +173 -0
- package/plugin/bulletin-engine.js +746 -0
- package/plugin/bulletin-source.js +150 -0
- package/plugin/celestial-source.js +417 -0
- package/plugin/fetch-engine.js +717 -0
- package/plugin/history-backfill.js +540 -0
- package/plugin/index.js +1479 -0
- package/plugin/logbook-source.js +463 -0
- package/plugin/notes-publisher.js +228 -0
- package/plugin/notes-store.js +241 -0
- package/plugin/raster-convert.js +168 -0
- package/plugin/sails-configuration.js +111 -0
- package/plugin/spool-watcher.js +218 -0
- package/plugin/sqlite-db.js +378 -0
- package/plugin/state-machine.js +249 -0
- package/plugin/statustilesexamples.js +101 -0
- package/plugin/synoptic-map.json +45 -0
- package/plugin/synoptic-source.js +227 -0
- package/plugin/zone-source.js +339 -0
- package/public/app.js +18 -0
- package/public/brief-ext-model.js +64 -0
- package/public/brief-ext-widget.html +14 -0
- package/public/brief-ext-widget.js +294 -0
- package/public/components/backfill-controls.js +100 -0
- package/public/components/comfort-info.js +94 -0
- package/public/components/conditions-here.js +234 -0
- package/public/components/horizon-sparkline.js +77 -0
- package/public/components/models.mjs +372 -0
- package/public/components/passage-outlook.js +368 -0
- package/public/components/sk-api.js +146 -0
- package/public/components/sk-base-css.js +177 -0
- package/public/components/strategic-outlook.js +248 -0
- package/public/components/synoptic-chart.js +48 -0
- package/public/components/tactical-dashboard.js +172 -0
- package/public/css/visuals.css +292 -0
- package/public/gmdss-zones-min.json +287 -0
- package/public/icon.png +0 -0
- package/public/index.html +13 -0
- package/public/polar.mjs +303 -0
- package/public/route-sim.mjs +750 -0
- package/public/sereno-physics.mjs +592 -0
- package/public/tack-gybe.js +174 -0
- package/public/vendor/plotterext-bus/LICENSE +21 -0
- package/public/vendor/plotterext-bus/README.md +17 -0
- package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
- package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
- package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
- package/public/vendor/plotterext-bus/extension.js +29 -0
- package/public/vendor/plotterext-bus/host.js +28 -0
- package/public/vendor/utif/LICENSE +21 -0
- package/public/vendor/utif/UTIF.js +1171 -0
- package/public/worker.js +35 -0
- package/status-tiles-examples.json +55 -0
- package/tests/backtest.test.js +461 -0
- package/tests/brief-ext.test.js +194 -0
- package/tests/bulletin-engine.test.js +361 -0
- package/tests/celestial-source.test.js +265 -0
- package/tests/fetch-engine.test.js +297 -0
- package/tests/history-backfill.test.js +341 -0
- package/tests/logbook-source.test.js +324 -0
- package/tests/notes-publisher.test.js +161 -0
- package/tests/notes-store.test.js +138 -0
- package/tests/openmeteo-mock.js +100 -0
- package/tests/plugin.test.js +1344 -0
- package/tests/route-sim.test.js +533 -0
- package/tests/sereno-physics.test.js +333 -0
- package/tests/sqlite-db.test.js +164 -0
- package/tests/state-machine.test.js +182 -0
- package/tests/statustilesexamples.test.js +86 -0
- package/tests/synoptic-source.test.js +195 -0
- package/tests/tack-gybe.test.js +211 -0
- package/tests/webapp.test.js +257 -0
- package/tests/zone-source.test.js +226 -0
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();
|