@meri-imperiumi/signalk-dead-reckoning 0.1.0 → 0.3.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,161 @@ 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.3.0] - 2026-08-27
9
+
10
+ ### Added
11
+ - **`inertial-polar` DR speed fallback (SPEC §3.1, work doc #18)** —
12
+ when the paddlewheel is unusable (`navigation.speedThroughWater`
13
+ missing, or the debounced §6.3 fouling verdict active), DR integrates
14
+ speed from the polar performance plugin's `performance.polarSpeed`
15
+ delta instead of freezing: requires `signalk-polar-performance-plugin`
16
+ installed and configured with its polar speed output enabled; the
17
+ super-jittery raw delta is running-averaged (default 60 s window,
18
+ 30 s staleness cutoff) before integration. Gated to underway+sailing
19
+ (no wind-on-mast drift at the dock, no meaningless polar under
20
+ power); no matrix corrections while on polar (bins were trained on
21
+ real STW — a model estimate is circular input); uncertainty grows at
22
+ the fallback rate; Training Mode and maneuver detection suspended;
23
+ the divergence advisory keeps watching. `navigation.speedThroughWater`
24
+ stays silent while on polar (a model estimate is not a measurement).
25
+ The DR state value gains `speedSource: "paddlewheel"|"polar"`, and a
26
+ §3.1 sensor-health alert names the switch (paddlewheel
27
+ unavailable/fouled — DR on polar-derived speed).
28
+ - Scalar sibling paths `navigation.deadReckoning.uncertainty.radius`
29
+ and `navigation.deadReckoning.divergence.distance` (metres, with
30
+ nautical-mile display-unit meta) for rule/display engines
31
+ (signalk-status-tiles threshold checks) that cannot read subfields of
32
+ object-valued paths — subscribing to a subfield path never sees a
33
+ delta.
34
+
35
+ ### Changed
36
+ - **All published deltas now follow the Signal K SI unit conventions**
37
+ (breaking):
38
+ - `navigation.deadReckoning.log` / `trip.log` publish metres (was
39
+ nautical miles), with `value/1852` NM display-unit meta.
40
+ - The `environment.current` object is replaced by the standard
41
+ `environment.current.setTrue` (radians) and
42
+ `environment.current.drift` (m/s) paths; the DR-specific
43
+ tier/source enrichment rides REST `/status` instead of the bus.
44
+ - The uncertainty object field `radius_nm` becomes `radius_m`; the
45
+ divergence object fields `distance_nm`/`bearing_true` (deg) become
46
+ `distance_m`/`bearing_true` (rad).
47
+ - `navigation.deadReckoning.elapsedSinceFix` gains duration
48
+ display-unit meta so glance consumers render "3h 05m", not
49
+ seconds.
50
+ - All display-unit meta declares `category: "custom"` — the
51
+ server's unit-preference system rewrites category-less
52
+ `displayUnits` to the user's global preference (a seconds path
53
+ rendered as "0.0 hour"); custom keeps the nautical styling.
54
+ - **Inbound sensor deltas are now interpreted per the Signal K unit
55
+ conventions** (breaking for feeds that were publishing non-SI):
56
+ `speedThroughWater`/`speedApparent` are read as m/s and heading paths
57
+ as radians, converting to the engine's internal knots/degrees at the
58
+ boundary. Previously m/s and radian values were treated as knots and
59
+ degrees — DR under-travelled ~5× and mis-steered on standard feeds.
60
+ The standard-path passthroughs (`navigation.speedThroughWater`,
61
+ `navigation.headingTrue`) publish what they received, unchanged.
62
+ - Logbook fix/tack entries convert SOG/COG/heading at the boundary
63
+ (`_kn`/`_deg` REST fields previously received raw m/s/radians).
64
+ - The webapp converts SI bus values (m, m/s, rad) to nautical displays
65
+ centrally in the view model (`metresToNm`/`msToKn`/`radToDeg`).
66
+ - REST `/status` and `/current/manual` keep the plugin's internal
67
+ nautical units (`logNm`, `current.setTrue` deg, `drift` kn) — they
68
+ are the plugin's own API, not the Signal K bus.
69
+ - `navigation.deadReckoning.method` now reflects the actual speed
70
+ source every tick, completing the SPEC §3.1 enum: the idle branch
71
+ (no usable speed at all) publishes `fallback-zero` instead of
72
+ inheriting the constructor's `inertial-paddlewheel` — the `Polar`/
73
+ `Zero` headline labels added earlier are now driven by real values.
74
+ SPEC §3.1/§3.2/§6.1 aligned with the implemented fallback hierarchy
75
+ (polar first, fault-based selection, speed output silent on polar).
76
+ - The "Active method" headline shows a short watchkeeper-sized label
77
+ (`STW` for `inertial-paddlewheel`, `Polar`/`Zero` for the spec's
78
+ reserved methods) with the full token on hover — one of five figures,
79
+ it previously gave a full spec token permanent large-type real estate
80
+ despite having exactly one possible value today.
81
+ - The DR status no longer claims the vessel is `underway` while
82
+ moored: `navigation.deadReckoning.state.status` gains a `"warm"`
83
+ value (engine integrating on a tied-up boat, SPEC §5 runs it warm for
84
+ instant OVERRIDE handoff — that is not being under way) alongside
85
+ `"underway"` and `"idle"`. The webapp subscribes to the vessel's own
86
+ `navigation.state` (already in the system — nothing is republished
87
+ inside our deltas) and words the line accordingly:
88
+ `DR warm — moored/anchored, integrating sensors` vs
89
+ `Dead reckoning active`. Text logic moved to the pure `drStatusText()`
90
+ view-model helper.
91
+
92
+ ## [0.2.0] - 2026-08-27
93
+
94
+ ### Fixed
95
+ - **Map no longer opens blank — defaults to the first chart provider.**
96
+ The webapp previously kept its tile-less offline-first default unless
97
+ the server had *configured* charts, so on servers without any (the
98
+ common case: the layers control showed only "OpenStreetMap (online)",
99
+ unselected) the plot opened on an empty dark canvas. The first entry
100
+ of the chart list — first configured chart when the server has them,
101
+ otherwise the OSM online fallback — is now auto-selected on load, and
102
+ duplicate chart names no longer clobber each other's entry in the
103
+ layers control.
104
+ - **Dockside false positives while moored/anchored** — three alerts
105
+ that misfire on a tied-up boat are now suppressed in the moored/
106
+ anchored regime (consistent with the divergence monitor's existing
107
+ §7.1–2 suppression):
108
+ - *Divergence advisory* no longer raises on GPS wander at the dock:
109
+ the uncertainty radius previously collapsed to exactly 0.0 nm at
110
+ zero distance run, so a few metres of marina multipath "exceeded
111
+ expected uncertainty × 1.5" and sustained for 30 s raised
112
+ `DR-GPS divergence 0.00 nm exceeds expected 0.00 nm`. The radius
113
+ now floors at `MIN_RADIUS_NM` (0.02 nm ≈ 37 m, GNSS noise scale)
114
+ — DR error can never honestly be smaller than the GPS it was
115
+ seeded from.
116
+ - *Phantom "paddlewheel fouled" alert* — the fouling detector saw
117
+ STW≈0 (a moored paddlewheel's honest reading) plus wind on the
118
+ mast or GPS jitter and concluded the boat was making way. The
119
+ detector (and the whole training/matrix path, incl. auto tack
120
+ logging) is skipped while moored/anchored, and any earlier verdict
121
+ is withdrawn.
122
+ - *Sticky advisories* — a divergence advisory raised just before
123
+ mooring could never clear (the monitor isn't fed while suppressed).
124
+ It is now withdrawn with a `DR monitoring suspended while
125
+ moored/anchored` notification, and the divergence readout publishes
126
+ an explicit `null` while suppressed so the UI drops the stale
127
+ figure instead of showing a frozen `0.00 nm / 306°`.
128
+ - **Sensor-health alerts no longer flap** — the fouling and
129
+ idle-but-making-way verdicts are raw per-tick threshold comparisons
130
+ (STW vs 0.3 kn, wind vs 3 kn, SOG vs 1 kn); a sensor hovering at a
131
+ threshold toggled the alert on and off every tick. Both now pass
132
+ through a reusable symmetric hysteresis
133
+ (`plugin/hysteresis.js`, `sensorHealth.sustainS`/`clearS`, default
134
+ 10 s/10 s): a real fault still surfaces after the sustain window,
135
+ threshold hover raises nothing.
136
+ - **Water-track log survives restart** — `navigation.deadReckoning.log`
137
+ and the trip log were persisted every flush but never restored at
138
+ start, resetting the headline to 0.00 nm on every server restart
139
+ mid-passage. Both are now restored from the `dr_state_store`.
140
+ - **Map layers control usable again** — the divergence chip overlay sat
141
+ at the top-right corner on top of Leaflet's layers control (equal
142
+ z-index, later sibling wins), covering its toggle: broken-looking
143
+ styling and swallowed clicks meant selecting a chart provider did
144
+ nothing. The chip now lives at the bottom-right (attribution is off,
145
+ so the corner is free).
146
+ - The DR vs GPS headline no longer shows a bearing below display
147
+ resolution (`0.00 nm / 306°` → `0.00 nm`): the bearing of a
148
+ zero-length noise vector is meaningless.
149
+
150
+ ### Changed
151
+ - When the server has no charts configured, the online OpenStreetMap
152
+ fallback layer is offered in the layers control but no longer
153
+ auto-added to the map (it was documented as opt-in; the tile-less
154
+ offline-first plot is the default again until selected).
155
+
156
+ ### Added
157
+ - `plugin/hysteresis.js` — generic symmetric sustain/clear debounce for
158
+ boolean conditions, extracted from the divergence advisory's shape
159
+ and unit-tested; also used for the sensor-health alerts.
160
+ - Work doc #17 on the rngit board tracks this batch (dockside false
161
+ positives: divergence advisory, fouled paddlewheel, layers control).
162
+
8
163
  ## [0.1.0] - 2026-08-27
9
164
 
10
165
  ### Changed
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # Signal K Dead Reckoning
2
2
 
3
- An offline-first dead reckoning and sensor fusion engine for Signal K that maintains a continuously computed "shadow boat" position from water-track sensors (speed through water, compass heading, and learned leeway and current corrections), so you always have a navigational fallback when GPS becomes unreliable — whether from jamming, spoofing, or plain receiver failure. While GPS is trusted, the engine learns vessel-specific calibration corrections against ground truth and watches for GPS anomalies; when it isn't, the same learned model keeps the dead-reckoned position, its uncertainty polygon, and a water-track log going. Fixes from celestial sights, compass bearings, and vertical angles are entered through a unified pipeline and can snap dead reckoning back on track, with optional write-through to `signalk-logbook`. Historical data can be backfilled to train the calibration model and backtest it against past passages. Licensed under the EUPL-1.2.
3
+ An offline-first dead reckoning and sensor fusion engine for Signal K that maintains a continuously computed "shadow boat" position from water-track sensors (speed through water, compass heading, and learned leeway and current corrections), so you always have a navigational fallback when GPS becomes unreliable — whether from jamming, spoofing, or plain receiver failure. While GPS is trusted, the engine learns vessel-specific calibration corrections against ground truth and watches for GPS anomalies; when it isn't, the same learned model keeps the dead-reckoned position, its uncertainty polygon, and a water-track log going. Fixes from celestial sights, compass bearings, and vertical angles are entered through a unified pipeline and can snap dead reckoning back on track, with optional write-through to `signalk-logbook`.
4
4
 
5
5
  **Note:** This is just a toy. Make your own navigation calculations and decisions.
package/SPEC.md CHANGED
@@ -38,10 +38,10 @@ The three motivations are causally linked: (1) generates the fix data and crew e
38
38
  |---|---|---|
39
39
  | `navigation.deadReckoning.position` | `{ latitude, longitude, altitude }` | **Always-on** 1Hz inertial "shadow boat" position — computed continuously regardless of mode. |
40
40
  | `navigation.deadReckoning.active` | boolean | Whether DR is currently the *authoritative* source feeding `navigation.position` (i.e. OVERRIDE engaged), as distinct from merely running in the background. |
41
- | `navigation.deadReckoning.method` | string: `inertial-polar` \| `inertial-paddlewheel` \| `fallback-zero` | Active state calculation mode. |
41
+ | `navigation.deadReckoning.method` | string: `inertial-polar` \| `inertial-paddlewheel` \| `fallback-zero` | Active speed-source mode, re-published every tick: `inertial-paddlewheel` while the paddlewheel serves (raw STW present, not fouled); `inertial-polar` when it doesn't and the polar fallback can engage (§6.1); `fallback-zero` when no usable speed source exists (DR holds position). Selection is fault-based, never merit-based — a working measurement always outranks a model. |
42
42
  | `navigation.deadReckoning.log` | number (nm) | Cumulative **water-track** distance, integrated from STW. Independent of GPS. |
43
43
  | `navigation.deadReckoning.trip.log` | number (nm) | Same, reset at trip boundaries (see §9.2). |
44
- | `navigation.speedThroughWater` | number | Calibrated STW output (matrix-corrected). |
44
+ | `navigation.speedThroughWater` | number | Calibrated STW output (matrix-corrected). Silent while the polar fallback is active — a model estimate must not masquerade as a measurement. |
45
45
  | `navigation.headingTrue` | number | Calibrated true heading (corrected for dynamic deviation). |
46
46
  | `environment.current` | `{ setTrue, drift, meta: { source, expiresAt } }` | Current vector broadcast, per §6.2 hierarchy. |
47
47
  | `notifications.navigation.gpsSpoofed` | alarm state | High-severity: sudden position discontinuity inconsistent with DR/physics, or at-anchor/moored displacement beyond plausible bound. See §7. |
@@ -55,6 +55,7 @@ The three motivations are causally linked: (1) generates the fix data and crew e
55
55
  | `navigation.position` | GPS baseline for training mode and anomaly detection. |
56
56
  | `navigation.speedThroughWater`, `navigation.headingMagnetic`, `navigation.attitude` | Raw sensor inputs (heel/pitch). |
57
57
  | `environment.wind.angleApparent`, `environment.wind.speedApparent` | Wind inputs for leeway/upwash modeling. |
58
+ | `performance.polarSpeed` | Polar-derived boat speed (m/s) from `signalk-polar-performance-plugin` — requirement for the §6.1 `inertial-polar` fallback: that plugin installed and configured with its polar-speed output enabled. The raw delta is a step-function polar lookup driven by gusty wind and is **running-averaged** before integration; sustained nulls (wind out-of-table) age the average out to staleness rather than decaying it toward zero. |
58
59
  | `navigation.sails` | Active sail configuration, from `signalk-logbook`. |
59
60
  | `environment.seaState` | Sea state tier, from logbook watch entries. |
60
61
  | `navigation.state` | `anchored` \| `moored` \| `sailing` \| `motoring` \| ... — trip boundaries, and the anchored/moored anomaly-detection gates. |
@@ -273,7 +274,14 @@ Worker Thread (DR Physics)
273
274
 
274
275
  **Inference Mode** — active when `isGpsReliable = false` OR OVERRIDE is manually engaged:
275
276
  - Freezes matrix learning. Reads raw sensors, looks up matching bins, applies corrections, integrates the resolved current vector, publishes `navigation.deadReckoning.position` as authoritative (`navigation.deadReckoning.active = true`).
276
- - If the paddlewheel is fouled during Inference Mode, falls back to GPS-SOG-derived speed (if GPS is at least partially available) or holds last-known-good STW with explicitly faster-growing uncertainty (if not) — distinct fallback branch from the "GPS unreliable" case, since the two can occur independently or together.
277
+
278
+ **Speed-source fallback** — mode-independent (the shadow boat integrates regardless of GPS trust, §5, so a sensor lost in NORMAL mode is handled the same way as one lost in Inference/OVERRIDE). A fouled or silent paddlewheel degrades the speed source by preference:
279
+
280
+ 1. **Polar-derived speed** — the running-averaged `performance.polarSpeed` (§3.2). Gated to underway + sailing: wind on a moored mast must not sail the shadow boat off the dock, and a polar is meaningless under power. While on this source: no matrix corrections (leeway/speed-loss bins were trained on real paddlewheel STW — feeding them a model estimate is circular), uncertainty grows at the fallback rate, and matrix training / maneuver detection are suspended (never train on synthetic input — same principle as the §6.3/§6.4 write gates). The divergence advisory (§7.3) keeps watching: model drift must be detected even though it never re-selects the method.
281
+ 2. **GPS-SOG-derived speed** — if no polar source is available and GPS is at least partially available.
282
+ 3. **Hold last-known-good STW** — with explicitly faster-growing uncertainty, if neither.
283
+
284
+ The paddlewheel-failure fallback is a distinct branch from the "GPS unreliable" case, since the two can occur independently or together.
277
285
 
278
286
  ### 6.2 Current Hierarchy of Truth
279
287
 
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.3.0",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -33,7 +33,8 @@
33
33
  ],
34
34
  "recommends": [
35
35
  "@meri-imperiumi/signalk-autostate",
36
- "@meri-imperiumi/signalk-logbook"
36
+ "@meri-imperiumi/signalk-logbook",
37
+ "signalk-polar-performance-plugin"
37
38
  ],
38
39
  "screenshots": [
39
40
  "./doc/dr-map.png",
@@ -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
+ };