@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,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sail preference backfill engine (SPEC §4.2).
|
|
3
|
+
*
|
|
4
|
+
* For each logbook sail event the engine gathers the wind conditions
|
|
5
|
+
* of the 15-minute window ending at the event, condenses them into
|
|
6
|
+
* TWS average / TWS peak / circular-mean TWA, caches them, and folds
|
|
7
|
+
* the observation into the learned sail preference matrix with an
|
|
8
|
+
* exponential moving average (α = 0.2).
|
|
9
|
+
*
|
|
10
|
+
* Wind sources are pluggable through a `getWindStats(event)` callback:
|
|
11
|
+
*
|
|
12
|
+
* - {@link createLogbookWindStats} reads the wind snapshots the crew
|
|
13
|
+
* wrote into the logbook entries themselves (the event entry and any
|
|
14
|
+
* adjacent entries in the window, e.g. hourly entries). This is the
|
|
15
|
+
* default and needs no History API — usable anywhere, including
|
|
16
|
+
* ashore with a copy of the log repository.
|
|
17
|
+
* - {@link createHistoryWindStats} queries the Signal K History API
|
|
18
|
+
* (`environment.wind.speedTrue` / `environment.wind.angleTrue`) for
|
|
19
|
+
* the window. This is the SPEC §4.2 source of record and is meant
|
|
20
|
+
* for the on-board backfill run where the full-resolution history is
|
|
21
|
+
* present.
|
|
22
|
+
*
|
|
23
|
+
* Backfills are idempotent: an event whose timestamp already has a
|
|
24
|
+
* `wind_history_cache` row is skipped, so re-running over overlapping
|
|
25
|
+
* windows never double-counts samples in the EMA.
|
|
26
|
+
*
|
|
27
|
+
* @file history-backfill.js
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { twsBinIndex, twaBinIndex, emaUpdate } = require("./sqlite-db.js");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lazy handle on the shared physics module (ESM, imported from this
|
|
34
|
+
* CJS module on first use) for the day/night bucket.
|
|
35
|
+
*
|
|
36
|
+
* @type {Promise<typeof import("../public/sereno-physics.mjs")>|null}
|
|
37
|
+
*/
|
|
38
|
+
let physicsPromise = null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @returns {Promise<typeof import("../public/sereno-physics.mjs")>}
|
|
42
|
+
*/
|
|
43
|
+
function loadPhysics() {
|
|
44
|
+
if (!physicsPromise) {
|
|
45
|
+
physicsPromise = import("../public/sereno-physics.mjs");
|
|
46
|
+
}
|
|
47
|
+
return physicsPromise;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Wind window ending at each sail event (SPEC §4.2).
|
|
52
|
+
*/
|
|
53
|
+
const WINDOW_MINUTES = 15;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Signal K paths queried from the History API.
|
|
57
|
+
*/
|
|
58
|
+
const WIND_PATHS = ["environment.wind.speedTrue", "environment.wind.angleTrue"];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* History API sample resolution (seconds).
|
|
62
|
+
*/
|
|
63
|
+
const HISTORY_RESOLUTION_SECONDS = 10;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Meters per second per knot (History API values are SI).
|
|
67
|
+
*/
|
|
68
|
+
const MS_TO_KNOTS = 1.943844;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parses a timestamp that carries no explicit offset as UTC (the
|
|
72
|
+
* History API returns naive ISO strings).
|
|
73
|
+
*
|
|
74
|
+
* @param {string} time - ISO 8601 timestamp
|
|
75
|
+
* @returns {Date}
|
|
76
|
+
*/
|
|
77
|
+
function parseUtcTimestamp(time) {
|
|
78
|
+
const hasOffset = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(time);
|
|
79
|
+
return new Date(hasOffset ? time : `${time}Z`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Folds a true wind direction in degrees onto the 0..180 TWA magnitude
|
|
84
|
+
* range of the learned matrix bins.
|
|
85
|
+
*
|
|
86
|
+
* @param {number} degrees - True wind direction (degrees)
|
|
87
|
+
* @returns {number} |TWA| in 0..180 degrees
|
|
88
|
+
*/
|
|
89
|
+
function foldTwaDegrees(degrees) {
|
|
90
|
+
const normalized = ((degrees % 360) + 360) % 360;
|
|
91
|
+
return normalized > 180 ? 360 - normalized : normalized;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Circular mean of wind angles given in degrees, folded to 0..180.
|
|
96
|
+
*
|
|
97
|
+
* @param {number[]} degreesList - Folded angles (0..180 degrees)
|
|
98
|
+
* @returns {number} Circular mean (0..180 degrees)
|
|
99
|
+
*/
|
|
100
|
+
function circularMeanDegrees(degreesList) {
|
|
101
|
+
let sinSum = 0;
|
|
102
|
+
let cosSum = 0;
|
|
103
|
+
for (const degrees of degreesList) {
|
|
104
|
+
const rad = (degrees * Math.PI) / 180;
|
|
105
|
+
sinSum += Math.sin(rad);
|
|
106
|
+
cosSum += Math.cos(rad);
|
|
107
|
+
}
|
|
108
|
+
const mean = (Math.atan2(sinSum, cosSum) * 180) / Math.PI;
|
|
109
|
+
return foldTwaDegrees(mean);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Wind statistics of a sail event's window (SPEC §4.2 step 2).
|
|
114
|
+
*
|
|
115
|
+
* @typedef {object} WindWindowStats
|
|
116
|
+
* @property {number} samples - Number of wind observations
|
|
117
|
+
* @property {number} twsAvg - Mean true wind speed (kn)
|
|
118
|
+
* @property {number} twsPeak - Peak true wind speed (kn)
|
|
119
|
+
* @property {number} twaAvg - Circular mean true wind angle (deg,
|
|
120
|
+
* 0..180)
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Builds a `getWindStats(event)` function that derives the window wind
|
|
125
|
+
* statistics from the wind snapshots written in the logbook entries
|
|
126
|
+
* (human-friendly units: knots, degrees true). All entries carrying a
|
|
127
|
+
* numeric `wind.speed` within `[t − window, t]` contribute — the sail
|
|
128
|
+
* event's own entry and any adjacent entries such as the hourly
|
|
129
|
+
* automatic entries.
|
|
130
|
+
*
|
|
131
|
+
* @param {Array<{datetime: string|Date, wind?: {speed?: number, direction?: number}}>|
|
|
132
|
+
* Array<{datetime: string|Date, text?: string, wind?: {speed?: number, direction?: number}}>} entries
|
|
133
|
+
* All logbook entries (not just sail events)
|
|
134
|
+
* @param {number} [windowMinutes] - Window length (default
|
|
135
|
+
* {@link WINDOW_MINUTES})
|
|
136
|
+
* @returns {(event: {timestamp: string}) => WindWindowStats|null}
|
|
137
|
+
*/
|
|
138
|
+
function createLogbookWindStats(entries, windowMinutes = WINDOW_MINUTES) {
|
|
139
|
+
// Pre-index the wind-carrying entries once, sorted by time.
|
|
140
|
+
const windEntries = entries
|
|
141
|
+
.map((entry) => ({
|
|
142
|
+
time: new Date(entry.datetime).getTime(),
|
|
143
|
+
twsKnots:
|
|
144
|
+
entry.wind && typeof entry.wind.speed === "number"
|
|
145
|
+
? entry.wind.speed
|
|
146
|
+
: null,
|
|
147
|
+
twaDeg:
|
|
148
|
+
entry.wind && typeof entry.wind.direction === "number"
|
|
149
|
+
? foldTwaDegrees(entry.wind.direction)
|
|
150
|
+
: null,
|
|
151
|
+
}))
|
|
152
|
+
.filter((e) => Number.isFinite(e.time) && e.twsKnots != null)
|
|
153
|
+
.sort((a, b) => a.time - b.time);
|
|
154
|
+
|
|
155
|
+
return (event) => {
|
|
156
|
+
const endMs = new Date(event.timestamp).getTime();
|
|
157
|
+
const startMs = endMs - windowMinutes * 60 * 1000;
|
|
158
|
+
const speeds = [];
|
|
159
|
+
const angles = [];
|
|
160
|
+
for (const entry of windEntries) {
|
|
161
|
+
if (entry.time < startMs || entry.time > endMs) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
speeds.push(entry.twsKnots);
|
|
165
|
+
if (entry.twaDeg != null) {
|
|
166
|
+
angles.push(entry.twaDeg);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (speeds.length === 0) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
samples: speeds.length,
|
|
174
|
+
twsAvg: speeds.reduce((a, b) => a + b, 0) / speeds.length,
|
|
175
|
+
twsPeak: Math.max(...speeds),
|
|
176
|
+
twaAvg: angles.length > 0 ? circularMeanDegrees(angles) : 0,
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Queries the Signal K History API (SPEC §4.2 step 1). Numeric wind
|
|
183
|
+
* paths need no aggregate-method suffix.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} params
|
|
186
|
+
* @param {string} params.baseUrl - Signal K server base URL
|
|
187
|
+
* @param {string} [params.token] - Bearer token (defaults to the
|
|
188
|
+
* SIGNALK_TOKEN env var)
|
|
189
|
+
* @param {string} [params.provider] - History provider ID
|
|
190
|
+
* @param {Date} params.from - Window start
|
|
191
|
+
* @param {Date} params.to - Window end
|
|
192
|
+
* @param {string[]} [params.paths] - Paths to query (default
|
|
193
|
+
* {@link WIND_PATHS})
|
|
194
|
+
* @param {number} [params.resolution] - Resolution in seconds
|
|
195
|
+
* @param {typeof fetch} [params.fetchImpl] - Fetch implementation (tests)
|
|
196
|
+
* @returns {Promise<object>} History API `/values` response
|
|
197
|
+
*/
|
|
198
|
+
async function queryHistory({
|
|
199
|
+
baseUrl,
|
|
200
|
+
token = process.env.SIGNALK_TOKEN,
|
|
201
|
+
provider,
|
|
202
|
+
from,
|
|
203
|
+
to,
|
|
204
|
+
paths = WIND_PATHS,
|
|
205
|
+
resolution = HISTORY_RESOLUTION_SECONDS,
|
|
206
|
+
fetchImpl = fetch,
|
|
207
|
+
}) {
|
|
208
|
+
const url = new URL("/signalk/v2/api/history/values", baseUrl);
|
|
209
|
+
url.searchParams.set("paths", paths.join(","));
|
|
210
|
+
url.searchParams.set("from", from.toISOString());
|
|
211
|
+
url.searchParams.set("to", to.toISOString());
|
|
212
|
+
url.searchParams.set("resolution", String(resolution));
|
|
213
|
+
if (provider) {
|
|
214
|
+
url.searchParams.set("provider", provider);
|
|
215
|
+
}
|
|
216
|
+
const headers = { Accept: "application/json" };
|
|
217
|
+
if (token) {
|
|
218
|
+
headers.Authorization = `Bearer ${token}`;
|
|
219
|
+
}
|
|
220
|
+
const response = await fetchImpl(url, { headers });
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`History API returned ${response.status}: ${response.statusText}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return response.json();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Extracts wind window statistics from a History API `/values`
|
|
231
|
+
* response (SPEC §4.2 steps 1–2). Values are SI: speedTrue in m/s,
|
|
232
|
+
* angleTrue in radians.
|
|
233
|
+
*
|
|
234
|
+
* @param {object} params
|
|
235
|
+
* @param {object} params.historyData - History API response
|
|
236
|
+
* @param {Date} params.from - Window start (inclusive)
|
|
237
|
+
* @param {Date} params.to - Window end (inclusive)
|
|
238
|
+
* @returns {WindWindowStats|null} Null when the window has no wind data
|
|
239
|
+
*/
|
|
240
|
+
function extractWindWindowStats({ historyData, from, to }) {
|
|
241
|
+
const columns = new Map();
|
|
242
|
+
for (const [index, column] of (historyData.values || []).entries()) {
|
|
243
|
+
if (column.path != null && !columns.has(column.path)) {
|
|
244
|
+
columns.set(column.path, index + 1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const speedColumn = columns.get("environment.wind.speedTrue");
|
|
248
|
+
const angleColumn = columns.get("environment.wind.angleTrue");
|
|
249
|
+
if (speedColumn == null) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const fromMs = from.getTime();
|
|
254
|
+
const toMs = to.getTime();
|
|
255
|
+
const speeds = [];
|
|
256
|
+
const angles = [];
|
|
257
|
+
for (const row of historyData.data || []) {
|
|
258
|
+
const time = parseUtcTimestamp(row[0]).getTime();
|
|
259
|
+
if (time < fromMs || time > toMs) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const speedMs = row[speedColumn];
|
|
263
|
+
if (typeof speedMs !== "number" || !Number.isFinite(speedMs)) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
speeds.push(speedMs * MS_TO_KNOTS);
|
|
267
|
+
if (angleColumn != null) {
|
|
268
|
+
const angleRad = row[angleColumn];
|
|
269
|
+
if (typeof angleRad === "number" && Number.isFinite(angleRad)) {
|
|
270
|
+
angles.push(foldTwaDegrees((angleRad * 180) / Math.PI));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (speeds.length === 0) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
samples: speeds.length,
|
|
279
|
+
twsAvg: speeds.reduce((a, b) => a + b, 0) / speeds.length,
|
|
280
|
+
twsPeak: Math.max(...speeds),
|
|
281
|
+
twaAvg: angles.length > 0 ? circularMeanDegrees(angles) : 0,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Builds a `getWindStats(event)` function backed by the Signal K
|
|
287
|
+
* History API (the SPEC §4.2 source of record, for the on-board run).
|
|
288
|
+
* Each event triggers one window query.
|
|
289
|
+
*
|
|
290
|
+
* @param {object} [options]
|
|
291
|
+
* @param {string} options.baseUrl - Signal K server base URL
|
|
292
|
+
* @param {string} [options.token] - Bearer token (defaults to the
|
|
293
|
+
* SIGNALK_TOKEN env var)
|
|
294
|
+
* @param {string} [options.provider] - History provider ID
|
|
295
|
+
* @param {number} [options.windowMinutes] - Window length (default
|
|
296
|
+
* {@link WINDOW_MINUTES})
|
|
297
|
+
* @param {number} [options.resolution] - Resolution in seconds
|
|
298
|
+
* @param {typeof fetch} [options.fetchImpl] - Fetch implementation (tests)
|
|
299
|
+
* @returns {(event: {timestamp: string}) => Promise<WindWindowStats|null>}
|
|
300
|
+
*/
|
|
301
|
+
function createHistoryWindStats({
|
|
302
|
+
baseUrl,
|
|
303
|
+
token,
|
|
304
|
+
provider,
|
|
305
|
+
windowMinutes = WINDOW_MINUTES,
|
|
306
|
+
resolution,
|
|
307
|
+
fetchImpl,
|
|
308
|
+
} = {}) {
|
|
309
|
+
return async (event) => {
|
|
310
|
+
const to = new Date(event.timestamp);
|
|
311
|
+
const from = new Date(to.getTime() - windowMinutes * 60 * 1000);
|
|
312
|
+
const historyData = await queryHistory({
|
|
313
|
+
baseUrl,
|
|
314
|
+
token,
|
|
315
|
+
provider,
|
|
316
|
+
from,
|
|
317
|
+
to,
|
|
318
|
+
resolution,
|
|
319
|
+
fetchImpl,
|
|
320
|
+
});
|
|
321
|
+
return extractWindWindowStats({ historyData, from, to });
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Folds one sail event observation into the learned matrix
|
|
327
|
+
* (SPEC §4.2 step 3) and caches the window statistics.
|
|
328
|
+
*
|
|
329
|
+
* The matrix bins are indexed by the window's average TWS and TWA plus
|
|
330
|
+
* the day/night bucket — the crew reefs deeper at night than the
|
|
331
|
+
* conditions alone require, and the matrix keeps both behaviors.
|
|
332
|
+
* Both trigger columns learn through the α = 0.2 EMA: the average
|
|
333
|
+
* trigger from the window's mean wind, the peak/gust trigger from the
|
|
334
|
+
* window's peak wind. The preferred sail of a bin is the most recent
|
|
335
|
+
* observation.
|
|
336
|
+
*
|
|
337
|
+
* @param {object} params
|
|
338
|
+
* @param {import("./sqlite-db.js").PassageDatabase} params.db
|
|
339
|
+
* @param {{timestamp: string, eventType: string, sailState: string, night?: boolean, notes?: string}} params.event
|
|
340
|
+
* @param {WindWindowStats} params.stats
|
|
341
|
+
* @param {boolean} [params.night] - Day/night bucket of the event
|
|
342
|
+
* (defaults to the event's own flag, else day)
|
|
343
|
+
* @returns {{twsBin: number, twaBin: number, night: boolean,
|
|
344
|
+
* preferredSail: string, avgTwsTrigger: number, peakGustTrigger:
|
|
345
|
+
* number, sampleCount: number}}
|
|
346
|
+
*/
|
|
347
|
+
function applySailEvent({ db, event, stats, night = event.night ?? false }) {
|
|
348
|
+
db.upsertWindHistory({
|
|
349
|
+
timestamp: event.timestamp,
|
|
350
|
+
twsAvg: stats.twsAvg,
|
|
351
|
+
twsPeak: stats.twsPeak,
|
|
352
|
+
twaAvg: stats.twaAvg,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const twsBin = twsBinIndex(stats.twsAvg);
|
|
356
|
+
const twaBin = twaBinIndex(stats.twaAvg);
|
|
357
|
+
const existing = db
|
|
358
|
+
.getMatrixBins()
|
|
359
|
+
.find(
|
|
360
|
+
(bin) =>
|
|
361
|
+
bin.twsBin === twsBin && bin.twaBin === twaBin && bin.night === night,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const cell = {
|
|
365
|
+
twsBin,
|
|
366
|
+
twaBin,
|
|
367
|
+
night,
|
|
368
|
+
preferredSail: event.sailState,
|
|
369
|
+
avgTwsTrigger: emaUpdate(existing?.avgTwsTrigger ?? null, stats.twsAvg),
|
|
370
|
+
peakGustTrigger: emaUpdate(
|
|
371
|
+
existing?.peakGustTrigger ?? null,
|
|
372
|
+
stats.twsPeak,
|
|
373
|
+
),
|
|
374
|
+
sampleCount: (existing?.sampleCount ?? 0) + 1,
|
|
375
|
+
};
|
|
376
|
+
db.upsertMatrixBin(cell);
|
|
377
|
+
return cell;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Runs the backfill over a list of sail events (SPEC §4.2). Events are
|
|
382
|
+
* processed chronologically; an event already present in
|
|
383
|
+
* `wind_history_cache` is skipped so re-runs are idempotent. Learned
|
|
384
|
+
* events are also recorded into `logbook_sail_events`.
|
|
385
|
+
*
|
|
386
|
+
* Each event lands in the day or night matrix bucket: events with a
|
|
387
|
+
* position get the bucket from the sun altitude at the event (below
|
|
388
|
+
* the end of civil twilight is night — past dusk, squalls stop being
|
|
389
|
+
* visible and the crew reefs deeper); events without a position are
|
|
390
|
+
* treated as day.
|
|
391
|
+
*
|
|
392
|
+
* @param {object} params
|
|
393
|
+
* @param {import("./sqlite-db.js").PassageDatabase} params.db
|
|
394
|
+
* @param {Array<{timestamp: string, eventType: string, sailState: string, position?: {latitude: number, longitude: number}, night?: boolean, notes?: string}>} params.events
|
|
395
|
+
* @param {(event: {timestamp: string}) => WindWindowStats|null|Promise<WindWindowStats|null>} params.getWindStats
|
|
396
|
+
* Wind source (see {@link createLogbookWindStats} and
|
|
397
|
+
* {@link createHistoryWindStats})
|
|
398
|
+
* @returns {Promise<{total: number, learned: number, skippedCached: number,
|
|
399
|
+
* skippedNoData: number, cells: object[]}>}
|
|
400
|
+
*/
|
|
401
|
+
async function backfillSailEvents({ db, events, getWindStats }) {
|
|
402
|
+
const { isNight } = await loadPhysics();
|
|
403
|
+
const sorted = [...events].sort(
|
|
404
|
+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
let learned = 0;
|
|
408
|
+
let skippedCached = 0;
|
|
409
|
+
let skippedNoData = 0;
|
|
410
|
+
const cells = [];
|
|
411
|
+
|
|
412
|
+
for (const event of sorted) {
|
|
413
|
+
if (!event.timestamp || Number.isNaN(new Date(event.timestamp).getTime())) {
|
|
414
|
+
skippedNoData++;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const cached = db.getWindHistory(event.timestamp, event.timestamp);
|
|
418
|
+
if (cached.length > 0) {
|
|
419
|
+
skippedCached++;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const stats = await getWindStats(event);
|
|
423
|
+
if (!stats) {
|
|
424
|
+
skippedNoData++;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const night =
|
|
428
|
+
event.night ??
|
|
429
|
+
(event.position
|
|
430
|
+
? isNight(
|
|
431
|
+
new Date(event.timestamp),
|
|
432
|
+
event.position.latitude,
|
|
433
|
+
event.position.longitude,
|
|
434
|
+
)
|
|
435
|
+
: false);
|
|
436
|
+
cells.push(applySailEvent({ db, event, stats, night }));
|
|
437
|
+
db.recordSailEvent({
|
|
438
|
+
timestamp: event.timestamp,
|
|
439
|
+
eventType: event.eventType,
|
|
440
|
+
sailState: event.sailState,
|
|
441
|
+
night,
|
|
442
|
+
notes: event.notes,
|
|
443
|
+
});
|
|
444
|
+
learned++;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
total: sorted.length,
|
|
449
|
+
learned,
|
|
450
|
+
skippedCached,
|
|
451
|
+
skippedNoData,
|
|
452
|
+
cells,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const round1 = (value) => Math.round(value * 10) / 10;
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Summarizes the sailing conditions per sail combination from the
|
|
460
|
+
* learned events and their cached wind statistics. Answers "in what
|
|
461
|
+
* conditions do we fly this setup?" — the raw material behind the
|
|
462
|
+
* learned matrix, and the comparison baseline for the configured
|
|
463
|
+
* whole-sail wind limits of `@signalk/sailsconfiguration`.
|
|
464
|
+
*
|
|
465
|
+
* @param {Array<{timestamp: string, eventType: string, sailState: string}>} events
|
|
466
|
+
* Learned sail events (e.g. `db.getSailEvents()` output or the
|
|
467
|
+
* logbook events that were backfilled)
|
|
468
|
+
* @param {Array<{timestamp: string, twsAvg: number, twsPeak: number, twaAvg: number}>} windRows
|
|
469
|
+
* Cached wind window statistics (`db.getWindHistory()` output)
|
|
470
|
+
* @returns {Array<{sailState: string, samples: number,
|
|
471
|
+
* eventTypes: Object<string, number>, twsAvgMin: number,
|
|
472
|
+
* twsAvgMean: number, twsAvgMax: number, twsPeakMax: number,
|
|
473
|
+
* twaMin: number, twaMean: number, twaMax: number}>}
|
|
474
|
+
* One row per sail state, ordered by mean TWS ascending
|
|
475
|
+
*/
|
|
476
|
+
function summarizeSailUsage(events, windRows) {
|
|
477
|
+
const statsByTimestamp = new Map(windRows.map((row) => [row.timestamp, row]));
|
|
478
|
+
const groups = new Map();
|
|
479
|
+
for (const event of events) {
|
|
480
|
+
const stats = statsByTimestamp.get(event.timestamp);
|
|
481
|
+
if (!stats) {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const night = Boolean(event.night);
|
|
485
|
+
const key = `${event.sailState}|${night ? "night" : "day"}`;
|
|
486
|
+
let group = groups.get(key);
|
|
487
|
+
if (!group) {
|
|
488
|
+
group = {
|
|
489
|
+
sailState: event.sailState,
|
|
490
|
+
night,
|
|
491
|
+
samples: 0,
|
|
492
|
+
eventTypes: {},
|
|
493
|
+
twsAvg: [],
|
|
494
|
+
twsPeak: [],
|
|
495
|
+
twa: [],
|
|
496
|
+
};
|
|
497
|
+
groups.set(key, group);
|
|
498
|
+
}
|
|
499
|
+
group.samples++;
|
|
500
|
+
group.eventTypes[event.eventType] =
|
|
501
|
+
(group.eventTypes[event.eventType] ?? 0) + 1;
|
|
502
|
+
group.twsAvg.push(stats.twsAvg);
|
|
503
|
+
group.twsPeak.push(stats.twsPeak);
|
|
504
|
+
group.twa.push(stats.twaAvg);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const mean = (values) => values.reduce((a, b) => a + b, 0) / values.length;
|
|
508
|
+
return Array.from(groups.values())
|
|
509
|
+
.map((group) => ({
|
|
510
|
+
sailState: group.sailState,
|
|
511
|
+
night: group.night,
|
|
512
|
+
samples: group.samples,
|
|
513
|
+
eventTypes: group.eventTypes,
|
|
514
|
+
twsAvgMin: round1(Math.min(...group.twsAvg)),
|
|
515
|
+
twsAvgMean: round1(mean(group.twsAvg)),
|
|
516
|
+
twsAvgMax: round1(Math.max(...group.twsAvg)),
|
|
517
|
+
twsPeakMax: round1(Math.max(...group.twsPeak)),
|
|
518
|
+
twaMin: round1(Math.min(...group.twa)),
|
|
519
|
+
twaMean: round1(mean(group.twa)),
|
|
520
|
+
twaMax: round1(Math.max(...group.twa)),
|
|
521
|
+
}))
|
|
522
|
+
.sort((a, b) => a.twsAvgMean - b.twsAvgMean);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
module.exports = {
|
|
526
|
+
WINDOW_MINUTES,
|
|
527
|
+
WIND_PATHS,
|
|
528
|
+
HISTORY_RESOLUTION_SECONDS,
|
|
529
|
+
MS_TO_KNOTS,
|
|
530
|
+
parseUtcTimestamp,
|
|
531
|
+
foldTwaDegrees,
|
|
532
|
+
circularMeanDegrees,
|
|
533
|
+
createLogbookWindStats,
|
|
534
|
+
createHistoryWindStats,
|
|
535
|
+
queryHistory,
|
|
536
|
+
extractWindWindowStats,
|
|
537
|
+
applySailEvent,
|
|
538
|
+
backfillSailEvents,
|
|
539
|
+
summarizeSailUsage,
|
|
540
|
+
};
|