@meri-imperiumi/signalk-dead-reckoning 0.13.1 → 0.13.3

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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.3] - 2026-10-15
11
+
12
+ ### Changed
13
+
14
+ - **The state flush no longer writes rows that did not change, and WAL checkpoint bursts are bounded** (`plugin/db.js`; found by file-activity profiling of a boat server, which showed the DR store's WAL parked at the 4 MB auto-checkpoint high-water and ~40 MB/day of flash writes at the 60 s flush cadence): `setState()` now reads the stored value and skips the write when it is identical — while anchored most flush scalars (`dr_log_nm`, `dr_trip_log_nm`, `dr_trip_start_ms`, `dr_log_since_origin`, `dr_underway_since_origin_s`, `dr_origin_error_nm`) are unchanged, so each flush previously dirtied a 4 kB WAL page per row for nothing; suppressing no-op upserts cuts the anchored flush to the rows that genuinely move (`last_known_good_fix`, whose fix timestamp advances, and `dr_elapsed_since_origin_s`, the wall-clock cone input). `updated_at` then stays at the value's last real change, which is the truthful reading of the column; nothing in the codebase reads it. The database also opens with `PRAGMA wal_autocheckpoint = 256` and `PRAGMA journal_size_limit = 1048576`: the default 1000-page (4 MB) threshold turned every WAL generation into a single ~4 MB copy-back burst on SD-card hardware, and the WAL file parked at its high-water size forever — the same bytes now flow as four 1 MB bursts and the file shrinks back between checkpoints.
15
+
16
+ ## [0.13.2] - 2026-10-11
17
+
18
+ ### Fixed
19
+ - **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.
20
+ - **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.
21
+
10
22
  ## [0.13.1] - 2026-10-05
11
23
 
12
24
  ### 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.3",
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,23 @@ 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");
209
+ // Bound the WAL's write bursts: the default auto-checkpoint threshold
210
+ // is 1000 pages (4 MB), so every WAL generation ends in a single ~4 MB
211
+ // copy back into the main database — a periodic flash-write spike on
212
+ // SD-card boat hardware. 256 pages (1 MB) spreads the same bytes over
213
+ // four smaller bursts, and journal_size_limit lets the WAL file shrink
214
+ // back to 1 MB after each checkpoint instead of parking forever at its
215
+ // high-water size (observed at ~4 MB on production stores).
216
+ db.exec("PRAGMA wal_autocheckpoint = 256");
217
+ db.exec("PRAGMA journal_size_limit = 1048576");
201
218
  db.exec("PRAGMA foreign_keys = ON");
202
219
  for (const stmt of SCHEMA_DDL) {
203
220
  db.exec(stmt);
@@ -343,17 +360,33 @@ function getState(db, key) {
343
360
  /**
344
361
  * Writes a scalar value to `dr_state_store`, upserting.
345
362
  *
363
+ * Identical values are not written: the periodic state flush rewrites
364
+ * the same scalars every cycle (log totals, origin error, trip state),
365
+ * and on SD-card boat hardware even a no-op upsert dirties a 4 kB page
366
+ * in the WAL, so suppressing unchanged writes is the single biggest
367
+ * flash-wear lever this store has. `updated_at` then stays at the
368
+ * value's last real change, which is the truthful reading of the
369
+ * column anyway. Column reads elsewhere only consume `value`.
370
+ *
346
371
  * @param {import("node:sqlite").DatabaseSync} db
347
372
  * @param {string} key
348
373
  * @param {string} value
349
- * @returns {void}
374
+ * @returns {boolean} True when a write happened, false when the stored
375
+ * value was already identical.
350
376
  */
351
377
  function setState(db, key, value) {
378
+ const existing = db
379
+ .prepare("SELECT value FROM dr_state_store WHERE key = ?")
380
+ .get(key);
381
+ if (existing && existing.value === value) {
382
+ return false;
383
+ }
352
384
  db.prepare(
353
385
  `INSERT INTO dr_state_store (key, value, updated_at)
354
386
  VALUES (?, ?, ?)
355
387
  ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
356
388
  ).run(key, value, new Date().toISOString());
389
+ return true;
357
390
  }
358
391
 
359
392
  /**
@@ -557,6 +590,17 @@ function attachObservationsToFix(db, fixId, ids) {
557
590
  for (const id of ids?.cplIds ?? []) cpl.run(fixId, id);
558
591
  }
559
592
 
593
+ /**
594
+ * Per-connection cache of prepared statements (WeakMap, so a closed db
595
+ * is collected with its statements). Statement compilation is not free,
596
+ * and `getDeviationRateStats` runs on the 1 Hz tick path — re-preparing
597
+ * the same SQL every second would pay the compile over and over for the
598
+ * life of the process.
599
+ *
600
+ * @type {WeakMap<import("node:sqlite").DatabaseSync, Record<string, import("node:sqlite").StatementSync>>}
601
+ */
602
+ const preparedStatements = new WeakMap();
603
+
560
604
  /**
561
605
  * Reads recent `dr_corrections` rows for the uncertainty polygon's
562
606
  * empirical deviation-rate (SPEC §8). Returns rows newest-first so the
@@ -571,8 +615,12 @@ function attachObservationsToFix(db, fixId, ids) {
571
615
  * @returns {Array<{deviation_nm: number, dr_elapsed_seconds: number}>}
572
616
  */
573
617
  function getDeviationRateStats(db, q = {}) {
574
- const limit = q.limit ?? 50;
575
- const stmt = db.prepare(
618
+ let stmts = preparedStatements.get(db);
619
+ if (!stmts) {
620
+ stmts = {};
621
+ preparedStatements.set(db, stmts);
622
+ }
623
+ stmts.deviationRate ??= db.prepare(
576
624
  `SELECT deviation_nm, dr_elapsed_seconds
577
625
  FROM dr_corrections
578
626
  WHERE (? IS NULL OR sail_state = ?)
@@ -580,12 +628,12 @@ function getDeviationRateStats(db, q = {}) {
580
628
  ORDER BY correction_id DESC
581
629
  LIMIT ?`,
582
630
  );
583
- return stmt.all(
631
+ return stmts.deviationRate.all(
584
632
  q.sail_state ?? null,
585
633
  q.sail_state ?? null,
586
634
  q.sea_state ?? null,
587
635
  q.sea_state ?? null,
588
- limit,
636
+ q.limit ?? 50,
589
637
  );
590
638
  }
591
639
 
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
@@ -243,6 +243,39 @@ test("setState upserts and getState reads back", () => {
243
243
  db.close();
244
244
  });
245
245
 
246
+ test("setState skips identical values (flash-wear guard)", () => {
247
+ const db = openDatabase(join(tempDir, "c.sqlite"));
248
+ assert.strictEqual(setState(db, "dr_log_nm", "123.45"), true);
249
+ const firstUpdatedAt = db
250
+ .prepare("SELECT updated_at FROM dr_state_store WHERE key = ?")
251
+ .get("dr_log_nm").updated_at;
252
+ // Identical write is suppressed: no updated_at churn, no WAL page dirtied
253
+ assert.strictEqual(setState(db, "dr_log_nm", "123.45"), false);
254
+ assert.strictEqual(
255
+ db
256
+ .prepare("SELECT updated_at FROM dr_state_store WHERE key = ?")
257
+ .get("dr_log_nm").updated_at,
258
+ firstUpdatedAt,
259
+ );
260
+ // A changed value still writes
261
+ assert.strictEqual(setState(db, "dr_log_nm", "124.00"), true);
262
+ assert.strictEqual(getState(db, "dr_log_nm"), "124.00");
263
+ db.close();
264
+ });
265
+
266
+ test("openDatabase bounds WAL checkpoint bursts and file high-water", () => {
267
+ const db = openDatabase(join(tempDir, "c.sqlite"));
268
+ assert.strictEqual(
269
+ db.prepare("PRAGMA wal_autocheckpoint").get()["wal_autocheckpoint"],
270
+ 256,
271
+ );
272
+ assert.strictEqual(
273
+ db.prepare("PRAGMA journal_size_limit").get()["journal_size_limit"],
274
+ 1048576,
275
+ );
276
+ db.close();
277
+ });
278
+
246
279
  test("dr_matrix_bins primary key enforces bin-level uniqueness", () => {
247
280
  const db = openDatabase(join(tempDir, "d.sqlite"));
248
281
  const ins = db.prepare(
@@ -378,6 +411,52 @@ test("getDeviationRateStats returns recent rows filtered by sail/sea state", asy
378
411
  await rm(dir, { recursive: true, force: true });
379
412
  });
380
413
 
414
+ test("getDeviationRateStats reuses its prepared statement per connection", async () => {
415
+ // The 1 Hz tick path calls this with the same SQL every second — the
416
+ // statement is memoized per database (WeakMap). Repeated calls with
417
+ // different bindings must stay correct, and a second connection must
418
+ // get its own statement, not share the first one's.
419
+ const dirs = await Promise.all([
420
+ mkdtemp(join(tmpdir(), "dr-stmt-a-")),
421
+ mkdtemp(join(tmpdir(), "dr-stmt-b-")),
422
+ ]);
423
+ const dbs = dirs.map((dir) => openDatabase(join(dir, "t.sqlite")));
424
+ for (const db of dbs) {
425
+ const fid = recordFix(db, {
426
+ timestamp: "2026-01-01T00:00:00Z",
427
+ source_type: "gps",
428
+ latitude: 60,
429
+ longitude: 24,
430
+ resets_dr_origin: true,
431
+ });
432
+ recordCorrection(db, {
433
+ fix_id: fid,
434
+ timestamp: "2026-01-01T00:00:00Z",
435
+ dr_lat: 60.01,
436
+ dr_lon: 24.01,
437
+ fix_lat: 60,
438
+ fix_lon: 24,
439
+ deviation_nm: 0.4,
440
+ deviation_bearing: 200,
441
+ dr_elapsed_seconds: 1800,
442
+ sail_state: "sailing",
443
+ });
444
+ }
445
+ // Interleaved calls across both connections, changing bindings each time.
446
+ const a1 = getDeviationRateStats(dbs[0], { limit: 10 });
447
+ const b1 = getDeviationRateStats(dbs[1], { limit: 10 });
448
+ const a2 = getDeviationRateStats(dbs[0], { limit: 1 });
449
+ const b2 = getDeviationRateStats(dbs[1], { sail_state: "motoring" });
450
+ assert.strictEqual(a1.length, 1);
451
+ assert.strictEqual(b1.length, 1);
452
+ assert.strictEqual(a2.length, 1);
453
+ assert.strictEqual(b2.length, 0);
454
+ for (const [db, dir] of dbs.map((db, i) => [db, dirs[i]])) {
455
+ db.close();
456
+ await rm(dir, { recursive: true, force: true });
457
+ }
458
+ });
459
+
381
460
  // --- Observation & fix CRUD (work doc #13 stage D) -------------------------
382
461
 
383
462
  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);