@meri-imperiumi/signalk-dead-reckoning 0.9.0 → 0.11.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/SPEC.md +8 -2
  3. package/doc/dr-bearing-to.png +0 -0
  4. package/doc/dr-celestial.png +0 -0
  5. package/doc/dr-map.png +0 -0
  6. package/doc/dr-mobile.png +0 -0
  7. package/package.json +2 -1
  8. package/plugin/db.js +43 -1
  9. package/plugin/derived-current.js +66 -10
  10. package/plugin/engine.js +11 -0
  11. package/plugin/fix-pipeline.js +8 -6
  12. package/plugin/fixes.js +97 -6
  13. package/plugin/index.js +262 -75
  14. package/plugin/publish-gate.js +121 -0
  15. package/plugin/training.js +30 -6
  16. package/public/dr-app.js +515 -77
  17. package/public/dr-current-panel.js +11 -5
  18. package/public/dr-ext-host-core.js +472 -0
  19. package/public/dr-ext-host.js +1098 -0
  20. package/public/dr-fix-panel.js +9 -5
  21. package/public/dr-map-view.js +312 -111
  22. package/public/dr-pending-list.js +8 -4
  23. package/public/dr-sight-panel.js +71 -10
  24. package/public/dr-signalk-stream.js +124 -12
  25. package/public/dr-theme.js +41 -1
  26. package/public/dr-viewmodel.js +213 -7
  27. package/public/vendor/plotterext-bus/README.md +9 -4
  28. package/public/vendor/plotterext-bus/chunk-RED55KML.js +112 -0
  29. package/public/vendor/plotterext-bus/host.js +29 -0
  30. package/tests/db.test.js +41 -1
  31. package/tests/derived-current.test.js +128 -0
  32. package/tests/dr-app-layout.test.js +311 -0
  33. package/tests/dr-chartwork.test.js +276 -0
  34. package/tests/dr-ext-host-core.test.js +315 -0
  35. package/tests/dr-ext-host.test.js +405 -0
  36. package/tests/dr-form-submit.test.js +99 -0
  37. package/tests/dr-recenter.test.js +13 -5
  38. package/tests/dr-signalk-stream.test.js +172 -0
  39. package/tests/dr-track-style.test.js +77 -0
  40. package/tests/dr-viewmodel.test.js +8 -4
  41. package/tests/engine.test.js +19 -0
  42. package/tests/fix-pipeline.test.js +4 -1
  43. package/tests/fixes.test.js +155 -0
  44. package/tests/plugin.test.js +317 -11
  45. package/tests/publish-gate.test.js +104 -0
  46. package/tests/training.test.js +45 -0
  47. package/tools/replay-passage.js +1 -0
  48. package/tools/webapp-screenshot-harness.html +231 -0
  49. package/tools/webapp-screenshot-scenario.js +185 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,224 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.0] - 2026-09-20
11
+
12
+ ### Added
13
+ - **The webapp is now also a plotter-extension host (Plotter
14
+ Extensions API v1).** Beside providing its own extension, the DR
15
+ webapp discovers other plugins' `plotterExtensions` manifests and
16
+ hosts their widgets: two 2×2 widget grids anchored *below* the
17
+ webapp's own top panels (so they can never cover the entry tools
18
+ or GPS status), placed and removed through a picker dialog with a
19
+ persistent localStorage layout (`dr.plotterext.layout.v1`, stable
20
+ per-placement instance ids). Hosted cells reuse the webapp's
21
+ visual language (translucent panel + corner brackets), the area
22
+ stays pointer-transparent where empty so the chart keeps dragging
23
+ through. Capabilities offered: `widgets`, `panels.iframe`
24
+ (configuration panels — persistent dialogs so `keepAlive` panels
25
+ survive close/reopen without iframe reloads), `signalk.stream`
26
+ (multiplexed onto the app's single WebSocket with per-path
27
+ ref-counting and incremental subscribe/unsubscribe) and
28
+ `signalk.put`. Widget long-press gestures reach the host via
29
+ `ui.openConfigPanel`/`ui.toggleConfigPanel` on the bus; the picker
30
+ provides the gesture-independent configure/remove path every
31
+ widget must get. The host-side bus (`signalk-plotterext-bus`
32
+ `/host` entry) is vendored alongside the extension side.
33
+
34
+ ### Changed
35
+ - **The webapp now uses a plotter layout: the chart fills the whole
36
+ viewport.** `<dr-map-view>` is the app; every control floats over
37
+ it as a translucent `.sk-floating` panel — corner brackets and
38
+ flat geometry preserved, chart context showing through. Corner
39
+ assignment: top-left carries the bearing & fix entry tools; top-
40
+ right the engine status badge and the failover control; bottom-
41
+ right the water-track readout (log, elapsed since fix, divergence
42
+ with its trend sparkline, current set/drift, active method) with
43
+ the manual current entry beside the figure it edits; bottom-left
44
+ keeps the map's own controls (zoom and chart layers moved there,
45
+ stacked above the follow control). The pending-observations box —
46
+ toggle and drawer both — only appears while there ARE pending
47
+ observations, docking left under the entry tools (bottom sheet on
48
+ phones), and the drawer opens automatically when the first
49
+ observation arrives on wide screens. The divergence readout +
50
+ sparkline moved from the map's old bottom-right chip into the
51
+ readout panel. The overlay layer passes pointer events through
52
+ everywhere except on the panels, so the chart stays draggable
53
+ between the controls; phones collapse to full-width bands with
54
+ the safety-critical figures (elapsed, divergence, current). On
55
+ phones the map's bottom-left control stack rides above the readout
56
+ band (chart-layers + follow, measured via a custom property), the
57
+ zoom buttons give way to pinch-zoom, and while the pending sheet is
58
+ open the whole control stack hides — the sheet spans the full width
59
+ and would otherwise push the stack up into the top control bands.
60
+ Phone bands are compact — tighter padding, figures as inline
61
+ value+label chips, the manual-current entry collapses to its ≋
62
+ glyph, the divergence sparkline hides — reclaiming ~60px of chart
63
+ (bands 346px of 844 with the sheet closed leaving 57% chart).
64
+ Dialog focus outlines are solid instead of dashed.
65
+
66
+ ### Fixed
67
+ - **Selecting pending observations no longer repaints their lines
68
+ white on the chart.** The selection highlight keeps each line's
69
+ semantic color (orange = active constraint, grey = used) and
70
+ emphasizes with line weight instead — white was invisible over
71
+ light chart tiles. Applies to LOPs and CPLs (circle + arc) alike.
72
+ - **Every selected observation now renders highlighted on the
73
+ chart** — previously only the most recently clicked one did, even
74
+ though the pending list allows multi-selection. The resolve button
75
+ also reads the same — "Preview selected (n)" — for one sight and
76
+ many: a single selection resolves as a running fix against the
77
+ last confirmed fix (tooltip explains, the status line names the
78
+ mode once the candidate is ready); both go through the same
79
+ preview→confirm flow.
80
+ - **Submitting the sight/fix/current forms with Enter now goes through
81
+ the app's own submit flow (Safari).** The submit buttons were
82
+ `type="button"` with click handlers, so the forms had no submit
83
+ button — pressing Enter fell into the browser's implicit-submission
84
+ constraint validation, which tripped over hidden-but-required
85
+ coordinate sub-fields (empty in the active position format) and
86
+ surfaced Safari's "The string did not match the expected pattern."
87
+ bubble without ever submitting. All three entry forms now use real
88
+ submit buttons + `novalidate` with the panel's own validation
89
+ (`requiredMissing` explains the first missing field in the app's
90
+ error element and skips sub-fields hidden by the position format);
91
+ the destructive "Clear override" stays a plain button so Enter
92
+ can't fire it. The sight-time field — required, but previously only
93
+ seeded after a first submit — is now seeded when the dialog opens.
94
+ - **Chartwork overlays now follow traditional navigation conventions.**
95
+ - Position lines carry their traditional arrowheads: a bearing PL
96
+ has a single arrowhead at the object end pointing INTO the
97
+ observed object (the marking that identifies what was sighted);
98
+ a celestial PL has single arrowheads at both ends; a transferred
99
+ (running-fix) PL has a double arrowhead at both ends.
100
+ - A bearing PL is a ray that starts at the charted object and runs
101
+ toward the navigator — it never crosses or extends past the mark
102
+ it was taken from.
103
+ - A range CPL draws as an arc sweeping ±45° around the bearing from
104
+ the observed object toward the DR position, with single
105
+ arrowheads at both arc ends; the full dashed circle stays
106
+ underneath (faded) as the complete constraint. Without a DR
107
+ position the full circle renders as before.
108
+ - Fixes plot with their traditional symbols: an outlined triangle
109
+ with a dot for a GPS fix, an outlined circle with a dot for fixes
110
+ by observation or manual plotting, and the DR position as the
111
+ navigator's X — all still color-coded by source.
112
+ - Plotted positions and the course line carry traditional labels,
113
+ always in Zulu time: "Fix 02:30Z" beside each fix, "DR 02:50Z"
114
+ beside the DR position, and "C 290° S 6.1" along the ghost track
115
+ (course & speed derived from the last minute of DR movement; no
116
+ label when moored).
117
+ - **The DR ghost track now renders as the dominant track on the chart.**
118
+ It draws at 3.5 px (up from 2, GPS stays at 2) over a dark casing
119
+ line, so the teal DR output stays legible on any tileset — light
120
+ raster charts included — instead of reading as an equal-weight twin
121
+ of the GPS track. Track weights/casing constants live in
122
+ `STYLE.track` in the view-model; the ghost lines are non-interactive
123
+ so chart picks and the context menu keep working on and near the
124
+ track.
125
+
126
+ ## [0.10.0] - 2026-09-17
127
+
128
+ ### Fixed
129
+ - **The derived-current EWMA can now sample on a healthy GPS feed — and
130
+ only ever pairs fixes from one receiver.** The sampler refreshed its
131
+ differential baseline on every tick, so with GPS fixes arriving faster
132
+ than the 5 s minimum interval the ≥5 s differential could only form
133
+ across `navigation.position` null gaps — and those gap-spanning samples
134
+ routinely paired fixes from *different* receivers. On the
135
+ 2026-09-11…14 Niue→Vava'u trial that produced a phantom "current" of
136
+ 1.1 kn @ ~131° for 55 h (a quasi-fixed ~140 m offset between two
137
+ position providers ÷ the multi-minute sampling gap — heading-
138
+ independent, which is why it didn't look like leeway), dragging DR to
139
+ 34.7 nm of error at arrival while the real current was 0.6 kn @ 006°.
140
+ Replaying the same passage with the sampler working ends at 8.7 nm.
141
+ The baseline now holds across cadence-rejected ticks (samples form at
142
+ the 5 s minimum instead of only across null gaps), differentials use
143
+ the fix's own timestamp (a paused feed can no longer fabricate
144
+ zero-ground "currents" from a running tick clock), fixes carry their
145
+ `$source` so a differential never spans two receivers, and the tick
146
+ path feeds the sampler (and training's ground truth) the server's
147
+ priority-filtered tree position instead of the last-writer-wins raw
148
+ delta — on multi-GPS installs the raw subscription stream flits
149
+ between every provider of the path (this one: ZG100, Orca, WIDELINK
150
+ AIS, YDNR, Cerbo, the RUTX11's indoor GNSS, and the own-boat
151
+ meshtastic node's LoRa-recycled position), several of which sit
152
+ 100 m+ from the truth. Verified by capturing the live subscription
153
+ stream: ranked-below sources still deliver; priorities only gate the
154
+ merged tree.
155
+ - **A bearing to a known object is now a ray constraint in the fix
156
+ resolver, not an infinite line.** On the same trial a running fix
157
+ against a bearing to AIS vessel MATILDA (115.5° true, 4.1 nm)
158
+ projected the advanced fix onto the far side of the line: a "fix"
159
+ 2.7 nm past the vessel from which the vessel bore the reciprocal of
160
+ the sighted bearing. The resolver now rejects points past the
161
+ object's anchor — in single-observation running fixes, bearing×
162
+ bearing intersections, bearing×range circle picks and the
163
+ parallel-bearings midline — with an error explaining that the run and
164
+ the bearing disagree (celestial intercept LOPs stay full lines; a
165
+ 100 m along-ray slack keeps close-range sights with ordinary compass
166
+ slop from hard-rejecting). The
167
+ `lop_type` threads from the persisted row through running-fix
168
+ advancement into the resolver, and REST resolve/confirm surface the
169
+ rejection as the 400 message.
170
+
171
+ ### Changed
172
+ - **Training Mode no longer runs on model currents (tier 3/4), only on
173
+ observed ones (manual tier 1, derived tier 2).** A wrong current —
174
+ model *or* phantom — gets compensated by the trainer as physically
175
+ impossible leeway: the Niue→Vava'u trial's phantom tier-2 current was
176
+ absorbed as 15–18° of leeway on broad reaches (thousands of live
177
+ hits), which then mis-rotate the water track whenever the current
178
+ resolves correctly. Same failure shape as the previous trial's tier-5
179
+ zero-current poisoning, now gated for the model tiers too. A write-time
180
+ plausibility bound rejects observations implying |leeway| > 15° (input
181
+ disagreement, not hydrodynamics), and schema v3 ships a one-time data
182
+ migration removing bins with |leeway| > 10° accumulated during the two
183
+ poisoned trials — no manual SQL on the boat.
184
+
185
+ ### Changed
186
+ - **The DR output is no longer published to the bus on every
187
+ integration tick.** Each published delta costs the Signal K server
188
+ fanout — delta-cache routing plus serialization to every websocket
189
+ client and logging plugin subscribed to the context — and most of
190
+ the DR output (log, trip log, current vector, uncertainty radius,
191
+ elapsed-since-fix) is slow-moving data nobody needs at 1 Hz. The
192
+ tick still integrates every `tickIntervalMs`; the published set is
193
+ batched to every `publish.everyTicks`-th tick (new setting, default
194
+ 2 → one delta every 2 s at the default 1 s tick; 1 restores
195
+ per-tick publishing). Notable transitions publish immediately,
196
+ within the tick that observed them: method/state/active flips, paths
197
+ appearing or disappearing (e.g. the STW echo dropping when the
198
+ polar fallback takes over), and values crossing to/from null (e.g.
199
+ the divergence readout clearing at anchor). The shadow vessel's
200
+ synthetic target follows the same cadence.
201
+
202
+ ### Fixed
203
+ - **The logbook access-request flow now targets the logbook URL's own
204
+ host and port.** It previously used a separate `logbook.baseUrl`
205
+ setting that wasn't even exposed in the plugin settings UI and
206
+ defaulted to `http://localhost:3000` — on installs where that port
207
+ isn't the Signal K server (Grafana there), its 404 read as "open
208
+ server", the tokenless probe then hit the real server's auth gate,
209
+ and logbook writes parked with "Logbook writes need authentication
210
+ this plugin cannot obtain" even though the server's access-request
211
+ flow was fully available. The `logbook.baseUrl` config option is
212
+ gone: the origin is derived from the configured `logbook.url`. An
213
+ unparseable URL parks the flow with a message naming the fix instead
214
+ of crashing the write path.
215
+ - **The uncertainty cone no longer grows while anchored or moored.**
216
+ The current-knowledge term scaled with wall-clock time since the
217
+ last fix, so a week on the hook painted a 100+ NM circle around a
218
+ boat the ground was holding perfectly still. The engine now tracks
219
+ a separate under-way clock (`underwaySinceOriginS`, gated by
220
+ `navigation.state` anchored/moored, persisted across restarts) and
221
+ the cone grows only on that axis; wall-clock `since last fix` still
222
+ counts anchor time (published unchanged). The same axis now also
223
+ drives the fix sanity cap (the boat can't have traveled while
224
+ anchored — a far fix is more suspect, not less) and the learned
225
+ `dr_corrections` deviation rate (anchor time between fixes no
226
+ longer deflates it).
227
+
10
228
  ## [0.9.0] - 2026-09-10
11
229
 
12
230
  ### Added
package/SPEC.md CHANGED
@@ -91,6 +91,7 @@ CREATE TABLE IF NOT EXISTS dr_matrix_bins (
91
91
  - `stw_bin`/`awa_bin`/`heel_bin` must be populated via an explicit quantization function (round to nearest bin width) before insert/lookup — storing raw continuous values as the primary key would prevent `hit_count` from ever accumulating meaningfully.
92
92
  - `live_hit_count` and `historical_hit_count` are tracked separately so a season of live sailing can outweigh years of backfilled data in the effective confidence/learning-rate calculation — each live sample should count as several times a historical sample by default (tunable), and each historical sample's weight further depends on `historical_confidence_tier` (a bin trained against reanalysis current data is more trustworthy than one that only had climatological pilot-chart current available).
93
93
  - `sail_state = motoring` (or any interval where `propulsion.main.state = started`) is **excluded** from writes to this table entirely — the underlying physics (prop walk, wake, rudder-induced yaw) differs from sail-driven leeway and would corrupt the model if blended. A separate under-power calibration table is out of scope for v1 (see §12).
94
+ - **Plausibility bound:** observations implying |leeway| > 15° are rejected at write time — real leeway for this hull is ≈0° on a run and ≈10–13° close-hauled in a gale; anything beyond is input disagreement (wrong current tier, flappy sensor sources), not hydrodynamics. Both 2026 sea trials poisoned bins this way (10–32° on broad reaches); schema v3 ships a one-time data migration removing bins with |leeway| > 10° accumulated before the bound existed.
94
95
  - Historical bins written during backfill should apply a mild recency-decay weight, and/or respect configured "epoch boundaries" (known re-rig / instrument-replacement dates) so pre- and post-change data isn't blended across a known discontinuity.
95
96
 
96
97
  ### 4.2 Persistent DR State & Checkpoints
@@ -174,6 +175,8 @@ CREATE TABLE IF NOT EXISTS circular_position_lines (
174
175
  - Circle × Circle → **two** candidate intersections; default to the one nearest current DR position, but surface both to the watchkeeper rather than silently discarding one.
175
176
  - A single, unresolved LOP or CPL may still be **advanced** along the DR track to combine with a later observation (classic running fix), reusing the DR engine's integrated track over the elapsed interval.
176
177
 
178
+ **Bearing ray constraint:** a bearing to a *known object* (charted feature, AIS target) is a ray, not a full line — the bearing itself says which side of the object the observer is on (the reciprocal side). A resolved point past the object's anchor would place the vessel where the object bears the *opposite* of the sighted bearing — geometrically on the line, navigationally wrong (2026-09-13, AIS vessel MATILDA: the running-fix projection landed 2.7 nm beyond the target and "reversed" the bearing). The resolver enforces the observer's ray for `lop_type = 'bearing'` in the point-projection (running fix), Line × Line, Line × Circle and parallel-midline branches — a candidate past the object beyond a 100 m along-ray slack (close-range compass/run slop) is rejected with an explanatory error. Celestial intercept LOPs remain full lines: any point on the line is equally valid.
179
+
177
180
  **Non-intersecting / contradictory inputs (residual fallback):** measurement error means an exact geometric intersection is the exception rather than the rule — three or more LOPs/CPLs will almost never cross at a single point (the classical "cocked hat" case), and even a two-input combination can fail to intersect cleanly if one observation was mistimed or misread. The resolver must not fail silently or force a nearest-point snap when inputs disagree beyond expected measurement tolerance. Instead:
178
181
  - With ≥3 inputs, resolve via least-squares residual minimization (best-fit point minimizing summed distance to each line/circle constraint) rather than requiring exact intersection.
179
182
  - With exactly 2 inputs that don't intersect within tolerance, present the closest-approach point and the residual gap explicitly, rather than silently picking a point on one of the two constraints.
@@ -269,8 +272,9 @@ Worker Thread (DR Physics)
269
272
 
270
273
  ### 6.1 Training Mode vs. Inference Mode
271
274
 
272
- **Training Mode** — active when `isGpsReliable = true` AND `propulsion.main.state = stopped` AND paddlewheel not fouled (§6.3) AND the resolved current is not the zero vector (§6.2 tier < 5 — training with an unknown current bakes it into the leeway/speed bins as fake corrections):
275
+ **Training Mode** — active when `isGpsReliable = true` AND `propulsion.main.state = stopped` AND paddlewheel not fouled (§6.3) AND the resolved current is an *observed* vector (§6.2 tier ≤ 2 — manual or derived; training with an unknown *or modelled* current bakes its error into the leeway/speed bins as fake corrections: the zero vector did this on the 2026-08-30→09-05 trial, a wrong tier-2 phantom on 2026-09-11…14):
273
276
  - Computes error vectors: GPS SOG/COG vs. sensor STW/heading, minus the resolved current vector (§6.2), updated into matching `dr_matrix_bins` via EMA, learning rate modulated by effective `hit_count`.
277
+ - Observations implying |leeway| > 15° are rejected outright as input disagreement, not hydrodynamics (§4.1 plausibility bound).
274
278
 
275
279
  **Inference Mode** — active when `isGpsReliable = false` OR OVERRIDE is manually engaged:
276
280
  - 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`).
@@ -286,7 +290,7 @@ The paddlewheel-failure fallback is a distinct branch from the "GPS unreliable"
286
290
  ### 6.2 Current Hierarchy of Truth
287
291
 
288
292
  1. Manual Override — watchstander input with valid TTL (`environment.current`).
289
- 2. Derived Residual — exponentially-weighted mean of the boat's own GPS-vs-water-track residual (`derived-current.js`), sampled while GPS is trusted and the water track is usable; carried forward with exponential decay when GPS degrades, TTL-bounded. The boat's own observation outranks model products (sea trial 2026-08-30→09-05, Aitutaki→Niue: ~11 nm DR error over 622 nm vs 47 nm for tier 3, 85 nm for tier 5).
293
+ 2. Derived Residual — exponentially-weighted mean of the boat's own GPS-vs-water-track residual (`derived-current.js`), sampled while GPS is trusted and the water track is usable; carried forward with exponential decay when GPS degrades, TTL-bounded. The boat's own observation outranks model products (sea trial 2026-08-30→09-05, Aitutaki→Niue: ~11 nm DR error over 622 nm vs 47 nm for tier 3, 85 nm for tier 5). Sampling discipline (2026-09-11…14 trial): differentials pair only fixes from the same `$source` (multi-receiver buses flit between antennas at 1 Hz each — a cross-receiver differential is antenna separation measured as current), span the fixes' own timestamps at a ≥5 s minimum cadence, and the tick path reads the server's priority-filtered tree position rather than the raw last-writer-wins subscription stream.
290
294
  3. Sparse Forecast — bilinear/temporal-interpolated radio GRIB vectors (Signal K Weather API).
291
295
  4. Offline Pilot Charts — static SQLite monthly historical averages (`offline_pilot_currents`).
292
296
  5. Zero Vector — pure inertial water track (U: 0, V: 0).
@@ -474,6 +478,8 @@ Celestial sight reduction and the solar-panel sanity check (§7.5) both assume t
474
478
 
475
479
  Native Web Components + OpenLayers/Leaflet (EUPL-1.2 compatible), no build tooling required.
476
480
 
481
+ **Plotter layout:** the chart is the app — `<dr-map-view>` fills the entire viewport and every control floats over it as a translucent overlay, with a fixed corner assignment: top-left carries the bearing & fix entry tools plus the pending-observations drawer; top-right the engine status badge and the failover control; bottom-right the water-track readout (with the divergence trend sparkline) and the manual current entry beside the figure it edits; bottom-left keeps the map's own controls (zoom, chart layers, follow). The pending-observations box — toggle included — only appears while there are pending observations. The overlay layer is pointer-transparent except on the panels themselves, so the chart stays draggable between the controls; on phones the corners collapse to full-width bands and the pending drawer docks as a bottom sheet.
482
+
477
483
  ### 14.1 Core Capabilities
478
484
 
479
485
  - **Dual/continuous track rendering:** physical GPS track vs. the always-on inertial "Ghost Track," regardless of current mode.
Binary file
Binary file
package/doc/dr-map.png CHANGED
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -39,6 +39,7 @@
39
39
  ],
40
40
  "screenshots": [
41
41
  "./doc/dr-map.png",
42
+ "./doc/dr-mobile.png",
42
43
  "./doc/dr-celestial.png",
43
44
  "./doc/dr-bearing-to.png"
44
45
  ]
package/plugin/db.js CHANGED
@@ -20,7 +20,7 @@ const { mkdirSync } = require("node:fs");
20
20
  /** Schema version, bumped when a migration is needed. Persisted in dr_state_store.
21
21
  * v2: `fixes.derived_from_fix_id` — provenance for single-observation
22
22
  * running fixes advanced from a previous confirmed fix. */
23
- const SCHEMA_VERSION = 2;
23
+ const SCHEMA_VERSION = 3;
24
24
 
25
25
  /**
26
26
  * DDL for every table in SPEC §4. Statements run in order; all are
@@ -202,6 +202,7 @@ function openDatabase(dbPath) {
202
202
  db.exec(stmt);
203
203
  }
204
204
  migrate(db);
205
+ migrateData(db);
205
206
  // Record schema version so future migrations can branch on it.
206
207
  setState(db, "schema_version", String(SCHEMA_VERSION));
207
208
  return db;
@@ -283,6 +284,47 @@ function migrate(db) {
283
284
  }
284
285
  }
285
286
 
287
+ /**
288
+ * One-time, version-gated *data* migrations. Unlike the column steps
289
+ * above (idempotent by construction, safe on every open), these are
290
+ * gated on the stored `schema_version` so each runs exactly once per
291
+ * database. Bumping SCHEMA_VERSION and adding an entry here upgrades
292
+ * an existing install on its first open.
293
+ *
294
+ * v3 — poisoned-leeway cleanup (sea trials 2026-08-30…09-05 and
295
+ * 2026-09-11…14): both trials trained while the resolved current was
296
+ * wrong (zero vector, then the phantom cross-receiver tier-2 vector),
297
+ * and the trainer absorbed the current error as leeway — bins carrying
298
+ * 10–32° of leeway on broad reaches, physically impossible for this
299
+ * hull (real leeway on a run is ≈0°, close-hauled in a gale ≈10–13°).
300
+ * Those bins mis-rotate the water track on every later passage until
301
+ * retrained, so they are removed outright: the EMA would blend them
302
+ * out only over many more hours than a clean restart. The current-tier
303
+ * training gate (§6.1) and the computeObservation plausibility bound
304
+ * prevent new poison of this shape.
305
+ */
306
+ const DATA_MIGRATIONS = [
307
+ {
308
+ version: 3,
309
+ run: (db) => {
310
+ db.exec("DELETE FROM dr_matrix_bins WHERE ABS(leeway_angle) > 10");
311
+ },
312
+ },
313
+ ];
314
+
315
+ /**
316
+ * Applies pending data migrations, gated on the stored schema version.
317
+ *
318
+ * @param {import("node:sqlite").DatabaseSync} db
319
+ * @returns {void}
320
+ */
321
+ function migrateData(db) {
322
+ const stored = Number(getState(db, "schema_version") ?? 0);
323
+ for (const step of DATA_MIGRATIONS) {
324
+ if (stored < step.version) step.run(db);
325
+ }
326
+ }
327
+
286
328
  /**
287
329
  * Reads a scalar value from `dr_state_store`.
288
330
  *
@@ -51,6 +51,14 @@ const MAX_AGE_MS = 24 * 3600 * 1000;
51
51
  /** Samples required before the snapshot resolves (below: not a current). */
52
52
  const MIN_SAMPLES = 10;
53
53
 
54
+ /**
55
+ * How long (ms) a source-mismatched fix is ignored before the sampler
56
+ * gives up on the baseline's source and adopts the new one (the old
57
+ * receiver went silent — better to restart the differential on the
58
+ * surviving antenna than to never sample again).
59
+ */
60
+ const MAX_BASELINE_STALE_MS = 60 * 1000;
61
+
54
62
  /**
55
63
  * Creates the derived-current state.
56
64
  *
@@ -68,16 +76,32 @@ function createDerivedCurrentState() {
68
76
  }
69
77
 
70
78
  /**
71
- * Feeds one tick's sensors into the EWMA. The ground vector comes from
79
+ * Feeds one GPS fix into the EWMA. The ground vector comes from
72
80
  * consecutive GPS fixes (position-differential SOG/COG — the ground
73
81
  * truth, per the calibration report §7); the water vector from STW and
74
82
  * true heading. Both must be present and sane for a sample; the stored
75
- * fix refreshes either way so the next differential starts here.
83
+ * fix refreshes only when a sample is actually taken (or the baseline
84
+ * is first adopted) so the ≥ MIN_GPS_INTERVAL_S differential can form
85
+ * across a healthy 1 Hz fix stream — refreshing the baseline on every
86
+ * cadence-rejected tick meant the EWMA could only sample across
87
+ * position-null gaps — and those paired fixes from different receivers,
88
+ * producing a phantom 1.1 kn @ ~131° "current" for 55 h on the
89
+ * 2026-09-11…14 Niue→Vava'u trial (see the sampler rules below).
90
+ *
91
+ * Fixes are attributed to their `$source` when the caller supplies one
92
+ * (multi-GPS installs flit between antennas metres apart at 1 Hz each —
93
+ * a cross-source differential is antenna separation, not current), and
94
+ * timestamps come from the fix itself (`fixTimeMs`) rather than the
95
+ * tick clock, so a paused feed never yields a zero-displacement
96
+ * "sample" against a refreshed clock.
76
97
  *
77
98
  * @param {object} st - state from {@link createDerivedCurrentState}
78
99
  * @param {object} s - per-tick snapshot
79
100
  * @param {number} s.tMs - epoch ms of this tick
80
101
  * @param {{latitude:number, longitude:number}|null} s.gps - latest GPS fix
102
+ * @param {number|null} [s.fixTimeMs] - epoch ms of the fix itself; falls
103
+ * back to s.tMs when the caller has no fix timestamp
104
+ * @param {string|null} [s.source] - `$source` of the fix delta, when known
81
105
  * @param {number|null} s.stwKn - speed through water (kn)
82
106
  * @param {number|null} s.headingTrueDeg - true heading (deg [0,360))
83
107
  * @returns {{sampled: boolean, reason: string|null}} why no sample was
@@ -85,16 +109,44 @@ function createDerivedCurrentState() {
85
109
  */
86
110
  function updateDerivedCurrent(st, s) {
87
111
  if (!s.gps) return { sampled: false, reason: "no-gps" };
88
- const dtS = (s.tMs - (st.lastGps?.tMs ?? 0)) / 1000;
112
+ const fixTMs = Number.isFinite(s.fixTimeMs) ? s.fixTimeMs : s.tMs;
113
+ const source = typeof s.source === "string" && s.source ? s.source : null;
114
+
115
+ // Antenna flapping: a differential between two different receivers is
116
+ // antenna separation measured as speed, not current. Hold the baseline
117
+ // (it belongs to the source that is still streaming) and ignore the
118
+ // interloper — unless the baseline's own source has gone quiet for so
119
+ // long that adopting the new source is the only way forward.
120
+ const baselineStaleMs = fixTMs - (st.lastGps?.tMs ?? 0);
121
+ if (
122
+ st.lastGps &&
123
+ source != null &&
124
+ st.lastGps.source != null &&
125
+ source !== st.lastGps.source &&
126
+ baselineStaleMs < MAX_BASELINE_STALE_MS
127
+ ) {
128
+ return { sampled: false, reason: "source" };
129
+ }
130
+
131
+ const dtS = (fixTMs - (st.lastGps?.tMs ?? 0)) / 1000;
132
+ if (!st.lastGps || dtS < MIN_GPS_INTERVAL_S) {
133
+ if (!st.lastGps) {
134
+ st.lastGps = {
135
+ latitude: s.gps.latitude,
136
+ longitude: s.gps.longitude,
137
+ tMs: fixTMs,
138
+ source,
139
+ };
140
+ }
141
+ return { sampled: false, reason: "interval" };
142
+ }
89
143
  const prev = st.lastGps;
90
144
  st.lastGps = {
91
145
  latitude: s.gps.latitude,
92
146
  longitude: s.gps.longitude,
93
- tMs: s.tMs,
147
+ tMs: fixTMs,
148
+ source,
94
149
  };
95
- if (!prev || dtS < MIN_GPS_INTERVAL_S) {
96
- return { sampled: false, reason: "interval" };
97
- }
98
150
  if (s.stwKn == null || s.stwKn < MIN_STW_KN) {
99
151
  return { sampled: false, reason: "stw" };
100
152
  }
@@ -134,7 +186,7 @@ function updateDerivedCurrent(st, s) {
134
186
  st.vKn += (v - st.vKn) * alpha;
135
187
  }
136
188
  st.sampleCount += 1;
137
- st.lastSampleMs = s.tMs;
189
+ st.lastSampleMs = fixTMs;
138
190
  return { sampled: true, reason: null };
139
191
  }
140
192
 
@@ -152,8 +204,11 @@ function updateDerivedCurrent(st, s) {
152
204
  */
153
205
  function derivedCurrentSnapshot(st, nowMs) {
154
206
  if (st.sampleCount < MIN_SAMPLES) return null;
155
- const ageMs = nowMs - st.lastSampleMs;
156
- if (ageMs < 0 || ageMs > MAX_AGE_MS) return null;
207
+ // Fix timestamps can sit slightly ahead of the wall clock (GPS time
208
+ // vs Pi time): clamp rather than treat a small negative age as
209
+ // "impossible" — the carry decay must still flow.
210
+ const ageMs = Math.max(0, nowMs - st.lastSampleMs);
211
+ if (ageMs > MAX_AGE_MS) return null;
157
212
  const drift =
158
213
  Math.hypot(st.uKn, st.vKn) * Math.exp(-ageMs / (CARRY_TAU_S * 1000));
159
214
  if (!Number.isFinite(drift) || drift < 1e-6) {
@@ -177,4 +232,5 @@ module.exports = {
177
232
  CARRY_TAU_S,
178
233
  MAX_AGE_MS,
179
234
  MIN_SAMPLES,
235
+ MAX_BASELINE_STALE_MS,
180
236
  };
package/plugin/engine.js CHANGED
@@ -65,6 +65,12 @@ class DeadReckoningEngine {
65
65
  this.tripLogNm = opts.tripLogNm ?? 0;
66
66
  /** @type {number|null} seconds since the DR origin was last reset */
67
67
  this.elapsedSinceOriginS = 0;
68
+ /** @type {number} seconds spent under way since the DR origin was last
69
+ * reset — the uncertainty cone's current-knowledge term grows only
70
+ * while the vessel is making way: at anchor/moored the ground holds
71
+ * the boat, so unknown current cannot accumulate DR error (a week on
72
+ * the hook must not paint a 100+ NM circle around the boat). */
73
+ this.underwaySinceOriginS = 0;
68
74
  /** @type {number} water-track log accumulated since the last snap-to-fix (nm)
69
75
  * — SPEC §8 uses elapsed *distance* run, not clock time, as the
70
76
  * uncertainty-polygon growth axis (DR error compounds with distance
@@ -91,6 +97,7 @@ class DeadReckoningEngine {
91
97
  snapToFix(fix, originErrorNm = 0) {
92
98
  this.origin = { latitude: fix.latitude, longitude: fix.longitude };
93
99
  this.elapsedSinceOriginS = 0;
100
+ this.underwaySinceOriginS = 0;
94
101
  this.logNmSinceOrigin = 0;
95
102
  this.originErrorNm = Math.max(0, originErrorNm);
96
103
  }
@@ -121,6 +128,9 @@ class DeadReckoningEngine {
121
128
  * @param {number} inputs.leewayDeg - learned leeway angle (degrees, + to leeward)
122
129
  * @param {number} inputs.speedLoss - learned speed-loss fraction [0,1]
123
130
  * @param {{setTrue: number, drift: number}} [inputs.current] - resolved current (deg true, kn)
131
+ * @param {boolean} [inputs.underway=true] - whether the vessel is under
132
+ * way this tick (from `navigation.state`); gates the under-way clock
133
+ * the uncertainty cone grows on, not the wall-clock one
124
134
  * @param {number} [inputs.dtS=1] - tick interval in seconds
125
135
  * @returns {{latitude: number, longitude: number}|null} new position, or null if no origin
126
136
  */
@@ -150,6 +160,7 @@ class DeadReckoningEngine {
150
160
  this.logNm += effectiveStw * hours;
151
161
  this.tripLogNm += effectiveStw * hours;
152
162
  this.elapsedSinceOriginS += dtS;
163
+ if (inputs.underway ?? true) this.underwaySinceOriginS += dtS;
153
164
  this.logNmSinceOrigin += effectiveStw * hours;
154
165
  return pos;
155
166
  }
@@ -134,6 +134,7 @@ function loadObservationsById(db, helpers, lopIds, cplIds) {
134
134
  assumed_lon: row.assumed_lon,
135
135
  azimuth_true: row.azimuth_true,
136
136
  intercept_nm: row.intercept_nm ?? 0,
137
+ lop_type: row.lop_type ?? null,
137
138
  timestamp_ms: Date.parse(row.timestamp),
138
139
  });
139
140
  }
@@ -262,18 +263,19 @@ function advanceToLatest(observations, advance) {
262
263
  * bad sight or bad input, not navigation — 69°S implied ~140 kn. But
263
264
  * legitimate DR drift accumulates ~0.5–1 nm/h (sea trials measured
264
265
  * 47 nm in 4.5 days with a current tier, 85 nm cold), so the cap must
265
- * grow with the hours since the origin was set — a flat cap trains
266
+ * grow with the hours under way since the origin was set — anchored
267
+ * time doesn't count (the ground holds the boat) — a flat cap trains
266
268
  * crews to force-confirm, defeating the guard for the teleport case
267
269
  * it exists for.
268
270
  *
269
271
  * @param {number} maxDisplacementNm - configured flat cap (nm)
270
- * @param {number} elapsedSinceOriginS - seconds since the origin was
271
- * set (integration time)
272
+ * @param {number} underwaySinceOriginS - seconds under way since the
273
+ * origin was set (integration time)
272
274
  * @returns {number} cap in nm
273
275
  */
274
- function fixSanityCapNm(maxDisplacementNm, elapsedSinceOriginS) {
275
- const hours = Number.isFinite(elapsedSinceOriginS)
276
- ? Math.max(0, elapsedSinceOriginS) / 3600
276
+ function fixSanityCapNm(maxDisplacementNm, underwaySinceOriginS) {
277
+ const hours = Number.isFinite(underwaySinceOriginS)
278
+ ? Math.max(0, underwaySinceOriginS) / 3600
277
279
  : 0;
278
280
  return Math.max(maxDisplacementNm, 1.5 * hours);
279
281
  }