@meri-imperiumi/signalk-dead-reckoning 0.13.1 → 0.13.2
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/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/plugin/db.js +27 -4
- package/plugin/engine.js +19 -2
- package/plugin/index.js +45 -4
- package/tests/db.test.js +46 -0
- package/tests/engine.test.js +35 -0
- package/tests/plugin.test.js +29 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.2] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **The shadow boat no longer sails away while anchored.** `engine.tick()` integrated its displacement unconditionally — the `underway` flag from `navigation.state` only gated the uncertainty cone's clock, never the position integration. A month on the hook therefore painted a fictional eastward passage across the chart: the resolved current vector (tier 3 Weather API keeps reporting a set/drift at the anchor position) was integrated every tick as ground displacement, and the paddlewheel on a stopped hull reads the water streaming past it as STW, integrated along the swinging bow. The anchor holds the ground position, so a moored/anchored tick now freezes the shadow boat's position and the logs entirely — water-track vector, current vector, `logNm`/`tripLogNm`/`logNmSinceOrigin` all hold — while the wall-clock `elapsedSinceOriginS` keeps counting the real stay and the engine stays warm for an instant OVERRIDE handoff. Sailing off the hook resumes integration from the held position with no snap needed.
|
|
14
|
+
- **Resource guards against degenerate stored config, and lighter per-tick database work** (found auditing a boat install whose server event loop stalled 10–74 s at a time — the DR plugin measured innocent, but its config surface allowed amplifying exactly such a stall): a hand-edited stored config bypasses the admin UI's schema validation, so `start()` now clamps `tickIntervalMs` to ≥ 10 ms (a 0/negative/NaN value would degenerate `setInterval` into a 1 ms storm multiplying the whole tick path) and `saveIntervalMs` to ≥ 1 s, and the ground-track ring capacity — sized as hours ÷ tick — is hard-capped at 1 M samples (~50 MB; unreachable at the default 1 s tick). The schema itself gains `minimum`/`maximum` for both intervals. The per-tick deviation-rate query no longer recompiles its SQL every second (statement memoized per connection), and the database opens with WAL's recommended `synchronous = NORMAL` — commit fsyncs on SD-card boat hardware drop from one per write to checkpoint time only, for state that is all reconstructable.
|
|
15
|
+
|
|
10
16
|
## [0.13.1] - 2026-10-05
|
|
11
17
|
|
|
12
18
|
### Fixed
|
package/package.json
CHANGED
package/plugin/db.js
CHANGED
|
@@ -198,6 +198,14 @@ function openDatabase(dbPath) {
|
|
|
198
198
|
mkdirSync(dirname(dbPath), { recursive: true });
|
|
199
199
|
const db = new DatabaseSync(dbPath);
|
|
200
200
|
db.exec("PRAGMA journal_mode = WAL");
|
|
201
|
+
// WAL's recommended durability level: commits sync the WAL to the OS
|
|
202
|
+
// but do not fsync per commit — a power loss may lose the last
|
|
203
|
+
// transactions but never corrupts the database. Everything this store
|
|
204
|
+
// holds is reconstructable (re-derivable from fixes, or a running
|
|
205
|
+
// average that self-corrects), so trading the per-commit fsync —
|
|
206
|
+
// sustained minute after minute on SD-card boat hardware — for that
|
|
207
|
+
// small window is the right default. FULL would fsync every commit.
|
|
208
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
201
209
|
db.exec("PRAGMA foreign_keys = ON");
|
|
202
210
|
for (const stmt of SCHEMA_DDL) {
|
|
203
211
|
db.exec(stmt);
|
|
@@ -557,6 +565,17 @@ function attachObservationsToFix(db, fixId, ids) {
|
|
|
557
565
|
for (const id of ids?.cplIds ?? []) cpl.run(fixId, id);
|
|
558
566
|
}
|
|
559
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Per-connection cache of prepared statements (WeakMap, so a closed db
|
|
570
|
+
* is collected with its statements). Statement compilation is not free,
|
|
571
|
+
* and `getDeviationRateStats` runs on the 1 Hz tick path — re-preparing
|
|
572
|
+
* the same SQL every second would pay the compile over and over for the
|
|
573
|
+
* life of the process.
|
|
574
|
+
*
|
|
575
|
+
* @type {WeakMap<import("node:sqlite").DatabaseSync, Record<string, import("node:sqlite").StatementSync>>}
|
|
576
|
+
*/
|
|
577
|
+
const preparedStatements = new WeakMap();
|
|
578
|
+
|
|
560
579
|
/**
|
|
561
580
|
* Reads recent `dr_corrections` rows for the uncertainty polygon's
|
|
562
581
|
* empirical deviation-rate (SPEC §8). Returns rows newest-first so the
|
|
@@ -571,8 +590,12 @@ function attachObservationsToFix(db, fixId, ids) {
|
|
|
571
590
|
* @returns {Array<{deviation_nm: number, dr_elapsed_seconds: number}>}
|
|
572
591
|
*/
|
|
573
592
|
function getDeviationRateStats(db, q = {}) {
|
|
574
|
-
|
|
575
|
-
|
|
593
|
+
let stmts = preparedStatements.get(db);
|
|
594
|
+
if (!stmts) {
|
|
595
|
+
stmts = {};
|
|
596
|
+
preparedStatements.set(db, stmts);
|
|
597
|
+
}
|
|
598
|
+
stmts.deviationRate ??= db.prepare(
|
|
576
599
|
`SELECT deviation_nm, dr_elapsed_seconds
|
|
577
600
|
FROM dr_corrections
|
|
578
601
|
WHERE (? IS NULL OR sail_state = ?)
|
|
@@ -580,12 +603,12 @@ function getDeviationRateStats(db, q = {}) {
|
|
|
580
603
|
ORDER BY correction_id DESC
|
|
581
604
|
LIMIT ?`,
|
|
582
605
|
);
|
|
583
|
-
return
|
|
606
|
+
return stmts.deviationRate.all(
|
|
584
607
|
q.sail_state ?? null,
|
|
585
608
|
q.sail_state ?? null,
|
|
586
609
|
q.sea_state ?? null,
|
|
587
610
|
q.sea_state ?? null,
|
|
588
|
-
limit,
|
|
611
|
+
q.limit ?? 50,
|
|
589
612
|
);
|
|
590
613
|
}
|
|
591
614
|
|
package/plugin/engine.js
CHANGED
|
@@ -129,13 +129,30 @@ class DeadReckoningEngine {
|
|
|
129
129
|
* @param {number} inputs.speedLoss - learned speed-loss fraction [0,1]
|
|
130
130
|
* @param {{setTrue: number, drift: number}} [inputs.current] - resolved current (deg true, kn)
|
|
131
131
|
* @param {boolean} [inputs.underway=true] - whether the vessel is under
|
|
132
|
-
* way this tick (from `navigation.state`);
|
|
133
|
-
* the
|
|
132
|
+
* way this tick (from `navigation.state`); false (moored/anchored)
|
|
133
|
+
* freezes the shadow boat's position — the anchor holds the ground
|
|
134
|
+
* position, so neither the water-track vector nor the resolved
|
|
135
|
+
* current vector may integrate — and gates the under-way clock the
|
|
136
|
+
* uncertainty cone grows on. The wall-clock `elapsedSinceOriginS`
|
|
137
|
+
* still advances: time since the last fix really did elapse.
|
|
134
138
|
* @param {number} [inputs.dtS=1] - tick interval in seconds
|
|
135
139
|
* @returns {{latitude: number, longitude: number}|null} new position, or null if no origin
|
|
136
140
|
*/
|
|
137
141
|
tick(inputs, dtS = 1) {
|
|
138
142
|
if (!this.origin) return null;
|
|
143
|
+
// §7.1–2: moored/anchored is a different regime. The anchor holds the
|
|
144
|
+
// ground position, so the shadow boat must not sail: neither the
|
|
145
|
+
// water-track vector (a paddlewheel on a stopped hull reads the water
|
|
146
|
+
// streaming past it) nor the resolved current vector (a GRIB tier
|
|
147
|
+
// would otherwise walk the shadow boat across the chart at drift kn
|
|
148
|
+
// for the whole stay — a month on the hook painted a fictional
|
|
149
|
+
// passage east) may integrate. The engine stays warm — origin, logs
|
|
150
|
+
// and clocks carry — so an OVERRIDE handoff stays instant; only the
|
|
151
|
+
// displacement is frozen, exactly like the real boat.
|
|
152
|
+
if (inputs.underway === false) {
|
|
153
|
+
this.elapsedSinceOriginS += dtS;
|
|
154
|
+
return this.origin;
|
|
155
|
+
}
|
|
139
156
|
const stw = toNumber(inputs.stwKn);
|
|
140
157
|
const hdg = toNumber(inputs.headingTrueDeg);
|
|
141
158
|
if (stw == null || hdg == null) return this.origin;
|
package/plugin/index.js
CHANGED
|
@@ -740,11 +740,19 @@ module.exports = (app) => {
|
|
|
740
740
|
tickIntervalMs: {
|
|
741
741
|
type: "integer",
|
|
742
742
|
title: "DR Integration Interval (ms)",
|
|
743
|
+
// Floor guards the whole pipeline: the ground-track ring is
|
|
744
|
+
// sized as hours/tickInterval, so a small tick multiplies the
|
|
745
|
+
// ring's memory, the tick-path work and the flush cadence by
|
|
746
|
+
// the same factor (1 ms would allocate a ~130 M-sample ring).
|
|
747
|
+
// 500 ms (2 Hz) is the useful ceiling for a shadow boat.
|
|
748
|
+
minimum: 500,
|
|
749
|
+
maximum: 60000,
|
|
743
750
|
default: DEFAULT_CONFIG.tickIntervalMs,
|
|
744
751
|
},
|
|
745
752
|
saveIntervalMs: {
|
|
746
753
|
type: "integer",
|
|
747
754
|
title: "State Save Interval (ms)",
|
|
755
|
+
minimum: 5000,
|
|
748
756
|
default: DEFAULT_CONFIG.saveIntervalMs,
|
|
749
757
|
},
|
|
750
758
|
"publish.everyTicks": {
|
|
@@ -836,6 +844,31 @@ module.exports = (app) => {
|
|
|
836
844
|
// logbook write-through).
|
|
837
845
|
const opts = deflattenConfig(options);
|
|
838
846
|
config = { ...DEFAULT_CONFIG, ...opts };
|
|
847
|
+
// Defensive clamps: the admin UI enforces the schema minimums, but
|
|
848
|
+
// hand-edited or legacy stored configs skip validation, and a
|
|
849
|
+
// 0/negative/NaN tick would degenerate setInterval to a 1 ms storm
|
|
850
|
+
// that also multiplies the tick path and the flush cadence. The
|
|
851
|
+
// floors are deliberately permissive (10 ms — the test harness
|
|
852
|
+
// drives ticks at 20–100 ms); the UI schema is where the human
|
|
853
|
+
//-facing floor (500 ms) lives.
|
|
854
|
+
config.tickIntervalMs = Math.min(
|
|
855
|
+
60000,
|
|
856
|
+
Math.max(
|
|
857
|
+
10,
|
|
858
|
+
Math.round(
|
|
859
|
+
Number(config.tickIntervalMs) || DEFAULT_CONFIG.tickIntervalMs,
|
|
860
|
+
),
|
|
861
|
+
),
|
|
862
|
+
);
|
|
863
|
+
config.saveIntervalMs = Math.min(
|
|
864
|
+
24 * 3600 * 1000,
|
|
865
|
+
Math.max(
|
|
866
|
+
1000,
|
|
867
|
+
Math.round(
|
|
868
|
+
Number(config.saveIntervalMs) || DEFAULT_CONFIG.saveIntervalMs,
|
|
869
|
+
),
|
|
870
|
+
),
|
|
871
|
+
);
|
|
839
872
|
// Allow partial divergence overrides without losing the defaults.
|
|
840
873
|
config.divergence = {
|
|
841
874
|
...DEFAULT_CONFIG.divergence,
|
|
@@ -891,10 +924,18 @@ module.exports = (app) => {
|
|
|
891
924
|
tripLogNm: Number.isFinite(savedTripNm) ? savedTripNm : 0,
|
|
892
925
|
});
|
|
893
926
|
groundTrack = new deps.GroundTrack({
|
|
894
|
-
capacity: Math.
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
927
|
+
capacity: Math.min(
|
|
928
|
+
// Memory guard: the ring is sized as hours/tickInterval, so a
|
|
929
|
+
// fast stored tick would scale it without bound (1 ms → a
|
|
930
|
+
// ~130 M-sample ring). 1 M samples ≈ 50 MB is the hard
|
|
931
|
+
// ceiling; at the default 1 s tick it is never reached
|
|
932
|
+
// (168 h, the schema max, is 604,800).
|
|
933
|
+
1_000_000,
|
|
934
|
+
Math.max(
|
|
935
|
+
60,
|
|
936
|
+
Math.round(
|
|
937
|
+
(config.groundTrackHours * 3600 * 1000) / config.tickIntervalMs,
|
|
938
|
+
),
|
|
898
939
|
),
|
|
899
940
|
),
|
|
900
941
|
});
|
package/tests/db.test.js
CHANGED
|
@@ -378,6 +378,52 @@ test("getDeviationRateStats returns recent rows filtered by sail/sea state", asy
|
|
|
378
378
|
await rm(dir, { recursive: true, force: true });
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
+
test("getDeviationRateStats reuses its prepared statement per connection", async () => {
|
|
382
|
+
// The 1 Hz tick path calls this with the same SQL every second — the
|
|
383
|
+
// statement is memoized per database (WeakMap). Repeated calls with
|
|
384
|
+
// different bindings must stay correct, and a second connection must
|
|
385
|
+
// get its own statement, not share the first one's.
|
|
386
|
+
const dirs = await Promise.all([
|
|
387
|
+
mkdtemp(join(tmpdir(), "dr-stmt-a-")),
|
|
388
|
+
mkdtemp(join(tmpdir(), "dr-stmt-b-")),
|
|
389
|
+
]);
|
|
390
|
+
const dbs = dirs.map((dir) => openDatabase(join(dir, "t.sqlite")));
|
|
391
|
+
for (const db of dbs) {
|
|
392
|
+
const fid = recordFix(db, {
|
|
393
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
394
|
+
source_type: "gps",
|
|
395
|
+
latitude: 60,
|
|
396
|
+
longitude: 24,
|
|
397
|
+
resets_dr_origin: true,
|
|
398
|
+
});
|
|
399
|
+
recordCorrection(db, {
|
|
400
|
+
fix_id: fid,
|
|
401
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
402
|
+
dr_lat: 60.01,
|
|
403
|
+
dr_lon: 24.01,
|
|
404
|
+
fix_lat: 60,
|
|
405
|
+
fix_lon: 24,
|
|
406
|
+
deviation_nm: 0.4,
|
|
407
|
+
deviation_bearing: 200,
|
|
408
|
+
dr_elapsed_seconds: 1800,
|
|
409
|
+
sail_state: "sailing",
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
// Interleaved calls across both connections, changing bindings each time.
|
|
413
|
+
const a1 = getDeviationRateStats(dbs[0], { limit: 10 });
|
|
414
|
+
const b1 = getDeviationRateStats(dbs[1], { limit: 10 });
|
|
415
|
+
const a2 = getDeviationRateStats(dbs[0], { limit: 1 });
|
|
416
|
+
const b2 = getDeviationRateStats(dbs[1], { sail_state: "motoring" });
|
|
417
|
+
assert.strictEqual(a1.length, 1);
|
|
418
|
+
assert.strictEqual(b1.length, 1);
|
|
419
|
+
assert.strictEqual(a2.length, 1);
|
|
420
|
+
assert.strictEqual(b2.length, 0);
|
|
421
|
+
for (const [db, dir] of dbs.map((db, i) => [db, dirs[i]])) {
|
|
422
|
+
db.close();
|
|
423
|
+
await rm(dir, { recursive: true, force: true });
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
381
427
|
// --- Observation & fix CRUD (work doc #13 stage D) -------------------------
|
|
382
428
|
|
|
383
429
|
const {
|
package/tests/engine.test.js
CHANGED
|
@@ -39,6 +39,41 @@ test("under-way clock only advances while under way", () => {
|
|
|
39
39
|
assert.strictEqual(e.underwaySinceOriginS, 0);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
test("anchored tick holds the shadow boat on the anchor", () => {
|
|
43
|
+
const e = new DeadReckoningEngine();
|
|
44
|
+
e.snapToFix({ latitude: -18.65, longitude: 174.0 });
|
|
45
|
+
// A month on the hook: sensors on a stopped hull still read — the
|
|
46
|
+
// paddlewheel sees the current streaming past, the resolver hands back
|
|
47
|
+
// a GRIB current — but the anchor holds the ground position, so none
|
|
48
|
+
// of it may move the shadow boat or grow the logs.
|
|
49
|
+
const before = { ...e };
|
|
50
|
+
for (let day = 0; day < 30; day++) {
|
|
51
|
+
const pos = e.tick(
|
|
52
|
+
{
|
|
53
|
+
stwKn: 0.8,
|
|
54
|
+
headingTrueDeg: 90,
|
|
55
|
+
leewayDeg: 5,
|
|
56
|
+
current: { setTrue: 90, drift: 0.4 },
|
|
57
|
+
underway: false,
|
|
58
|
+
},
|
|
59
|
+
24 * 3600,
|
|
60
|
+
);
|
|
61
|
+
assert.deepStrictEqual(pos, before.origin);
|
|
62
|
+
}
|
|
63
|
+
assert.deepStrictEqual(e.origin, before.origin);
|
|
64
|
+
assert.strictEqual(e.logNm, before.logNm);
|
|
65
|
+
assert.strictEqual(e.tripLogNm, before.tripLogNm);
|
|
66
|
+
assert.strictEqual(e.logNmSinceOrigin, before.logNmSinceOrigin);
|
|
67
|
+
// The under-way clock — the cone's growth axis — stays frozen, while
|
|
68
|
+
// the wall-clock "since fix" keeps counting the real stay.
|
|
69
|
+
assert.strictEqual(e.underwaySinceOriginS, 0);
|
|
70
|
+
assert.strictEqual(e.elapsedSinceOriginS, 30 * 24 * 3600);
|
|
71
|
+
// The engine stays warm: sailing off the hook resumes integration
|
|
72
|
+
// from the held position with no snap needed.
|
|
73
|
+
const pos = e.tick({ stwKn: 5, headingTrueDeg: 0, underway: true }, 3600);
|
|
74
|
+
assert.ok(Math.abs(pos.latitude - (before.origin.latitude + 5 / 60)) < 1e-4);
|
|
75
|
+
});
|
|
76
|
+
|
|
42
77
|
test("tick advances north along a meridian at the given STW", () => {
|
|
43
78
|
const e = new DeadReckoningEngine();
|
|
44
79
|
e.snapToFix({ latitude: 60, longitude: 24 });
|
package/tests/plugin.test.js
CHANGED
|
@@ -44,14 +44,14 @@ test.after(async () => {
|
|
|
44
44
|
*
|
|
45
45
|
* @returns {{app: FakeSignalKApp, plugin: object, router: FakeRouter}}
|
|
46
46
|
*/
|
|
47
|
-
function makeStarted() {
|
|
47
|
+
function makeStarted(options) {
|
|
48
48
|
const app = new FakeSignalKApp();
|
|
49
49
|
// Fresh data dir per call: the speed-plausibility gate and restart
|
|
50
50
|
// persistence make cross-test DB leakage (a stale restored DR origin)
|
|
51
51
|
// observable, so each test owns its database.
|
|
52
52
|
app.dataPath = mkdtempSync(join(tmpdir(), "dr-plugin-"));
|
|
53
53
|
const plugin = makePlugin(app);
|
|
54
|
-
plugin.start({});
|
|
54
|
+
plugin.start(options ?? {});
|
|
55
55
|
const router = new FakeRouter();
|
|
56
56
|
plugin.registerWithRouter(router);
|
|
57
57
|
return { app, plugin, router };
|
|
@@ -2843,6 +2843,33 @@ test("app.get config endpoint serves the plugin config with a hash", () => {
|
|
|
2843
2843
|
plugin.stop();
|
|
2844
2844
|
});
|
|
2845
2845
|
|
|
2846
|
+
test("insane intervals from stored config are clamped, ring capacity bounded", () => {
|
|
2847
|
+
// The admin UI enforces the schema minimums, but a hand-edited stored
|
|
2848
|
+
// config skips validation — a 1 ms tick would degenerate setInterval
|
|
2849
|
+
// into a 1 ms storm, and the ground-track ring (sized as hours ÷ tick)
|
|
2850
|
+
// would grow without bound. The floors are deliberately permissive
|
|
2851
|
+
// (the test harness itself drives ticks at 20–100 ms); the clamp only
|
|
2852
|
+
// stops the degenerate cases.
|
|
2853
|
+
const { app, plugin } = makeStarted({
|
|
2854
|
+
tickIntervalMs: 1,
|
|
2855
|
+
saveIntervalMs: 100,
|
|
2856
|
+
});
|
|
2857
|
+
const cfg = app.appRoutes.find((r) =>
|
|
2858
|
+
r.path.includes("/signalk-dead-reckoning/configuration"),
|
|
2859
|
+
);
|
|
2860
|
+
const res = {
|
|
2861
|
+
set() {},
|
|
2862
|
+
json(obj) {
|
|
2863
|
+
this._body = obj;
|
|
2864
|
+
},
|
|
2865
|
+
status() {},
|
|
2866
|
+
};
|
|
2867
|
+
cfg.handler({}, res);
|
|
2868
|
+
assert.strictEqual(res._body.config.tickIntervalMs, 10);
|
|
2869
|
+
assert.strictEqual(res._body.config.saveIntervalMs, 1000);
|
|
2870
|
+
plugin.stop();
|
|
2871
|
+
});
|
|
2872
|
+
|
|
2846
2873
|
test("schema exposes the positionFormat option with DMS default", () => {
|
|
2847
2874
|
const app = new FakeSignalKApp();
|
|
2848
2875
|
const plugin = makePlugin(app);
|