@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.
- package/CHANGELOG.md +218 -0
- package/SPEC.md +8 -2
- package/doc/dr-bearing-to.png +0 -0
- package/doc/dr-celestial.png +0 -0
- package/doc/dr-map.png +0 -0
- package/doc/dr-mobile.png +0 -0
- package/package.json +2 -1
- package/plugin/db.js +43 -1
- package/plugin/derived-current.js +66 -10
- package/plugin/engine.js +11 -0
- package/plugin/fix-pipeline.js +8 -6
- package/plugin/fixes.js +97 -6
- package/plugin/index.js +262 -75
- package/plugin/publish-gate.js +121 -0
- package/plugin/training.js +30 -6
- package/public/dr-app.js +515 -77
- package/public/dr-current-panel.js +11 -5
- package/public/dr-ext-host-core.js +472 -0
- package/public/dr-ext-host.js +1098 -0
- package/public/dr-fix-panel.js +9 -5
- package/public/dr-map-view.js +312 -111
- package/public/dr-pending-list.js +8 -4
- package/public/dr-sight-panel.js +71 -10
- package/public/dr-signalk-stream.js +124 -12
- package/public/dr-theme.js +41 -1
- package/public/dr-viewmodel.js +213 -7
- package/public/vendor/plotterext-bus/README.md +9 -4
- package/public/vendor/plotterext-bus/chunk-RED55KML.js +112 -0
- package/public/vendor/plotterext-bus/host.js +29 -0
- package/tests/db.test.js +41 -1
- package/tests/derived-current.test.js +128 -0
- package/tests/dr-app-layout.test.js +311 -0
- package/tests/dr-chartwork.test.js +276 -0
- package/tests/dr-ext-host-core.test.js +315 -0
- package/tests/dr-ext-host.test.js +405 -0
- package/tests/dr-form-submit.test.js +99 -0
- package/tests/dr-recenter.test.js +13 -5
- package/tests/dr-signalk-stream.test.js +172 -0
- package/tests/dr-track-style.test.js +77 -0
- package/tests/dr-viewmodel.test.js +8 -4
- package/tests/engine.test.js +19 -0
- package/tests/fix-pipeline.test.js +4 -1
- package/tests/fixes.test.js +155 -0
- package/tests/plugin.test.js +317 -11
- package/tests/publish-gate.test.js +104 -0
- package/tests/training.test.js +45 -0
- package/tools/replay-passage.js +1 -0
- package/tools/webapp-screenshot-harness.html +231 -0
- 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
|
|
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.
|
package/doc/dr-bearing-to.png
CHANGED
|
Binary file
|
package/doc/dr-celestial.png
CHANGED
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
156
|
-
|
|
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
|
}
|
package/plugin/fix-pipeline.js
CHANGED
|
@@ -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 —
|
|
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}
|
|
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,
|
|
275
|
-
const hours = Number.isFinite(
|
|
276
|
-
? Math.max(0,
|
|
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
|
}
|