@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
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
- const limit = q.limit ?? 50;
575
- const stmt = db.prepare(
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 stmt.all(
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`); gates the under-way clock
133
- * the uncertainty cone grows on, not the wall-clock one
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.max(
895
- 60,
896
- Math.round(
897
- (config.groundTrackHours * 3600 * 1000) / config.tickIntervalMs,
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 {
@@ -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 });
@@ -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);