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