@meri-imperiumi/signalk-dead-reckoning 0.1.0 → 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/CHANGELOG.md CHANGED
@@ -5,6 +5,68 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2026-08-27
9
+
10
+ ### Fixed
11
+ - **Dockside false positives while moored/anchored** — three alerts
12
+ that misfire on a tied-up boat are now suppressed in the moored/
13
+ anchored regime (consistent with the divergence monitor's existing
14
+ §7.1–2 suppression):
15
+ - *Divergence advisory* no longer raises on GPS wander at the dock:
16
+ the uncertainty radius previously collapsed to exactly 0.0 nm at
17
+ zero distance run, so a few metres of marina multipath "exceeded
18
+ expected uncertainty × 1.5" and sustained for 30 s raised
19
+ `DR-GPS divergence 0.00 nm exceeds expected 0.00 nm`. The radius
20
+ now floors at `MIN_RADIUS_NM` (0.02 nm ≈ 37 m, GNSS noise scale)
21
+ — DR error can never honestly be smaller than the GPS it was
22
+ seeded from.
23
+ - *Phantom "paddlewheel fouled" alert* — the fouling detector saw
24
+ STW≈0 (a moored paddlewheel's honest reading) plus wind on the
25
+ mast or GPS jitter and concluded the boat was making way. The
26
+ detector (and the whole training/matrix path, incl. auto tack
27
+ logging) is skipped while moored/anchored, and any earlier verdict
28
+ is withdrawn.
29
+ - *Sticky advisories* — a divergence advisory raised just before
30
+ mooring could never clear (the monitor isn't fed while suppressed).
31
+ It is now withdrawn with a `DR monitoring suspended while
32
+ moored/anchored` notification, and the divergence readout publishes
33
+ an explicit `null` while suppressed so the UI drops the stale
34
+ figure instead of showing a frozen `0.00 nm / 306°`.
35
+ - **Sensor-health alerts no longer flap** — the fouling and
36
+ idle-but-making-way verdicts are raw per-tick threshold comparisons
37
+ (STW vs 0.3 kn, wind vs 3 kn, SOG vs 1 kn); a sensor hovering at a
38
+ threshold toggled the alert on and off every tick. Both now pass
39
+ through a reusable symmetric hysteresis
40
+ (`plugin/hysteresis.js`, `sensorHealth.sustainS`/`clearS`, default
41
+ 10 s/10 s): a real fault still surfaces after the sustain window,
42
+ threshold hover raises nothing.
43
+ - **Water-track log survives restart** — `navigation.deadReckoning.log`
44
+ and the trip log were persisted every flush but never restored at
45
+ start, resetting the headline to 0.00 nm on every server restart
46
+ mid-passage. Both are now restored from the `dr_state_store`.
47
+ - **Map layers control usable again** — the divergence chip overlay sat
48
+ at the top-right corner on top of Leaflet's layers control (equal
49
+ z-index, later sibling wins), covering its toggle: broken-looking
50
+ styling and swallowed clicks meant selecting a chart provider did
51
+ nothing. The chip now lives at the bottom-right (attribution is off,
52
+ so the corner is free).
53
+ - The DR vs GPS headline no longer shows a bearing below display
54
+ resolution (`0.00 nm / 306°` → `0.00 nm`): the bearing of a
55
+ zero-length noise vector is meaningless.
56
+
57
+ ### Changed
58
+ - When the server has no charts configured, the online OpenStreetMap
59
+ fallback layer is offered in the layers control but no longer
60
+ auto-added to the map (it was documented as opt-in; the tile-less
61
+ offline-first plot is the default again until selected).
62
+
63
+ ### Added
64
+ - `plugin/hysteresis.js` — generic symmetric sustain/clear debounce for
65
+ boolean conditions, extracted from the divergence advisory's shape
66
+ and unit-tested; also used for the sensor-health alerts.
67
+ - Work doc #17 on the rngit board tracks this batch (dockside false
68
+ positives: divergence advisory, fouled paddlewheel, layers control).
69
+
8
70
  ## [0.1.0] - 2026-08-27
9
71
 
10
72
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Generic symmetric hysteresis for a boolean condition (sustained raise,
3
+ * sustained clear), extracted from the divergence advisory monitor's
4
+ * debounce shape so other watchkeeper alerts (§6.3 paddlewheel fouling,
5
+ * idle-but-making-way) can't flap when a sensor hovers at a threshold.
6
+ *
7
+ * The condition must persist for `sustainS` before the debounced flag
8
+ * goes true, and stay false for `clearS` before it drops — a single
9
+ * jittery tick neither raises nor clears.
10
+ *
11
+ * Pure logic over an explicit state object — unit-testable without
12
+ * Signal K plumbing.
13
+ *
14
+ * @file hysteresis.js
15
+ */
16
+
17
+ /**
18
+ * Default windows (seconds) for sensor-health style flags. Slower than
19
+ * the divergence advisory's 30 s — fouling and idle transitions are
20
+ * mechanical conditions that don't change meaningfully in a second, but
21
+ * fast enough that a real fault surfaces within a watch check.
22
+ */
23
+ const DEFAULT_SUSTAIN_S = 10;
24
+ const DEFAULT_CLEAR_S = 10;
25
+
26
+ /**
27
+ * Creates a fresh debounce state.
28
+ *
29
+ * @returns {{active: boolean, exceedS: number, insideS: number}}
30
+ */
31
+ function createFlagState() {
32
+ return {
33
+ /** debounced flag currently raised */
34
+ active: false,
35
+ /** sustained seconds of the raw condition accumulated */
36
+ exceedS: 0,
37
+ /** sustained seconds of the raw condition being absent */
38
+ insideS: 0,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Advances the debounce one tick.
44
+ *
45
+ * @param {object} state - from createFlagState(), mutated
46
+ * @param {boolean} raw - the undebounced per-tick condition
47
+ * @param {number} dtS - tick interval in seconds
48
+ * @param {object} [opts]
49
+ * @param {number} [opts.sustainS=DEFAULT_SUSTAIN_S]
50
+ * @param {number} [opts.clearS=DEFAULT_CLEAR_S]
51
+ * @returns {{active: boolean, transition: "raise"|"clear"|null}}
52
+ */
53
+ function flagTick(state, raw, dtS, opts = {}) {
54
+ const sustainS = opts.sustainS ?? DEFAULT_SUSTAIN_S;
55
+ const clearS = opts.clearS ?? DEFAULT_CLEAR_S;
56
+ let transition = null;
57
+
58
+ if (raw) {
59
+ state.insideS = 0;
60
+ state.exceedS += dtS;
61
+ if (!state.active && state.exceedS >= sustainS) {
62
+ state.active = true;
63
+ transition = "raise";
64
+ }
65
+ } else {
66
+ state.exceedS = 0;
67
+ state.insideS += dtS;
68
+ if (state.active && state.insideS >= clearS) {
69
+ state.active = false;
70
+ transition = "clear";
71
+ }
72
+ }
73
+
74
+ return { active: state.active, transition };
75
+ }
76
+
77
+ module.exports = {
78
+ DEFAULT_SUSTAIN_S,
79
+ DEFAULT_CLEAR_S,
80
+ createFlagState,
81
+ flagTick,
82
+ };
package/plugin/index.js CHANGED
@@ -70,6 +70,7 @@ const {
70
70
  createDivergenceState,
71
71
  divergenceTick,
72
72
  } = require("./divergence.js");
73
+ const { createFlagState, flagTick } = require("./hysteresis.js");
73
74
  const {
74
75
  composeFixEntry,
75
76
  composeTackEntry,
@@ -220,6 +221,17 @@ const DEFAULT_CONFIG = {
220
221
  sustainS: DEFAULT_SUSTAIN_S,
221
222
  clearS: DEFAULT_CLEAR_S,
222
223
  },
224
+ /**
225
+ * Sensor-health alert hysteresis (§6.3 fouling, idle-moving): the raw
226
+ * per-tick verdicts compare live sensors against fixed thresholds, so
227
+ * a gust or GPS wander hovering at a threshold would flap the alert
228
+ * on every tick. Both windows are symmetric — a real fault surfaces
229
+ * after sustainS, withdraws after clearS.
230
+ */
231
+ sensorHealth: {
232
+ sustainS: 10,
233
+ clearS: 10,
234
+ },
223
235
  logbook: {
224
236
  enabled: false,
225
237
  url: "http://localhost:3000/plugins/signalk-logbook/logs",
@@ -257,6 +269,8 @@ const deps = {
257
269
  computeRadius,
258
270
  createDivergenceState,
259
271
  divergenceTick,
272
+ createFlagState,
273
+ flagTick,
260
274
  composeFixEntry,
261
275
  composeTackEntry,
262
276
  composeObservationEntry,
@@ -383,6 +397,17 @@ module.exports = (app) => {
383
397
  /** @type {ReturnType<typeof createDivergenceState>|null} §7.3 divergence monitor */
384
398
  let divergence = null;
385
399
 
400
+ /**
401
+ * §6.3 sensor-health alert hysteresis: raw fouling / idle-moving
402
+ * verdicts debounce through these so threshold-hovering sensors
403
+ * can't flap notifications on every tick.
404
+ * @type {ReturnType<typeof createFlagState>|null}
405
+ */
406
+ let fouledFlag = null;
407
+
408
+ /** @type {ReturnType<typeof createFlagState>|null} */
409
+ let movingFlag = null;
410
+
386
411
  /** @type {object|null} §9.5 logbook write-through client */
387
412
  let logbook = null;
388
413
 
@@ -476,6 +501,10 @@ module.exports = (app) => {
476
501
  ...DEFAULT_CONFIG.divergence,
477
502
  ...(options?.divergence ?? {}),
478
503
  };
504
+ config.sensorHealth = {
505
+ ...DEFAULT_CONFIG.sensorHealth,
506
+ ...(options?.sensorHealth ?? {}),
507
+ };
479
508
  config.logbook = {
480
509
  ...DEFAULT_CONFIG.logbook,
481
510
  ...(options?.logbook ?? {}),
@@ -492,7 +521,15 @@ module.exports = (app) => {
492
521
  dbPath = join(app.getDataDirPath(), "dead-reckoning.sqlite");
493
522
  db = deps.openDatabase(dbPath);
494
523
  matrix = new deps.MatrixStore(db);
495
- engine = new deps.DeadReckoningEngine();
524
+ // Restart survival: the cumulative water-track logs are persisted
525
+ // every flush — restore them so the headline log doesn't reset to
526
+ // 0.00 on every server restart mid-passage.
527
+ const savedLogNm = Number(deps.getState(db, "dr_log_nm"));
528
+ const savedTripNm = Number(deps.getState(db, "dr_trip_log_nm"));
529
+ engine = new deps.DeadReckoningEngine({
530
+ logNm: Number.isFinite(savedLogNm) ? savedLogNm : 0,
531
+ tripLogNm: Number.isFinite(savedTripNm) ? savedTripNm : 0,
532
+ });
496
533
  groundTrack = new deps.GroundTrack({
497
534
  capacity: Math.max(
498
535
  60,
@@ -541,6 +578,8 @@ module.exports = (app) => {
541
578
  settleSustainS: config.training.settleSustainS,
542
579
  });
543
580
  divergence = deps.createDivergenceState();
581
+ fouledFlag = deps.createFlagState();
582
+ movingFlag = deps.createFlagState();
544
583
  clockS = 0;
545
584
 
546
585
  // Seed the engine origin from the last known good position, if any.
@@ -663,6 +702,8 @@ module.exports = (app) => {
663
702
  });
664
703
  }
665
704
  divergence = null;
705
+ fouledFlag = null;
706
+ movingFlag = null;
666
707
  if (sensorHealthIssue) {
667
708
  publishNotification(PATHS.sensorHealth, {
668
709
  state: "normal",
@@ -784,6 +825,12 @@ module.exports = (app) => {
784
825
  const aws = unwrapNumber(deltaState.get("environment.wind.speedApparent"));
785
826
  const heel = unwrapHeel(deltaState.get("navigation.attitude"));
786
827
  const gps = unwrapPosition(deltaState.get("navigation.position"));
828
+ // One underway verdict for the whole tick (§7.1–2: moored/anchored is
829
+ // a different regime — divergence advisories, fouling suspicion and
830
+ // training are all suppressed there). Unknown state is treated as
831
+ // underway: not every install runs an autostate source.
832
+ const navState = deltaState.get("navigation.state");
833
+ const underway = navState !== "anchored" && navState !== "moored";
787
834
 
788
835
  // GPS-derived motion (independent of the water-track sensors):
789
836
  // updated every tick, used to detect "idle but making way" below.
@@ -801,7 +848,6 @@ module.exports = (app) => {
801
848
  // Publish why DR is idle so the UI (SPEC §14.1) can explain the empty
802
849
  // readout instead of leaving the user to guess. The vessel isn't
803
850
  // necessarily broken — moored/anchored with no STW/heading is normal.
804
- const navState = deltaState.get("navigation.state");
805
851
  let reason;
806
852
  if (navState === "moored" || navState === "anchored") {
807
853
  reason = navState;
@@ -819,12 +865,25 @@ module.exports = (app) => {
819
865
  // polygon must keep growing — by the ground distance travelled with
820
866
  // no water track — or it falsely advertises confidence. GPS stays
821
867
  // authoritative until proven faulty or OVERRIDE (§7), so the
822
- // watchkeeper is alerted, not left guessing.
823
- const moving =
824
- gpsMotion.speedKn != null && gpsMotion.speedKn > SOG_MOVING_KN;
868
+ // watchkeeper is alerted, not left guessing. Moored/anchored is the
869
+ // honest-stopped regime — dockside GPS wander is not "making way".
870
+ // The alert itself is debounced (config.sensorHealth): GPS wander
871
+ // hovering at the threshold must not flap it on and off.
872
+ const movingRaw =
873
+ underway &&
874
+ gpsMotion.speedKn != null &&
875
+ gpsMotion.speedKn > SOG_MOVING_KN;
876
+ const moving = deps.flagTick(
877
+ movingFlag,
878
+ movingRaw,
879
+ config.tickIntervalMs / 1000,
880
+ config.sensorHealth,
881
+ ).active;
882
+ if (movingRaw) {
883
+ idleRunNm += (gpsMotion.speedKn * config.tickIntervalMs) / 3600000;
884
+ }
825
885
  let uncertaintyValue = null;
826
886
  if (moving) {
827
- idleRunNm += (gpsMotion.speedKn * config.tickIntervalMs) / 3600000;
828
887
  const u = deps.computeRadius({
829
888
  elapsedDistanceNm: engine.logNmSinceOrigin + idleRunNm,
830
889
  effectiveHitCount: 0, // unknown bin while idle — fallback growth
@@ -900,40 +959,57 @@ module.exports = (app) => {
900
959
  // When GPS is reliable and we're sailing (not motoring, paddlewheel
901
960
  // not fouled, not in a tack/gybe transient), compute the error vector
902
961
  // vs. GPS ground truth and EMA-merge it into the matching bin.
903
- const tr = deps.trainingTick(training, {
904
- timestampS: clockS,
905
- gps,
906
- stwKn: rawStw,
907
- headingTrueDeg,
908
- awaDeg,
909
- awsKn: aws,
910
- heelDeg: heel ?? 0,
911
- propulsionState: deltaState.get("propulsion.main.state"),
912
- current,
913
- lookupLeewayDeg: corrections.leeway_angle,
914
- lookupSpeedLoss: corrections.speed_loss,
915
- });
916
- if (tr.observation) {
917
- matrix.update(
918
- {
919
- sail_state: sailState,
920
- sea_state: seaState,
921
- stwKn: rawStw,
922
- awaDeg,
923
- heelDeg: heel ?? 0,
924
- },
925
- tr.observation,
926
- { source: "live" },
927
- );
962
+ // Moored/anchored is excluded entirely (§7.1–2 different regime):
963
+ // a tied-up boat has STW≈0 by design while wind blows on the mast
964
+ // and GPS wanders at the dock — running the trainer there would
965
+ // flag a phantom fouled paddlewheel, write GPS-jitter observations
966
+ // into the matrix, and log mooring-yaw wind shifts as fake tacks.
967
+ let tr = { transient: false, fouled: false };
968
+ if (underway) {
969
+ tr = deps.trainingTick(training, {
970
+ timestampS: clockS,
971
+ gps,
972
+ stwKn: rawStw,
973
+ headingTrueDeg,
974
+ awaDeg,
975
+ awsKn: aws,
976
+ heelDeg: heel ?? 0,
977
+ propulsionState: deltaState.get("propulsion.main.state"),
978
+ current,
979
+ lookupLeewayDeg: corrections.leeway_angle,
980
+ lookupSpeedLoss: corrections.speed_loss,
981
+ });
982
+ if (tr.observation) {
983
+ matrix.update(
984
+ {
985
+ sail_state: sailState,
986
+ sea_state: seaState,
987
+ stwKn: rawStw,
988
+ awaDeg,
989
+ heelDeg: heel ?? 0,
990
+ },
991
+ tr.observation,
992
+ { source: "live" },
993
+ );
994
+ }
928
995
  }
929
996
 
930
997
  // §6.3: the fouling detector's verdict (STW≈0 while SOG/wind say
931
998
  // the boat moves) is surfaced, not just used to gate training — a
932
999
  // fouled paddlewheel silently integrating 0 speed is the classic
933
- // way DR freezes while the vessel sails on.
1000
+ // way DR freezes while the vessel sails on. While moored/anchored
1001
+ // the detector doesn't run, so any earlier verdict is withdrawn.
1002
+ // Debounced through config.sensorHealth: a paddlewheel or gust
1003
+ // hovering at a threshold must not flap the alert on and off.
1004
+ const fouled = deps.flagTick(
1005
+ fouledFlag,
1006
+ Boolean(tr.fouled),
1007
+ config.tickIntervalMs / 1000,
1008
+ config.sensorHealth,
1009
+ ).active;
934
1010
  setSensorHealth(
935
- tr.fouled ? "fouled" : null,
936
- tr.fouled
1011
+ fouled ? "fouled" : null,
1012
+ fouled
937
1013
  ? "Paddlewheel appears fouled — STW≈0 while the vessel makes way. DR is integrating near-zero speed; verify position by other means"
938
1014
  : null,
939
1015
  );
@@ -942,10 +1018,12 @@ module.exports = (app) => {
942
1018
  // edge is a completed maneuver; classify from the AWA change across
943
1019
  // the window. Debounced so a beat's rapid back-to-back maneuvers
944
1020
  // (or a botched tack immediately redone) log as one event.
945
- const maneuver = deps.detectManeuver(training, {
946
- awaDeg,
947
- headingDeg: headingTrueDeg,
948
- });
1021
+ const maneuver = underway
1022
+ ? deps.detectManeuver(training, {
1023
+ awaDeg,
1024
+ headingDeg: headingTrueDeg,
1025
+ })
1026
+ : null;
949
1027
  if (maneuver) {
950
1028
  const now = Date.now();
951
1029
  const debounceMs = (config.logbook?.tackDebounceS ?? 120) * 1000;
@@ -984,9 +1062,11 @@ module.exports = (app) => {
984
1062
  // expected growth (§8), with sustained-interval hysteresis so a
985
1063
  // single jittery fix neither raises nor flaps the advisory.
986
1064
  // §14.1's live divergence readout (distance + bearing) is published
987
- // alongside. Suppressed at anchor/moored (different regime, §7.1–2).
988
- const navState = deltaState.get("navigation.state");
989
- const underway = navState !== "anchored" && navState !== "moored";
1065
+ // alongside. Suppressed at anchor/moored (different regime, §7.1–2):
1066
+ // while suppressed the monitor is not fed, an advisory raised just
1067
+ // before the transition is withdrawn (it can never clear on its own
1068
+ // otherwise), and the readout is nulled so the UI doesn't show a
1069
+ // stale figure from the last underway tick.
990
1070
  let dvg = null;
991
1071
  if (gps && underway) {
992
1072
  const distNm = deps.distanceNm(pos, gps);
@@ -1012,6 +1092,12 @@ module.exports = (app) => {
1012
1092
  message: `DR-GPS divergence back within expected uncertainty (${d.divergenceNm.toFixed(2)} nm)`,
1013
1093
  });
1014
1094
  }
1095
+ } else if (divergence.active) {
1096
+ divergence = deps.createDivergenceState();
1097
+ publishNotification(PATHS.divergenceAdvisory, {
1098
+ state: "normal",
1099
+ message: "DR monitoring suspended while moored/anchored",
1100
+ });
1015
1101
  }
1016
1102
 
1017
1103
  publish({
@@ -1033,10 +1119,12 @@ module.exports = (app) => {
1033
1119
  [PATHS.state]: {
1034
1120
  status: "underway",
1035
1121
  transient: tr.transient,
1036
- fouled: tr.fouled,
1122
+ fouled,
1037
1123
  },
1038
1124
  [PATHS.elapsedSinceFix]: engine.elapsedSinceOriginS,
1039
- ...(dvg ? { [PATHS.divergence]: dvg } : {}),
1125
+ // Always published: null while suppressed (moored/anchored or no
1126
+ // GPS) so consumers drop any stale underway figure.
1127
+ [PATHS.divergence]: dvg,
1040
1128
  });
1041
1129
  }
1042
1130
  }
@@ -33,6 +33,16 @@
33
33
  * @file uncertainty.js
34
34
  */
35
35
 
36
+ /**
37
+ * Minimum published radius (nm) ≈ 37 m: GNSS position noise at the
38
+ * dock. With zero distance run the model would otherwise report a
39
+ * point-exact DR position (radius 0), and any GPS wander — however
40
+ * small — then "exceeds expected uncertainty", firing the divergence
41
+ * advisory on a moored boat. DR error can never honestly be smaller
42
+ * than the GPS it was seeded from.
43
+ */
44
+ const MIN_RADIUS_NM = 0.02;
45
+
36
46
  /**
37
47
  * Conservative angular-error margin (degrees of DR error per nautical
38
48
  * mile run) used by the fallback regime. ~1° over a mile is a deliberately
@@ -166,7 +176,7 @@ function computeRadius(input) {
166
176
 
167
177
  const w = blendWeight(hits);
168
178
  const rate = w * empiricalRate + (1 - w) * fallbackRate;
169
- const radius_nm = rate * elapsed;
179
+ const radius_nm = Math.max(rate * elapsed, MIN_RADIUS_NM);
170
180
 
171
181
  let method;
172
182
  if (w <= 0) method = "fallback";
@@ -185,6 +195,7 @@ function computeRadius(input) {
185
195
 
186
196
  module.exports = {
187
197
  FALLBACK_DEG_PER_NM,
198
+ MIN_RADIUS_NM,
188
199
  MIN_HITS_FOR_EMPIRICAL,
189
200
  EWMA_N,
190
201
  EWMA_ALPHA,
@@ -41,10 +41,15 @@ class DrMapView extends HTMLElement {
41
41
  .map-host { position: relative; width: 100%; height: 100%; }
42
42
  .map-wrap { width: 100%; height: 100%; background: var(--bg-base, #080a0c); }
43
43
  /* Floating overlays: semi-transparent dark panels with sharp 1px
44
- borders so they stay legible over any tileset (UI spec §7). */
44
+ borders so they stay legible over any tileset (UI spec §7).
45
+ Bottom-right: the top-right corner belongs to Leaflet's layers
46
+ control — stacking the chip there covered the control's toggle
47
+ (same z-index, later sibling wins), which both looked broken and
48
+ swallowed the clicks that were meant to switch chart providers.
49
+ The bottom-right corner is free here: attribution is off. */
45
50
  .dr-chip {
46
51
  position: absolute;
47
- top: 8px;
52
+ bottom: 8px;
48
53
  right: 8px;
49
54
  z-index: 1000;
50
55
  display: flex;
@@ -216,17 +221,19 @@ class DrMapView extends HTMLElement {
216
221
  );
217
222
 
218
223
  // Basemap: Signal K configured charts (offline MBTiles / tile proxies)
219
- // when available, falling back to online OSM when the server has none
220
- // configured (route 404s or returns empty). The first available layer
221
- // is added to the map so the plot has context immediately; the rest
222
- // are registered in the layers control for switching.
224
+ // when available. The first *configured* chart is added to the map so
225
+ // the plot has context immediately; the rest are registered in the
226
+ // layers control for switching. When the server has no charts the
227
+ // online OSM fallback is offered in the control but NEVER auto-added
228
+ // (offline-first: the DR plot stays tile-less until the watchkeeper
229
+ // opts in — vm.DEFAULT_OSM_LAYER's documented contract).
223
230
  fetch("/signalk/v1/api/resources/charts")
224
231
  .then((r) => (r.ok ? r.json() : null))
225
232
  .then((resource) => {
226
- const charts = vm.chartLayersWithFallback(resource);
227
- if (charts.length === 0) return;
233
+ const configured = vm.parseChartLayers(resource);
234
+ const charts =
235
+ configured.length > 0 ? configured : [vm.DEFAULT_OSM_LAYER];
228
236
  const bases = {};
229
- let first = null;
230
237
  for (const c of charts) {
231
238
  const layer = L.tileLayer(c.url, {
232
239
  minZoom: c.minZoom,
@@ -235,10 +242,12 @@ class DrMapView extends HTMLElement {
235
242
  });
236
243
  this.tileLayers[c.identifier] = layer;
237
244
  bases[c.name] = layer;
238
- if (!first) first = layer;
239
245
  }
240
- first.addTo(this.map);
241
- if (Object.keys(bases).length > 1) {
246
+ if (configured.length > 0) {
247
+ // Auto-select only a real configured chart, never the fallback.
248
+ Object.values(bases)[0].addTo(this.map);
249
+ }
250
+ if (Object.keys(bases).length > 1 || configured.length === 0) {
242
251
  L.control.layers(bases, {}, { collapsed: true }).addTo(this.map);
243
252
  }
244
253
  })
@@ -396,6 +396,9 @@ export class Sparkline {
396
396
  */
397
397
  export function divergenceText(d) {
398
398
  if (!d || !Number.isFinite(d.distance_nm)) return "— nm";
399
+ // Below display resolution the bearing is the direction of noise on a
400
+ // zero-length vector — meaningless, don't show it.
401
+ if (d.distance_nm < 0.005) return `${d.distance_nm.toFixed(2)} nm`;
399
402
  const brg = String(Math.round(d.bearing_true)).padStart(3, "0");
400
403
  return `${d.distance_nm.toFixed(2)} nm / ${brg}°`;
401
404
  }
@@ -183,6 +183,20 @@ test("divergenceText: formats distance and zero-padded bearing", async () => {
183
183
  assert.strictEqual(vm.divergenceText(null), "— nm");
184
184
  });
185
185
 
186
+ test("divergenceText: no bearing below display resolution — it is the direction of noise", async () => {
187
+ const vm = await loadVm();
188
+ // A few metres of DR-GPS offset: bearing is meaningless, distance is
189
+ // honest at "0.00 nm".
190
+ assert.strictEqual(
191
+ vm.divergenceText({ distance_nm: 0.004, bearing_true: 248 }),
192
+ "0.00 nm",
193
+ );
194
+ assert.strictEqual(
195
+ vm.divergenceText({ distance_nm: 0.006, bearing_true: 97 }),
196
+ "0.01 nm / 097°",
197
+ );
198
+ });
199
+
186
200
  test("uncertaintySpec: passes radius/method with DR center", async () => {
187
201
  const vm = await loadVm();
188
202
  const u = vm.uncertaintySpec([60, 24], {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Tests for the generic flag hysteresis (sensor-health alert debouncing).
3
+ * @file hysteresis.test.js
4
+ */
5
+
6
+ const test = require("node:test");
7
+ const assert = require("node:assert/strict");
8
+
9
+ const { createFlagState, flagTick } = require("../plugin/hysteresis.js");
10
+
11
+ test("flagTick: raw condition must persist sustainS before raising", () => {
12
+ const st = createFlagState();
13
+ const opts = { sustainS: 10, clearS: 10 };
14
+ // 9 seconds of raw true — not yet.
15
+ for (let i = 0; i < 9; i++) {
16
+ assert.strictEqual(flagTick(st, true, 1, opts).transition, null);
17
+ assert.strictEqual(st.active, false);
18
+ }
19
+ // 10th second — raises.
20
+ const out = flagTick(st, true, 1, opts);
21
+ assert.strictEqual(out.transition, "raise");
22
+ assert.strictEqual(out.active, true);
23
+ });
24
+
25
+ test("flagTick: condition must stay false clearS before clearing", () => {
26
+ const st = createFlagState();
27
+ const opts = { sustainS: 1, clearS: 3 };
28
+ assert.strictEqual(flagTick(st, true, 1, opts).transition, "raise");
29
+ // 2 seconds of raw false — still active.
30
+ assert.strictEqual(flagTick(st, false, 1, opts).transition, null);
31
+ assert.strictEqual(st.active, true);
32
+ assert.strictEqual(flagTick(st, false, 1, opts).transition, null);
33
+ // 3rd second — clears.
34
+ const out = flagTick(st, false, 1, opts);
35
+ assert.strictEqual(out.transition, "clear");
36
+ assert.strictEqual(out.active, false);
37
+ });
38
+
39
+ test("flagTick: a jittery tick neither raises nor clears (no flapping)", () => {
40
+ const st = createFlagState();
41
+ const opts = { sustainS: 3, clearS: 3 };
42
+ // Raise first.
43
+ for (let i = 0; i < 3; i++) flagTick(st, true, 1, opts);
44
+ assert.strictEqual(st.active, true);
45
+ // Alternating raw true/false around a threshold: timers reset each
46
+ // side, so no clear transition ever fires and no re-raise flaps.
47
+ for (let i = 0; i < 20; i++) {
48
+ const out = flagTick(st, i % 2 === 0, 1, opts);
49
+ assert.strictEqual(out.transition, null);
50
+ }
51
+ assert.strictEqual(st.active, true);
52
+ });
53
+
54
+ test("flagTick: sub-second ticks accumulate fractionally", () => {
55
+ const st = createFlagState();
56
+ const opts = { sustainS: 0.3, clearS: 0.3 };
57
+ const a = flagTick(st, true, 0.1, opts);
58
+ const b = flagTick(st, true, 0.1, opts);
59
+ assert.strictEqual(a.transition, null);
60
+ assert.strictEqual(b.transition, null);
61
+ assert.strictEqual(flagTick(st, true, 0.1, opts).transition, "raise");
62
+ });
@@ -993,6 +993,21 @@ test("uncertainty polygon publishes fallback method with no correction history",
993
993
  test("uncertainty polygon radius grows monotonically with distance run", async () => {
994
994
  // Isolated DB so no leftover dr_corrections push it into empirical mode.
995
995
  const dir = await mkdtemp(join(tmpdir(), "dr-unc-grow-"));
996
+ // Pre-seed the restored per-excursion distance well above the GNSS
997
+ // noise floor (0.02 nm) so both samples measure growth, not the floor.
998
+ const seedDb = require("../plugin/db.js").openDatabase(
999
+ join(dir, "dead-reckoning.sqlite"),
1000
+ );
1001
+ const { setState } = require("../plugin/db.js");
1002
+ setState(seedDb, "dr_log_since_origin", "5");
1003
+ // Seed the origin too, or the first GPS delta snaps to it and resets
1004
+ // the excursion distance back to zero.
1005
+ setState(
1006
+ seedDb,
1007
+ "last_known_good_fix",
1008
+ JSON.stringify({ latitude: 60, longitude: 24 }),
1009
+ );
1010
+ seedDb.close();
996
1011
  const app = new FakeSignalKApp();
997
1012
  app.dataPath = dir;
998
1013
  const plugin = makePlugin(app);
@@ -1024,12 +1039,21 @@ test("uncertainty polygon radius grows monotonically with distance run", async (
1024
1039
  r2.radius_nm > r1.radius_nm,
1025
1040
  `radius should grow: r1=${r1.radius_nm} r2=${r2.radius_nm}`,
1026
1041
  );
1042
+ assert.ok(r1.radius_nm > 0.02, "seeded run should exceed the floor");
1027
1043
  plugin.stop();
1028
1044
  await rm(dir, { recursive: true, force: true });
1029
1045
  });
1030
1046
 
1031
1047
  test("uncertainty polygon tightens toward empirical after corrections + bin hits", async () => {
1032
1048
  const dir = await mkdtemp(join(tmpdir(), "dr-unc-emp-"));
1049
+ // Seed the restored per-excursion distance so both regimes measure
1050
+ // rates, not the GNSS-noise floor.
1051
+ const seedDb = require("../plugin/db.js").openDatabase(
1052
+ join(dir, "dead-reckoning.sqlite"),
1053
+ );
1054
+ const { setState } = require("../plugin/db.js");
1055
+ setState(seedDb, "dr_log_since_origin", "5");
1056
+ seedDb.close();
1033
1057
  const app = new FakeSignalKApp();
1034
1058
  app.dataPath = dir;
1035
1059
  const plugin = makePlugin(app);
@@ -1091,7 +1115,7 @@ test("uncertainty polygon tightens toward empirical after corrections + bin hits
1091
1115
  // radius at the same distance (the tightening signal).
1092
1116
  const { computeRadius } = require("../plugin/uncertainty.js");
1093
1117
  const fallbackOnly = computeRadius({
1094
- elapsedDistanceNm: 5 / 3600,
1118
+ elapsedDistanceNm: 5,
1095
1119
  effectiveHitCount: 0,
1096
1120
  deviationRows: [],
1097
1121
  stwKn: 5,
@@ -1106,6 +1130,20 @@ test("uncertainty polygon tightens toward empirical after corrections + bin hits
1106
1130
 
1107
1131
  test("uncertainty polygon radius drops after a snap-to-fix resets the excursion", async () => {
1108
1132
  const dir = await mkdtemp(join(tmpdir(), "dr-unc-snap-"));
1133
+ // Seed the restored per-excursion distance so `before` is well above
1134
+ // the GNSS-noise floor; after the snap it must drop to the floor.
1135
+ const seedDb = require("../plugin/db.js").openDatabase(
1136
+ join(dir, "dead-reckoning.sqlite"),
1137
+ );
1138
+ const { setState } = require("../plugin/db.js");
1139
+ setState(seedDb, "dr_log_since_origin", "10");
1140
+ // Seed the origin so the first GPS delta doesn't reset the excursion.
1141
+ setState(
1142
+ seedDb,
1143
+ "last_known_good_fix",
1144
+ JSON.stringify({ latitude: 60, longitude: 24 }),
1145
+ );
1146
+ seedDb.close();
1109
1147
  const app = new FakeSignalKApp();
1110
1148
  app.dataPath = dir;
1111
1149
  const plugin = makePlugin(app);
@@ -1174,8 +1212,10 @@ test("divergence advisory raises on sustained DR-GPS divergence and publishes th
1174
1212
  });
1175
1213
  const router = new FakeRouter();
1176
1214
  plugin.registerWithRouter(router);
1177
- // GPS held fixed at (60, 24) while the boat sails north at 6 kn from it
1178
- // — divergence grows far faster than the fallback polygon radius.
1215
+ // GPS held fixed at (60, 24) while the boat sails north at a
1216
+ // synthetic 120 kn from it — divergence must outrun the GNSS-noise
1217
+ // radius floor (0.02 nm) within the fast-hysteresis window; a real
1218
+ // 6 kn would take ~20 s to cross it.
1179
1219
  app.emitDelta({
1180
1220
  context: "vessels.self",
1181
1221
  updates: [
@@ -1185,13 +1225,13 @@ test("divergence advisory raises on sustained DR-GPS divergence and publishes th
1185
1225
  path: "navigation.position",
1186
1226
  value: { latitude: 60, longitude: 24 },
1187
1227
  },
1188
- { path: "navigation.speedThroughWater", value: 6 },
1228
+ { path: "navigation.speedThroughWater", value: 120 },
1189
1229
  { path: "navigation.headingTrue", value: 0 },
1190
1230
  ],
1191
1231
  },
1192
1232
  ],
1193
1233
  });
1194
- await new Promise((r) => setTimeout(r, 1000));
1234
+ await new Promise((r) => setTimeout(r, 2000));
1195
1235
  // Divergence readout published and growing.
1196
1236
  const dvg = latestDivergence(app);
1197
1237
  assert.ok(dvg.distance_nm > 0, "divergence readout should be non-zero");
@@ -1228,13 +1268,13 @@ test("divergence advisory clears after a confirmed fix snaps DR back to GPS", as
1228
1268
  path: "navigation.position",
1229
1269
  value: { latitude: 60, longitude: 24 },
1230
1270
  },
1231
- { path: "navigation.speedThroughWater", value: 6 },
1271
+ { path: "navigation.speedThroughWater", value: 120 },
1232
1272
  { path: "navigation.headingTrue", value: 0 },
1233
1273
  ],
1234
1274
  },
1235
1275
  ],
1236
1276
  });
1237
- await new Promise((r) => setTimeout(r, 900));
1277
+ await new Promise((r) => setTimeout(r, 2000));
1238
1278
  const raised = latestNotification(
1239
1279
  app,
1240
1280
  "notifications.navigation.deadReckoning.divergenceAdvisory",
@@ -1292,27 +1332,264 @@ test("divergence monitor is suppressed at anchor", async () => {
1292
1332
  ],
1293
1333
  });
1294
1334
  await new Promise((r) => setTimeout(r, 900));
1335
+ // Suppression now means an explicit null readout (so connected UIs drop
1336
+ // any stale underway figure), not silence.
1295
1337
  const vals = app.handledMessages
1296
1338
  .flatMap((m) => m.message?.updates ?? [])
1297
1339
  .flatMap((u) => u.values ?? [])
1298
1340
  .filter((v) => v.path === "navigation.deadReckoning.divergence");
1341
+ assert.ok(vals.length > 0, "null divergence readout should be published");
1299
1342
  assert.strictEqual(
1300
- vals.length,
1301
- 0,
1302
- "divergence readout should be suppressed at anchor",
1343
+ vals[vals.length - 1].value,
1344
+ null,
1345
+ "divergence readout should be null at anchor",
1303
1346
  );
1304
1347
  const notif = app.handledMessages
1305
1348
  .flatMap((m) => m.message?.updates ?? [])
1306
1349
  .flatMap((u) => u.values ?? [])
1307
1350
  .find(
1308
1351
  (v) =>
1309
- v.path === "notifications.navigation.deadReckoning.divergenceAdvisory",
1352
+ v.path ===
1353
+ "notifications.navigation.deadReckoning.divergenceAdvisory" &&
1354
+ v.value.state === "alert",
1310
1355
  );
1311
1356
  assert.ok(!notif, "advisory should not fire at anchor");
1312
1357
  plugin.stop();
1313
1358
  await rm(dir, { recursive: true, force: true });
1314
1359
  });
1315
1360
 
1361
+ test("moored with wind: no fouled paddlewheel alert, advisory withdrawn, null divergence", async () => {
1362
+ const dir = await mkdtemp(join(tmpdir(), "dr-moored-fouled-"));
1363
+ const app = new FakeSignalKApp();
1364
+ app.dataPath = dir;
1365
+ const plugin = makePlugin(app);
1366
+ plugin.start({
1367
+ tickIntervalMs: 100,
1368
+ divergence: { sustainS: 0.3, clearS: 0.3 },
1369
+ });
1370
+ const router = new FakeRouter();
1371
+ plugin.registerWithRouter(router);
1372
+
1373
+ // Phase 1 — underway, sailing away from a fixed GPS at a synthetic
1374
+ // 120 kn (divergence outruns the 0.02 nm radius floor quickly):
1375
+ // advisory raises.
1376
+ app.emitDelta({
1377
+ context: "vessels.self",
1378
+ updates: [
1379
+ {
1380
+ values: [
1381
+ {
1382
+ path: "navigation.position",
1383
+ value: { latitude: 60, longitude: 24 },
1384
+ },
1385
+ { path: "navigation.speedThroughWater", value: 120 },
1386
+ { path: "navigation.headingTrue", value: 0 },
1387
+ ],
1388
+ },
1389
+ ],
1390
+ });
1391
+ await new Promise((r) => setTimeout(r, 2000));
1392
+ assert.strictEqual(
1393
+ latestNotification(
1394
+ app,
1395
+ "notifications.navigation.deadReckoning.divergenceAdvisory",
1396
+ ).state,
1397
+ "alert",
1398
+ );
1399
+
1400
+ // Phase 2 — tie up: STW reads 0 (a moored paddlewheel's honest value),
1401
+ // wind blows 12 kn on the mast, GPS wanders a few metres. The old
1402
+ // behaviour: phantom "paddlewheel fouled" alert + stuck advisory.
1403
+ app.emitDelta({
1404
+ context: "vessels.self",
1405
+ updates: [
1406
+ {
1407
+ values: [
1408
+ { path: "navigation.state", value: "moored" },
1409
+ { path: "navigation.speedThroughWater", value: 0 },
1410
+ { path: "environment.wind.speedApparent", value: 12 },
1411
+ {
1412
+ path: "navigation.position",
1413
+ value: { latitude: 60.0005, longitude: 24.0005 },
1414
+ },
1415
+ ],
1416
+ },
1417
+ ],
1418
+ });
1419
+ await new Promise((r) => setTimeout(r, 900));
1420
+
1421
+ // The advisory raised just before mooring is withdrawn, not stuck.
1422
+ const notif = latestNotification(
1423
+ app,
1424
+ "notifications.navigation.deadReckoning.divergenceAdvisory",
1425
+ );
1426
+ assert.strictEqual(notif.state, "normal");
1427
+ assert.ok(/suspended while moored/.test(notif.message));
1428
+
1429
+ // Divergence readout is an explicit null — no stale underway figure.
1430
+ assert.strictEqual(latestDivergence(app), null);
1431
+
1432
+ // No phantom fouled paddlewheel: wind on a tied-up boat is not making way.
1433
+ const health = latestNotification(
1434
+ app,
1435
+ "notifications.navigation.deadReckoning.status",
1436
+ );
1437
+ assert.ok(health?.state !== "alert", `got ${health?.message}`);
1438
+ plugin.stop();
1439
+ await rm(dir, { recursive: true, force: true });
1440
+ });
1441
+
1442
+ test("moored from the start: DR state clean (no fouled, no transient), fouled detector idle", async () => {
1443
+ const dir = await mkdtemp(join(tmpdir(), "dr-moored-clean-"));
1444
+ const app = new FakeSignalKApp();
1445
+ app.dataPath = dir;
1446
+ const plugin = makePlugin(app);
1447
+ plugin.start({ tickIntervalMs: 100 });
1448
+ app.emitDelta({
1449
+ context: "vessels.self",
1450
+ updates: [
1451
+ {
1452
+ values: [
1453
+ { path: "navigation.state", value: "moored" },
1454
+ {
1455
+ path: "navigation.position",
1456
+ value: { latitude: 60, longitude: 24 },
1457
+ },
1458
+ { path: "navigation.speedThroughWater", value: 0 },
1459
+ { path: "navigation.headingTrue", value: 10 },
1460
+ { path: "environment.wind.speedApparent", value: 15 },
1461
+ { path: "environment.wind.angleApparent", value: 1.2 },
1462
+ ],
1463
+ },
1464
+ ],
1465
+ });
1466
+ await new Promise((r) => setTimeout(r, 500));
1467
+ const stateDeltas = app.handledMessages
1468
+ .flatMap((m) => m.message?.updates ?? [])
1469
+ .flatMap((u) => u.values ?? [])
1470
+ .filter((v) => v.path === "navigation.deadReckoning.state");
1471
+ assert.ok(stateDeltas.length > 0, "no DR state delta published");
1472
+ const last = stateDeltas[stateDeltas.length - 1].value;
1473
+ assert.strictEqual(last.fouled, false);
1474
+ assert.strictEqual(last.transient, false);
1475
+ const health = app.handledMessages
1476
+ .flatMap((m) => m.message?.updates ?? [])
1477
+ .flatMap((u) => u.values ?? [])
1478
+ .find(
1479
+ (v) =>
1480
+ v.path === "notifications.navigation.deadReckoning.status" &&
1481
+ v.value.state === "alert",
1482
+ );
1483
+ assert.ok(!health, "no sensor-health alert while moored");
1484
+ plugin.stop();
1485
+ await rm(dir, { recursive: true, force: true });
1486
+ });
1487
+
1488
+ test("fouling alert is debounced: threshold-hovering STW doesn't flap, sustained fouling raises", async () => {
1489
+ const dir = await mkdtemp(join(tmpdir(), "dr-fouled-debounce-"));
1490
+ const app = new FakeSignalKApp();
1491
+ app.dataPath = dir;
1492
+ const plugin = makePlugin(app);
1493
+ plugin.start({
1494
+ tickIntervalMs: 100,
1495
+ sensorHealth: { sustainS: 1, clearS: 1 },
1496
+ });
1497
+ app.emitDelta({
1498
+ context: "vessels.self",
1499
+ updates: [
1500
+ {
1501
+ values: [
1502
+ {
1503
+ path: "navigation.position",
1504
+ value: { latitude: 60, longitude: 24 },
1505
+ },
1506
+ { path: "navigation.headingTrue", value: 0 },
1507
+ { path: "environment.wind.speedApparent", value: 12 },
1508
+ ],
1509
+ },
1510
+ ],
1511
+ });
1512
+
1513
+ // STW hovers at the detector's stop threshold, strictly alternating
1514
+ // every 37 ms (incommensurate with the 100 ms tick so consecutive
1515
+ // ticks never sample a stable parity — paddlewheel spinner
1516
+ // sticking/slipping): raw verdict flips far faster than the 1 s
1517
+ // sustain window, so the alert must not raise.
1518
+ let flip = 0;
1519
+ const flap = setInterval(() => {
1520
+ app.emitDelta({
1521
+ context: "vessels.self",
1522
+ updates: [
1523
+ {
1524
+ values: [
1525
+ {
1526
+ path: "navigation.speedThroughWater",
1527
+ value: flip++ % 2 === 0 ? 0 : 0.4,
1528
+ },
1529
+ ],
1530
+ },
1531
+ ],
1532
+ });
1533
+ }, 37);
1534
+ await new Promise((r) => setTimeout(r, 1500));
1535
+ clearInterval(flap);
1536
+ const healthAlerts = () =>
1537
+ app.handledMessages
1538
+ .flatMap((m) => m.message?.updates ?? [])
1539
+ .flatMap((u) => u.values ?? [])
1540
+ .filter(
1541
+ (v) =>
1542
+ v.path === "notifications.navigation.deadReckoning.status" &&
1543
+ v.value.state === "alert",
1544
+ );
1545
+ assert.strictEqual(
1546
+ healthAlerts().length,
1547
+ 0,
1548
+ "fouling alert flapped during threshold hover",
1549
+ );
1550
+
1551
+ // A real fault — STW pinned at 0 while wind says 12 kn — must still
1552
+ // surface after the sustain window.
1553
+ app.emitDelta({
1554
+ context: "vessels.self",
1555
+ updates: [
1556
+ {
1557
+ values: [{ path: "navigation.speedThroughWater", value: 0 }],
1558
+ },
1559
+ ],
1560
+ });
1561
+ await new Promise((r) => setTimeout(r, 1600));
1562
+ const alerts = healthAlerts();
1563
+ assert.strictEqual(alerts.length, 1, "sustained fouling did not raise");
1564
+ assert.ok(/fouled/.test(alerts[0].value.message));
1565
+ plugin.stop();
1566
+ await rm(dir, { recursive: true, force: true });
1567
+ });
1568
+
1569
+ test("water-track log survives restart via dr_log_nm state", async () => {
1570
+ const dir = await mkdtemp(join(tmpdir(), "dr-log-restore-"));
1571
+ // Pre-seed the persisted state a previous run's flush would have left.
1572
+ const db = require("../plugin/db.js").openDatabase(
1573
+ join(dir, "dead-reckoning.sqlite"),
1574
+ );
1575
+ const { setState } = require("../plugin/db.js");
1576
+ setState(db, "dr_log_nm", "12.5");
1577
+ setState(db, "dr_trip_log_nm", "3.25");
1578
+ db.close();
1579
+
1580
+ const app = new FakeSignalKApp();
1581
+ app.dataPath = dir;
1582
+ const plugin = makePlugin(app);
1583
+ plugin.start({});
1584
+ const router = new FakeRouter();
1585
+ plugin.registerWithRouter(router);
1586
+ const { body } = router.invoke("get", "/status");
1587
+ assert.strictEqual(body.logNm, 12.5);
1588
+ assert.strictEqual(body.tripLogNm, 3.25);
1589
+ plugin.stop();
1590
+ await rm(dir, { recursive: true, force: true });
1591
+ });
1592
+
1316
1593
  /** Returns the latest published divergence readout, or throws. */
1317
1594
  function latestDivergence(app) {
1318
1595
  const vals = app.handledMessages
@@ -2331,13 +2608,31 @@ function lastValues(app, path) {
2331
2608
 
2332
2609
  test("idle while making way: uncertainty grows, state carries moving, alert raised", async () => {
2333
2610
  const dir = await mkdtemp(join(tmpdir(), "dr-idle-moving-"));
2611
+ // Seed the restored per-excursion distance above the GNSS-noise floor
2612
+ // so idle-run growth is observable above it within the test window.
2613
+ const seedDb = require("../plugin/db.js").openDatabase(
2614
+ join(dir, "dead-reckoning.sqlite"),
2615
+ );
2616
+ const { setState } = require("../plugin/db.js");
2617
+ setState(seedDb, "dr_log_since_origin", "1.5");
2618
+ // Seed the origin so the first GPS delta doesn't reset the excursion.
2619
+ setState(
2620
+ seedDb,
2621
+ "last_known_good_fix",
2622
+ JSON.stringify({ latitude: 60, longitude: 24 }),
2623
+ );
2624
+ seedDb.close();
2334
2625
  const app = new FakeSignalKApp();
2335
2626
  app.dataPath = dir;
2336
2627
  const plugin = makePlugin(app);
2337
- plugin.start({ tickIntervalMs: 20, saveIntervalMs: 60000 });
2628
+ plugin.start({
2629
+ tickIntervalMs: 20,
2630
+ saveIntervalMs: 60000,
2631
+ sensorHealth: { sustainS: 0.1, clearS: 0.1 },
2632
+ });
2338
2633
  try {
2339
- // No STW/heading (idle), but GPS shows ~6 kn over ground.
2340
- await emitMovingGps(app, 6, 6);
2634
+ // No STW/heading (idle), but GPS shows ~60 kn over ground.
2635
+ await emitMovingGps(app, 60, 20);
2341
2636
 
2342
2637
  const states = lastValues(app, "navigation.deadReckoning.state");
2343
2638
  assert.ok(states.length > 0, "no state deltas");
@@ -2414,7 +2709,11 @@ test("fouled paddlewheel: STW 0 while making way raises fouling alert + state fl
2414
2709
  const app = new FakeSignalKApp();
2415
2710
  app.dataPath = dir;
2416
2711
  const plugin = makePlugin(app);
2417
- plugin.start({ tickIntervalMs: 20, saveIntervalMs: 60000 });
2712
+ plugin.start({
2713
+ tickIntervalMs: 20,
2714
+ saveIntervalMs: 60000,
2715
+ sensorHealth: { sustainS: 0.1, clearS: 0.1 },
2716
+ });
2418
2717
  try {
2419
2718
  // STW reads 0, heading present → underway branch; GPS moving ~6 kn.
2420
2719
  app.emitDelta({
@@ -130,14 +130,37 @@ test("computeRadius: radius scales with elapsed distance, not time", () => {
130
130
  assert.ok(Math.abs(b.radius_nm - 2 * a.radius_nm) < 1e-9);
131
131
  });
132
132
 
133
- test("computeRadius: radius is 0 at excursion start (no distance run)", () => {
133
+ test("computeRadius: radius floors at GNSS noise at excursion start (no distance run)", () => {
134
+ // Zero distance run must not report a point-exact DR position: the
135
+ // radius can never honestly be smaller than the GPS noise the origin
136
+ // was seeded from, or any dockside GPS wander "exceeds expected
137
+ // uncertainty" and trips the divergence advisory on a moored boat.
134
138
  const out = u.computeRadius({
135
139
  elapsedDistanceNm: 0,
136
140
  effectiveHitCount: 100,
137
141
  deviationRows: [{ deviation_nm: 1, dr_elapsed_seconds: 1000 }],
138
142
  stwKn: 5,
139
143
  });
140
- assert.strictEqual(out.radius_nm, 0);
144
+ assert.strictEqual(out.radius_nm, u.MIN_RADIUS_NM);
145
+ assert.ok(u.MIN_RADIUS_NM >= 0.02, "floor below GNSS noise scale");
146
+ });
147
+
148
+ test("computeRadius: floor only applies below it — growth is unchanged above", () => {
149
+ const far = u.computeRadius({
150
+ elapsedDistanceNm: 10,
151
+ effectiveHitCount: 0,
152
+ deviationRows: [],
153
+ stwKn: 5,
154
+ });
155
+ assert.ok(Math.abs(far.radius_nm - u.fallbackRateNmPerNm() * 10) < 1e-9);
156
+ const near = u.computeRadius({
157
+ elapsedDistanceNm: 0.01,
158
+ effectiveHitCount: 0,
159
+ deviationRows: [],
160
+ stwKn: 5,
161
+ });
162
+ // 0.01 nm run × 0.01745 ≈ 0.00017 nm — below floor → floored.
163
+ assert.strictEqual(near.radius_nm, u.MIN_RADIUS_NM);
141
164
  });
142
165
 
143
166
  test("computeRadius: blend regime between fallback and empirical", () => {