@meri-imperiumi/signalk-passage-briefing 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.editorconfig +5 -0
  2. package/.github/workflows/publish.yml +34 -0
  3. package/.github/workflows/signalk-ci.yml +11 -0
  4. package/.github/workflows/test.yml +21 -0
  5. package/CHANGELOG.md +423 -0
  6. package/README.md +86 -0
  7. package/SPEC.md +488 -0
  8. package/bin/backfill-report.js +218 -0
  9. package/bin/backtest-cli.js +132 -0
  10. package/biome.json +6 -0
  11. package/doc/here-brief.png +0 -0
  12. package/package.json +48 -0
  13. package/plugin/backtest.js +742 -0
  14. package/plugin/brief-ext.js +173 -0
  15. package/plugin/bulletin-engine.js +746 -0
  16. package/plugin/bulletin-source.js +150 -0
  17. package/plugin/celestial-source.js +417 -0
  18. package/plugin/fetch-engine.js +717 -0
  19. package/plugin/history-backfill.js +540 -0
  20. package/plugin/index.js +1479 -0
  21. package/plugin/logbook-source.js +463 -0
  22. package/plugin/notes-publisher.js +228 -0
  23. package/plugin/notes-store.js +241 -0
  24. package/plugin/raster-convert.js +168 -0
  25. package/plugin/sails-configuration.js +111 -0
  26. package/plugin/spool-watcher.js +218 -0
  27. package/plugin/sqlite-db.js +378 -0
  28. package/plugin/state-machine.js +249 -0
  29. package/plugin/statustilesexamples.js +101 -0
  30. package/plugin/synoptic-map.json +45 -0
  31. package/plugin/synoptic-source.js +227 -0
  32. package/plugin/zone-source.js +339 -0
  33. package/public/app.js +18 -0
  34. package/public/brief-ext-model.js +64 -0
  35. package/public/brief-ext-widget.html +14 -0
  36. package/public/brief-ext-widget.js +294 -0
  37. package/public/components/backfill-controls.js +100 -0
  38. package/public/components/comfort-info.js +94 -0
  39. package/public/components/conditions-here.js +234 -0
  40. package/public/components/horizon-sparkline.js +77 -0
  41. package/public/components/models.mjs +372 -0
  42. package/public/components/passage-outlook.js +368 -0
  43. package/public/components/sk-api.js +146 -0
  44. package/public/components/sk-base-css.js +177 -0
  45. package/public/components/strategic-outlook.js +248 -0
  46. package/public/components/synoptic-chart.js +48 -0
  47. package/public/components/tactical-dashboard.js +172 -0
  48. package/public/css/visuals.css +292 -0
  49. package/public/gmdss-zones-min.json +287 -0
  50. package/public/icon.png +0 -0
  51. package/public/index.html +13 -0
  52. package/public/polar.mjs +303 -0
  53. package/public/route-sim.mjs +750 -0
  54. package/public/sereno-physics.mjs +592 -0
  55. package/public/tack-gybe.js +174 -0
  56. package/public/vendor/plotterext-bus/LICENSE +21 -0
  57. package/public/vendor/plotterext-bus/README.md +17 -0
  58. package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
  59. package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
  60. package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
  61. package/public/vendor/plotterext-bus/extension.js +29 -0
  62. package/public/vendor/plotterext-bus/host.js +28 -0
  63. package/public/vendor/utif/LICENSE +21 -0
  64. package/public/vendor/utif/UTIF.js +1171 -0
  65. package/public/worker.js +35 -0
  66. package/status-tiles-examples.json +55 -0
  67. package/tests/backtest.test.js +461 -0
  68. package/tests/brief-ext.test.js +194 -0
  69. package/tests/bulletin-engine.test.js +361 -0
  70. package/tests/celestial-source.test.js +265 -0
  71. package/tests/fetch-engine.test.js +297 -0
  72. package/tests/history-backfill.test.js +341 -0
  73. package/tests/logbook-source.test.js +324 -0
  74. package/tests/notes-publisher.test.js +161 -0
  75. package/tests/notes-store.test.js +138 -0
  76. package/tests/openmeteo-mock.js +100 -0
  77. package/tests/plugin.test.js +1344 -0
  78. package/tests/route-sim.test.js +533 -0
  79. package/tests/sereno-physics.test.js +333 -0
  80. package/tests/sqlite-db.test.js +164 -0
  81. package/tests/state-machine.test.js +182 -0
  82. package/tests/statustilesexamples.test.js +86 -0
  83. package/tests/synoptic-source.test.js +195 -0
  84. package/tests/tack-gybe.test.js +211 -0
  85. package/tests/webapp.test.js +257 -0
  86. package/tests/zone-source.test.js +226 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * File spool watcher (SPEC §1.1/§2.2/§3.1): picks up METAREA text
3
+ * bulletins dropped into the spool directory by the on-board HF
4
+ * gateway (VARA HF / inReach fallback) and exposes the newest one to
5
+ * the briefing pipeline while the boat is offline.
6
+ *
7
+ * Spool contract: plain text files (`*.txt`, `*.emc`) in
8
+ * `spool_directory`. Each file is one bulletin; the first line is
9
+ * taken as the header, a date-time is parsed out of the text (ISO
10
+ * first, then the common `DDHHMMZ MON YYYY` NAVTEX style), and the
11
+ * file modification time is the last-resort issue time. The newest
12
+ * bulletin by issuedAt wins.
13
+ *
14
+ * `fs.watch` with a debounce rescan keeps this dependency-free; a
15
+ * missed event self-heals on the next rescan tick.
16
+ *
17
+ * Pure parsing plus one watcher — unit-testable with a temp dir.
18
+ *
19
+ * @file spool-watcher.js
20
+ */
21
+
22
+ const { readdir, readFile, stat } = require("node:fs/promises");
23
+ const { join } = require("node:path");
24
+
25
+ /** File extensions treated as bulletins. */
26
+ const BULLETIN_EXTENSIONS = new Set([".txt", ".emc", ".text"]);
27
+
28
+ /** Debounce window for the watch-triggered rescan (ms). */
29
+ const RESCAN_DEBOUNCE_MS = 500;
30
+
31
+ /** Keep at most this many parsed bulletins in memory. */
32
+ const KEEP_BULLETINS = 20;
33
+
34
+ /** Months for NAVTEX-style `DDHHMMZ MON YYYY` dates. */
35
+ const MONTHS = {
36
+ JAN: 0,
37
+ FEB: 1,
38
+ MAR: 2,
39
+ APR: 3,
40
+ MAY: 4,
41
+ JUN: 5,
42
+ JUL: 6,
43
+ AUG: 7,
44
+ SEP: 8,
45
+ OCT: 9,
46
+ NOV: 10,
47
+ DEC: 11,
48
+ };
49
+
50
+ /**
51
+ * Extracts an issue timestamp from bulletin text. Recognizes ISO
52
+ * 8601 and the maritime `DDHHMMZ MON YYYY` form (day, hour, minute,
53
+ * Zulu, month, year — e.g. `011200Z AUG 26`).
54
+ *
55
+ * @param {string} text
56
+ * @returns {string|null} ISO timestamp, or null when none found
57
+ */
58
+ function extractIssuedAt(text) {
59
+ const iso = text.match(/\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?Z?)/);
60
+ if (iso) {
61
+ const parsed = new Date(iso[0].replace(" ", "T"));
62
+ if (!Number.isNaN(parsed.getTime())) {
63
+ return parsed.toISOString();
64
+ }
65
+ }
66
+ const navtex = text.match(
67
+ /\b(\d{2})(\d{2})(\d{2})Z\s+([A-Z]{3})\s+(\d{2,4})\b/,
68
+ );
69
+ if (navtex) {
70
+ const [, day, hour, minute, mon, year] = navtex;
71
+ const month = MONTHS[mon];
72
+ if (month != null) {
73
+ const fullYear = Number(year) < 100 ? 2000 + Number(year) : Number(year);
74
+ const parsed = new Date(
75
+ Date.UTC(fullYear, month, Number(day), Number(hour), Number(minute)),
76
+ );
77
+ if (!Number.isNaN(parsed.getTime())) {
78
+ return parsed.toISOString();
79
+ }
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Parses one spool file into a bulletin (SPEC §3.1
87
+ * `metareaBulletin` shape).
88
+ *
89
+ * @param {string} filename - Basename (used when no header line)
90
+ * @param {string} text - Raw bulletin text
91
+ * @param {Date} mtime - File modification time
92
+ * @returns {{header: string, issuedAt: string, bulletinText: string}}
93
+ */
94
+ function parseBulletin(filename, text, mtime) {
95
+ const trimmed = text.trim();
96
+ const firstLine = trimmed.split(/\r?\n/, 1)[0]?.trim() ?? "";
97
+ const header = firstLine.length > 0 ? firstLine : filename;
98
+ const issuedAt = extractIssuedAt(trimmed) ?? mtime.toISOString();
99
+ return {
100
+ header,
101
+ issuedAt,
102
+ bulletinText: text,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Lists the bulletin files currently in the spool directory.
108
+ *
109
+ * @param {string} dir
110
+ * @returns {Promise<string[]>} Basenames, sorted
111
+ */
112
+ async function listBulletinFiles(dir) {
113
+ let entries = [];
114
+ try {
115
+ entries = await readdir(dir);
116
+ } catch {
117
+ return []; // Directory missing: no bulletins, not an error
118
+ }
119
+ return entries
120
+ .filter((name) =>
121
+ BULLETIN_EXTENSIONS.has(name.slice(name.lastIndexOf(".")).toLowerCase()),
122
+ )
123
+ .sort();
124
+ }
125
+
126
+ /**
127
+ * Reads and parses every bulletin file in the directory.
128
+ *
129
+ * @param {string} dir
130
+ * @returns {Promise<Array<{header: string, issuedAt: string,
131
+ * bulletinText: string, file: string}>>} Newest first
132
+ */
133
+ async function readBulletins(dir) {
134
+ const files = await listBulletinFiles(dir);
135
+ const bulletins = [];
136
+ for (const file of files) {
137
+ try {
138
+ const fullPath = join(dir, file);
139
+ const [text, stats] = await Promise.all([
140
+ readFile(fullPath, "utf8"),
141
+ stat(fullPath),
142
+ ]);
143
+ bulletins.push({
144
+ ...parseBulletin(file, text, stats.mtime),
145
+ file,
146
+ });
147
+ } catch {
148
+ // File vanished mid-scan or unreadable: skip it
149
+ }
150
+ }
151
+ bulletins.sort((a, b) => b.issuedAt.localeCompare(a.issuedAt));
152
+ return bulletins;
153
+ }
154
+
155
+ /**
156
+ * Creates the spool watcher. Reads the directory immediately, then
157
+ * re-reads on `fs.watch` events (debounced) and on demand via
158
+ * `refresh()`.
159
+ *
160
+ * @param {object} params
161
+ * @param {string} params.dir - Spool directory (may not exist yet)
162
+ * @param {Function} [params.onChange] - Called with the latest
163
+ * bulletin (or null) after every rescan that changed it
164
+ * @returns {{latest: () => object|null, all: () => Array, refresh:
165
+ * () => Promise<object|null>, close: () => void}}
166
+ */
167
+ function createSpoolWatcher({ dir, onChange }) {
168
+ let bulletins = [];
169
+ let watcher = null;
170
+ let debounce = null;
171
+ let lastNotified = null;
172
+
173
+ const rescan = async () => {
174
+ const next = await readBulletins(dir);
175
+ bulletins = next.slice(0, KEEP_BULLETINS);
176
+ const latest = bulletins[0] ?? null;
177
+ const changed =
178
+ (latest?.issuedAt ?? null) !== (lastNotified?.issuedAt ?? null) ||
179
+ (latest?.file ?? null) !== (lastNotified?.file ?? null);
180
+ if (changed) {
181
+ lastNotified = latest;
182
+ onChange?.(latest);
183
+ }
184
+ return latest;
185
+ };
186
+
187
+ try {
188
+ watcher = require("node:fs").watch(dir, () => {
189
+ clearTimeout(debounce);
190
+ debounce = setTimeout(rescan, RESCAN_DEBOUNCE_MS);
191
+ });
192
+ watcher.on("error", () => {
193
+ // Directory deleted while watched: keep polling on demand
194
+ });
195
+ } catch {
196
+ // Directory does not exist yet: no live watch; refresh() still works
197
+ }
198
+
199
+ return {
200
+ latest: () => bulletins[0] ?? null,
201
+ all: () => bulletins,
202
+ refresh: rescan,
203
+ close: () => {
204
+ clearTimeout(debounce);
205
+ watcher?.close();
206
+ },
207
+ };
208
+ }
209
+
210
+ module.exports = {
211
+ BULLETIN_EXTENSIONS,
212
+ RESCAN_DEBOUNCE_MS,
213
+ extractIssuedAt,
214
+ parseBulletin,
215
+ listBulletinFiles,
216
+ readBulletins,
217
+ createSpoolWatcher,
218
+ };
@@ -0,0 +1,378 @@
1
+ /**
2
+ * SQLite engine for the Passage Briefing plugin (SPEC §4.1).
3
+ *
4
+ * Stores the logbook sail events, the wind history cache extracted from
5
+ * the Signal K History API, and the learned sail preference matrix.
6
+ *
7
+ * Uses the `node:sqlite` builtin (Node >= 23.4, or Node >= 22.5 with the
8
+ * server started with `--experimental-sqlite`). `open()` fails fast with
9
+ * both remedies in the message when the builtin is missing.
10
+ *
11
+ * @file sqlite-db.js
12
+ */
13
+
14
+ /**
15
+ * TWS bin edges (knots) of the learned sail preference matrix
16
+ * (SPEC §3.2). A sample with TWS `v` belongs to bin
17
+ * `twsBinIndex(v)` — the index of the greatest edge `<= v`.
18
+ */
19
+ const TWS_BINS_KNOTS = [0, 5, 10, 15, 20, 25, 30, 35, 40];
20
+
21
+ /**
22
+ * TWA bin edges (degrees, 0..180) of the learned sail preference matrix
23
+ * (SPEC §3.2).
24
+ */
25
+ const TWA_BINS_DEGREES = [0, 30, 60, 90, 120, 150, 180];
26
+
27
+ /**
28
+ * Learning rate of the exponential moving average applied to matrix
29
+ * triggers (SPEC §4.2).
30
+ */
31
+ const EMA_ALPHA = 0.2;
32
+
33
+ /**
34
+ * Index of the bin edge a value belongs to: the greatest edge `<= value`,
35
+ * clamped to the last bin so values beyond the table stay representable.
36
+ *
37
+ * @param {number} value - Observed quantity (TWS knots or |TWA| degrees)
38
+ * @param {number[]} bins - Sorted ascending bin edges
39
+ * @returns {number} Bin index into `bins`
40
+ */
41
+ function binIndexFor(value, bins) {
42
+ let index = 0;
43
+ for (let i = 1; i < bins.length; i++) {
44
+ if (value >= bins[i]) {
45
+ index = i;
46
+ }
47
+ }
48
+ return index;
49
+ }
50
+
51
+ /**
52
+ * TWS bin index for a wind speed in knots.
53
+ *
54
+ * @param {number} knots
55
+ * @returns {number}
56
+ */
57
+ function twsBinIndex(knots) {
58
+ return binIndexFor(knots, TWS_BINS_KNOTS);
59
+ }
60
+
61
+ /**
62
+ * TWA bin index for a true wind angle in degrees (0..180).
63
+ *
64
+ * @param {number} degrees
65
+ * @returns {number}
66
+ */
67
+ function twaBinIndex(degrees) {
68
+ return binIndexFor(degrees, TWA_BINS_DEGREES);
69
+ }
70
+
71
+ /**
72
+ * Exponential moving average update of a learned trigger
73
+ * (SPEC §4.2): `next = (1 - alpha) * old + alpha * sample`.
74
+ *
75
+ * @param {number|null} old - Previous trigger value (null seeds directly)
76
+ * @param {number} sample - Observed value (e.g. TWS peak in knots)
77
+ * @param {number} [alpha] - Learning rate (default {@link EMA_ALPHA})
78
+ * @returns {number} Updated trigger
79
+ */
80
+ function emaUpdate(old, sample, alpha = EMA_ALPHA) {
81
+ if (old == null) {
82
+ return sample;
83
+ }
84
+ return (1 - alpha) * old + alpha * sample;
85
+ }
86
+
87
+ /**
88
+ * Loads the node:sqlite builtin or fails with an actionable message.
89
+ *
90
+ * @returns {{DatabaseSync: typeof import("node:sqlite").DatabaseSync}}
91
+ */
92
+ function loadSqlite() {
93
+ try {
94
+ return require("node:sqlite");
95
+ } catch (error) {
96
+ throw new Error(
97
+ `passage-briefing: SQLite storage requires the node:sqlite builtin ` +
98
+ `(Node >= 23.4, or Node >= 22.5 with the server started with ` +
99
+ `--experimental-sqlite): ${error.message}`,
100
+ );
101
+ }
102
+ }
103
+
104
+ /**
105
+ * A logbook sail event as recorded by the backfill engine.
106
+ *
107
+ * @typedef {object} SailEvent
108
+ * @property {string} timestamp - ISO-8601 instant of the event
109
+ * @property {"REEF_INCREASE"|"REEF_DECREASE"|"SAIL_CHANGE"} eventType
110
+ * @property {string} sailState - e.g. `REEF_1_GENOA`
111
+ * @property {boolean} [night] - Whether the event happened at night
112
+ * (sun below civil twilight)
113
+ * @property {string} [notes]
114
+ */
115
+
116
+ /**
117
+ * One learned matrix cell (SPEC §3.2).
118
+ *
119
+ * @typedef {object} MatrixBin
120
+ * @property {number} twsBin - TWS bin index
121
+ * @property {number} twaBin - TWA bin index
122
+ * @property {string} preferredSailState - e.g. `MAIN_REEF_1_GENOA_100`
123
+ * @property {number} avgTwsTrigger - EMA of observed TWS average (kn)
124
+ * @property {number} minTwsGustTrigger - EMA of observed TWS peak (kn)
125
+ * @property {number} samplesCount - Number of merged samples
126
+ */
127
+
128
+ /**
129
+ * Learned sail preference matrix (SPEC §3.2).
130
+ *
131
+ * @typedef {object} SailPreferenceMatrix
132
+ * @property {number[]} twsBinsKnots
133
+ * @property {number[]} twaBinsDegrees
134
+ * @property {MatrixBin[]} matrix
135
+ */
136
+
137
+ /**
138
+ * SQLite-backed store for sail events, wind history, and the learned
139
+ * sail preference matrix.
140
+ */
141
+ class PassageDatabase {
142
+ /**
143
+ * Opens (and initializes) the database inside `dataDir`.
144
+ *
145
+ * @param {string} dataDir - Plugin data directory
146
+ * (`app.getDataDirPath()`), created when missing
147
+ * @param {object} [options]
148
+ * @param {string} [options.filename] - Database file name
149
+ */
150
+ constructor(dataDir, { filename = "passage-outlook.sqlite" } = {}) {
151
+ const { mkdirSync } = require("node:fs");
152
+ const { join } = require("node:path");
153
+ mkdirSync(dataDir, { recursive: true });
154
+
155
+ const { DatabaseSync } = loadSqlite();
156
+ this.path = join(dataDir, filename);
157
+ this.db = new DatabaseSync(this.path);
158
+ this.db.exec("PRAGMA busy_timeout = 5000");
159
+ this.db.exec("PRAGMA journal_mode = WAL");
160
+ this.db.exec("PRAGMA synchronous = NORMAL");
161
+ this.migrate();
162
+ }
163
+
164
+ /**
165
+ * Creates the schema (SPEC §4.1) when missing.
166
+ */
167
+ migrate() {
168
+ this.db.exec(`
169
+ CREATE TABLE IF NOT EXISTS logbook_sail_events (
170
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
171
+ timestamp TEXT NOT NULL,
172
+ event_type TEXT NOT NULL,
173
+ sail_state TEXT NOT NULL,
174
+ night INTEGER NOT NULL DEFAULT 0,
175
+ notes TEXT
176
+ );
177
+
178
+ CREATE TABLE IF NOT EXISTS wind_history_cache (
179
+ timestamp TEXT PRIMARY KEY,
180
+ tws_avg REAL NOT NULL,
181
+ tws_peak REAL NOT NULL,
182
+ twa_avg REAL NOT NULL
183
+ );
184
+
185
+ CREATE TABLE IF NOT EXISTS learned_sail_matrix (
186
+ tws_bin INTEGER NOT NULL,
187
+ twa_bin INTEGER NOT NULL,
188
+ night INTEGER NOT NULL DEFAULT 0,
189
+ preferred_sail TEXT NOT NULL,
190
+ avg_tws_trigger REAL NOT NULL,
191
+ peak_gust_trigger REAL NOT NULL,
192
+ sample_count INTEGER NOT NULL,
193
+ PRIMARY KEY (tws_bin, twa_bin, night)
194
+ );
195
+ `);
196
+ }
197
+
198
+ /**
199
+ * Records a logbook sail event.
200
+ *
201
+ * @param {SailEvent} event
202
+ * @returns {number} Inserted row id
203
+ */
204
+ recordSailEvent({ timestamp, eventType, sailState, night = false, notes }) {
205
+ const result = this.db
206
+ .prepare(
207
+ `INSERT INTO logbook_sail_events (timestamp, event_type, sail_state, night, notes)
208
+ VALUES (?, ?, ?, ?, ?)`,
209
+ )
210
+ .run(timestamp, eventType, sailState, night ? 1 : 0, notes ?? null);
211
+ return Number(result.lastInsertRowid);
212
+ }
213
+
214
+ /**
215
+ * Lists recorded sail events, newest first.
216
+ *
217
+ * @param {object} [options]
218
+ * @param {number} [options.limit] - Maximum rows (default 100)
219
+ * @returns {Array<SailEvent & {id: number, night: boolean}>}
220
+ */
221
+ getSailEvents({ limit = 100 } = {}) {
222
+ return this.db
223
+ .prepare(
224
+ `SELECT id, timestamp, event_type AS eventType, sail_state AS sailState,
225
+ night, notes
226
+ FROM logbook_sail_events
227
+ ORDER BY timestamp DESC, id DESC
228
+ LIMIT ?`,
229
+ )
230
+ .all(limit)
231
+ .map((row) => ({ ...row, night: Boolean(row.night) }));
232
+ }
233
+
234
+ /**
235
+ * Inserts or replaces a wind history sample (SPEC §4.2 window stats).
236
+ *
237
+ * @param {object} sample
238
+ * @param {string} sample.timestamp - ISO-8601 instant (primary key)
239
+ * @param {number} sample.twsAvg - Mean true wind speed (kn)
240
+ * @param {number} sample.twsPeak - Peak true wind speed (kn)
241
+ * @param {number} sample.twaAvg - Circular mean true wind angle (deg)
242
+ */
243
+ upsertWindHistory({ timestamp, twsAvg, twsPeak, twaAvg }) {
244
+ this.db
245
+ .prepare(
246
+ `INSERT INTO wind_history_cache (timestamp, tws_avg, tws_peak, twa_avg)
247
+ VALUES (?, ?, ?, ?)
248
+ ON CONFLICT (timestamp) DO UPDATE SET
249
+ tws_avg = excluded.tws_avg,
250
+ tws_peak = excluded.tws_peak,
251
+ twa_avg = excluded.twa_avg`,
252
+ )
253
+ .run(timestamp, twsAvg, twsPeak, twaAvg);
254
+ }
255
+
256
+ /**
257
+ * Fetches cached wind history samples in a window.
258
+ *
259
+ * @param {string} from - ISO-8601 start (inclusive)
260
+ * @param {string} to - ISO-8601 end (inclusive)
261
+ * @returns {Array<{timestamp: string, twsAvg: number, twsPeak: number, twaAvg: number}>}
262
+ */
263
+ getWindHistory(from, to) {
264
+ return this.db
265
+ .prepare(
266
+ `SELECT timestamp, tws_avg AS twsAvg, tws_peak AS twsPeak, twa_avg AS twaAvg
267
+ FROM wind_history_cache
268
+ WHERE timestamp >= ? AND timestamp <= ?
269
+ ORDER BY timestamp ASC`,
270
+ )
271
+ .all(from, to);
272
+ }
273
+
274
+ /**
275
+ * Inserts or replaces a learned matrix cell. The day/night buckets
276
+ * stay separate: the crew reefs deeper at night than the conditions
277
+ * alone require (windvane steering, unseeable squalls), and that is
278
+ * a behavior worth keeping.
279
+ *
280
+ * @param {object} bin
281
+ * @param {number} bin.twsBin
282
+ * @param {number} bin.twaBin
283
+ * @param {boolean} [bin.night]
284
+ * @param {string} bin.preferredSail
285
+ * @param {number} bin.avgTwsTrigger
286
+ * @param {number} bin.peakGustTrigger
287
+ * @param {number} bin.sampleCount
288
+ */
289
+ upsertMatrixBin({
290
+ twsBin,
291
+ twaBin,
292
+ night = false,
293
+ preferredSail,
294
+ avgTwsTrigger,
295
+ peakGustTrigger,
296
+ sampleCount,
297
+ }) {
298
+ this.db
299
+ .prepare(
300
+ `INSERT INTO learned_sail_matrix
301
+ (tws_bin, twa_bin, night, preferred_sail, avg_tws_trigger, peak_gust_trigger, sample_count)
302
+ VALUES (?, ?, ?, ?, ?, ?, ?)
303
+ ON CONFLICT (tws_bin, twa_bin, night) DO UPDATE SET
304
+ preferred_sail = excluded.preferred_sail,
305
+ avg_tws_trigger = excluded.avg_tws_trigger,
306
+ peak_gust_trigger = excluded.peak_gust_trigger,
307
+ sample_count = excluded.sample_count`,
308
+ )
309
+ .run(
310
+ twsBin,
311
+ twaBin,
312
+ night ? 1 : 0,
313
+ preferredSail,
314
+ avgTwsTrigger,
315
+ peakGustTrigger,
316
+ sampleCount,
317
+ );
318
+ }
319
+
320
+ /**
321
+ * Raw learned matrix rows, ordered by bin coordinates.
322
+ *
323
+ * @returns {Array<{twsBin: number, twaBin: number, night: boolean, preferredSail: string, avgTwsTrigger: number, peakGustTrigger: number, sampleCount: number}>}
324
+ */
325
+ getMatrixBins() {
326
+ return this.db
327
+ .prepare(
328
+ `SELECT tws_bin AS twsBin, twa_bin AS twaBin, night,
329
+ preferred_sail AS preferredSail,
330
+ avg_tws_trigger AS avgTwsTrigger,
331
+ peak_gust_trigger AS peakGustTrigger,
332
+ sample_count AS sampleCount
333
+ FROM learned_sail_matrix
334
+ ORDER BY tws_bin ASC, twa_bin ASC, night ASC`,
335
+ )
336
+ .all()
337
+ .map((row) => ({ ...row, night: Boolean(row.night) }));
338
+ }
339
+
340
+ /**
341
+ * Learned sail preference matrix in the SPEC §3.2 shape.
342
+ *
343
+ * @returns {SailPreferenceMatrix}
344
+ */
345
+ getSailPreferenceMatrix() {
346
+ return {
347
+ twsBinsKnots: TWS_BINS_KNOTS,
348
+ twaBinsDegrees: TWA_BINS_DEGREES,
349
+ matrix: this.getMatrixBins().map((row) => ({
350
+ twsBin: row.twsBin,
351
+ twaBin: row.twaBin,
352
+ night: row.night,
353
+ preferredSailState: row.preferredSail,
354
+ minTwsGustTrigger: row.peakGustTrigger,
355
+ samplesCount: row.sampleCount,
356
+ })),
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Checkpoints the WAL and closes the database.
362
+ */
363
+ close() {
364
+ this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
365
+ this.db.close();
366
+ }
367
+ }
368
+
369
+ module.exports = {
370
+ PassageDatabase,
371
+ TWS_BINS_KNOTS,
372
+ TWA_BINS_DEGREES,
373
+ EMA_ALPHA,
374
+ binIndexFor,
375
+ twsBinIndex,
376
+ twaBinIndex,
377
+ emaUpdate,
378
+ };