@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
|
@@ -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
|
+
};
|