@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 +12 -0
- package/package.json +1 -1
- package/plugin/db.js +53 -5
- package/plugin/engine.js +19 -2
- package/plugin/index.js +45 -4
- package/tests/db.test.js +79 -0
- package/tests/engine.test.js +35 -0
- package/tests/plugin.test.js +29 -2
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
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 {
|
|
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
|
-
|
|
575
|
-
|
|
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
|
|
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`);
|
|
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
|
@@ -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 {
|
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);
|