@meri-imperiumi/signalk-dead-reckoning 0.8.0 → 0.10.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 +200 -0
- package/SPEC.md +7 -3
- package/package.json +3 -2
- package/plugin/current.js +73 -47
- package/plugin/db.js +43 -1
- package/plugin/derived-current.js +236 -0
- package/plugin/engine.js +11 -0
- package/plugin/fix-pipeline.js +25 -0
- package/plugin/fixes.js +97 -6
- package/plugin/index.js +835 -204
- package/plugin/logbook.js +17 -7
- package/plugin/publish-gate.js +121 -0
- package/plugin/resource-polar.js +316 -0
- package/plugin/training.js +50 -6
- package/plugin/uncertainty.js +4 -0
- package/tests/current.test.js +96 -27
- package/tests/db.test.js +41 -1
- package/tests/derived-current.test.js +389 -0
- package/tests/engine.test.js +19 -0
- package/tests/fake-app.js +11 -0
- package/tests/fix-pipeline.test.js +21 -0
- package/tests/fixes.test.js +155 -0
- package/tests/logbook.test.js +47 -0
- package/tests/plugin.test.js +2096 -839
- package/tests/publish-gate.test.js +104 -0
- package/tests/resource-polar.test.js +279 -0
- package/tests/training.test.js +88 -1
- package/tests/uncertainty.test.js +14 -0
- package/tools/replay-passage.js +77 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,206 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.0] - 2026-09-17
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **The derived-current EWMA can now sample on a healthy GPS feed — and
|
|
14
|
+
only ever pairs fixes from one receiver.** The sampler refreshed its
|
|
15
|
+
differential baseline on every tick, so with GPS fixes arriving faster
|
|
16
|
+
than the 5 s minimum interval the ≥5 s differential could only form
|
|
17
|
+
across `navigation.position` null gaps — and those gap-spanning samples
|
|
18
|
+
routinely paired fixes from *different* receivers. On the
|
|
19
|
+
2026-09-11…14 Niue→Vava'u trial that produced a phantom "current" of
|
|
20
|
+
1.1 kn @ ~131° for 55 h (a quasi-fixed ~140 m offset between two
|
|
21
|
+
position providers ÷ the multi-minute sampling gap — heading-
|
|
22
|
+
independent, which is why it didn't look like leeway), dragging DR to
|
|
23
|
+
34.7 nm of error at arrival while the real current was 0.6 kn @ 006°.
|
|
24
|
+
Replaying the same passage with the sampler working ends at 8.7 nm.
|
|
25
|
+
The baseline now holds across cadence-rejected ticks (samples form at
|
|
26
|
+
the 5 s minimum instead of only across null gaps), differentials use
|
|
27
|
+
the fix's own timestamp (a paused feed can no longer fabricate
|
|
28
|
+
zero-ground "currents" from a running tick clock), fixes carry their
|
|
29
|
+
`$source` so a differential never spans two receivers, and the tick
|
|
30
|
+
path feeds the sampler (and training's ground truth) the server's
|
|
31
|
+
priority-filtered tree position instead of the last-writer-wins raw
|
|
32
|
+
delta — on multi-GPS installs the raw subscription stream flits
|
|
33
|
+
between every provider of the path (this one: ZG100, Orca, WIDELINK
|
|
34
|
+
AIS, YDNR, Cerbo, the RUTX11's indoor GNSS, and the own-boat
|
|
35
|
+
meshtastic node's LoRa-recycled position), several of which sit
|
|
36
|
+
100 m+ from the truth. Verified by capturing the live subscription
|
|
37
|
+
stream: ranked-below sources still deliver; priorities only gate the
|
|
38
|
+
merged tree.
|
|
39
|
+
- **A bearing to a known object is now a ray constraint in the fix
|
|
40
|
+
resolver, not an infinite line.** On the same trial a running fix
|
|
41
|
+
against a bearing to AIS vessel MATILDA (115.5° true, 4.1 nm)
|
|
42
|
+
projected the advanced fix onto the far side of the line: a "fix"
|
|
43
|
+
2.7 nm past the vessel from which the vessel bore the reciprocal of
|
|
44
|
+
the sighted bearing. The resolver now rejects points past the
|
|
45
|
+
object's anchor — in single-observation running fixes, bearing×
|
|
46
|
+
bearing intersections, bearing×range circle picks and the
|
|
47
|
+
parallel-bearings midline — with an error explaining that the run and
|
|
48
|
+
the bearing disagree (celestial intercept LOPs stay full lines; a
|
|
49
|
+
100 m along-ray slack keeps close-range sights with ordinary compass
|
|
50
|
+
slop from hard-rejecting). The
|
|
51
|
+
`lop_type` threads from the persisted row through running-fix
|
|
52
|
+
advancement into the resolver, and REST resolve/confirm surface the
|
|
53
|
+
rejection as the 400 message.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
- **Training Mode no longer runs on model currents (tier 3/4), only on
|
|
57
|
+
observed ones (manual tier 1, derived tier 2).** A wrong current —
|
|
58
|
+
model *or* phantom — gets compensated by the trainer as physically
|
|
59
|
+
impossible leeway: the Niue→Vava'u trial's phantom tier-2 current was
|
|
60
|
+
absorbed as 15–18° of leeway on broad reaches (thousands of live
|
|
61
|
+
hits), which then mis-rotate the water track whenever the current
|
|
62
|
+
resolves correctly. Same failure shape as the previous trial's tier-5
|
|
63
|
+
zero-current poisoning, now gated for the model tiers too. A write-time
|
|
64
|
+
plausibility bound rejects observations implying |leeway| > 15° (input
|
|
65
|
+
disagreement, not hydrodynamics), and schema v3 ships a one-time data
|
|
66
|
+
migration removing bins with |leeway| > 10° accumulated during the two
|
|
67
|
+
poisoned trials — no manual SQL on the boat.
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
- **The DR output is no longer published to the bus on every
|
|
71
|
+
integration tick.** Each published delta costs the Signal K server
|
|
72
|
+
fanout — delta-cache routing plus serialization to every websocket
|
|
73
|
+
client and logging plugin subscribed to the context — and most of
|
|
74
|
+
the DR output (log, trip log, current vector, uncertainty radius,
|
|
75
|
+
elapsed-since-fix) is slow-moving data nobody needs at 1 Hz. The
|
|
76
|
+
tick still integrates every `tickIntervalMs`; the published set is
|
|
77
|
+
batched to every `publish.everyTicks`-th tick (new setting, default
|
|
78
|
+
2 → one delta every 2 s at the default 1 s tick; 1 restores
|
|
79
|
+
per-tick publishing). Notable transitions publish immediately,
|
|
80
|
+
within the tick that observed them: method/state/active flips, paths
|
|
81
|
+
appearing or disappearing (e.g. the STW echo dropping when the
|
|
82
|
+
polar fallback takes over), and values crossing to/from null (e.g.
|
|
83
|
+
the divergence readout clearing at anchor). The shadow vessel's
|
|
84
|
+
synthetic target follows the same cadence.
|
|
85
|
+
|
|
86
|
+
### Fixed
|
|
87
|
+
- **The logbook access-request flow now targets the logbook URL's own
|
|
88
|
+
host and port.** It previously used a separate `logbook.baseUrl`
|
|
89
|
+
setting that wasn't even exposed in the plugin settings UI and
|
|
90
|
+
defaulted to `http://localhost:3000` — on installs where that port
|
|
91
|
+
isn't the Signal K server (Grafana there), its 404 read as "open
|
|
92
|
+
server", the tokenless probe then hit the real server's auth gate,
|
|
93
|
+
and logbook writes parked with "Logbook writes need authentication
|
|
94
|
+
this plugin cannot obtain" even though the server's access-request
|
|
95
|
+
flow was fully available. The `logbook.baseUrl` config option is
|
|
96
|
+
gone: the origin is derived from the configured `logbook.url`. An
|
|
97
|
+
unparseable URL parks the flow with a message naming the fix instead
|
|
98
|
+
of crashing the write path.
|
|
99
|
+
- **The uncertainty cone no longer grows while anchored or moored.**
|
|
100
|
+
The current-knowledge term scaled with wall-clock time since the
|
|
101
|
+
last fix, so a week on the hook painted a 100+ NM circle around a
|
|
102
|
+
boat the ground was holding perfectly still. The engine now tracks
|
|
103
|
+
a separate under-way clock (`underwaySinceOriginS`, gated by
|
|
104
|
+
`navigation.state` anchored/moored, persisted across restarts) and
|
|
105
|
+
the cone grows only on that axis; wall-clock `since last fix` still
|
|
106
|
+
counts anchor time (published unchanged). The same axis now also
|
|
107
|
+
drives the fix sanity cap (the boat can't have traveled while
|
|
108
|
+
anchored — a far fix is more suspect, not less) and the learned
|
|
109
|
+
`dr_corrections` deviation rate (anchor time between fixes no
|
|
110
|
+
longer deflates it).
|
|
111
|
+
|
|
112
|
+
## [0.9.0] - 2026-09-10
|
|
113
|
+
|
|
114
|
+
### Added
|
|
115
|
+
- **Derived-current tier (SPEC §6.2 tier 2)**: an exponentially-weighted
|
|
116
|
+
mean of the boat's own GPS-vs-water-track residual, sampled every
|
|
117
|
+
tick while GPS is trusted and the water track is usable (STW floor,
|
|
118
|
+
interval, glitch/outlier bounds), carried forward with exponential
|
|
119
|
+
decay when GPS degrades and TTL-bounded (24 h) so the resolver falls
|
|
120
|
+
through to the model tiers after. Zero-configuration: it outranks
|
|
121
|
+
the Weather API and pilot charts as soon as ~10 samples exist (the
|
|
122
|
+
first minutes of a passage), `environment.current` and `/status`
|
|
123
|
+
surface it as source `derived`, and the uncertainty cone uses a
|
|
124
|
+
tighter per-tier residual (0.2 kn). Sea-trial replay
|
|
125
|
+
(Aitutaki→Niue 2026-08-30→09-05): final DR error 10.9 nm over 622 nm
|
|
126
|
+
vs 47.3 nm for the live tier-3 configuration and 85.2 nm for the
|
|
127
|
+
zero vector — landfall-visible DR on a trade-wind passage.
|
|
128
|
+
|
|
129
|
+
### Fixed
|
|
130
|
+
- **The weather-current client calls the Weather API in-process**
|
|
131
|
+
(`app.weatherApi.getForecasts()`, the same instance the REST routes
|
|
132
|
+
wrap — mirrors signalk-energy-predictor) instead of an HTTP loopback
|
|
133
|
+
that defaulted to `localhost:3000` — a port Grafana answers on this
|
|
134
|
+
install, so every poll 404ed and tier 3 never resolved
|
|
135
|
+
("Weather current fetch failed: weather API returned 404 — using
|
|
136
|
+
zero current"). No base URL, ports or auth tokens involved; the
|
|
137
|
+
`weatherCurrent.baseUrl` config option is gone.
|
|
138
|
+
- **The celestial sight plausibility gate measures the distance from
|
|
139
|
+
the DR origin, not from the sight's assumed position** (sea trial
|
|
140
|
+
2026-08-31 root-cause follow-up): a small intercept at a wrong
|
|
141
|
+
assumed position drew the LOP an ocean away and the gate never saw
|
|
142
|
+
it. Non-noon sights now gate on the perpendicular distance from the
|
|
143
|
+
DR origin to the LOP (floored by the intercept magnitude), mirroring
|
|
144
|
+
the `/fix/lop` bearing gate; noon sights gate on the origin-vs-
|
|
145
|
+
reduction latitude difference.
|
|
146
|
+
- **The fix-confirm sanity cap now grows with DR time-since-origin**
|
|
147
|
+
instead of being a flat 100 NM: legitimate fixes after days GPS-less
|
|
148
|
+
(DR drifts ~0.5–1 NM/h; the trials measured 47 NM in 4.5 days with a
|
|
149
|
+
current tier, 85 NM cold) were being rejected, training crews to
|
|
150
|
+
habitually force-confirm — defeating the guard for the teleport
|
|
151
|
+
case it exists for. The cap is now `max(100 NM, 1.5 kn × hours since
|
|
152
|
+
origin)`, mirrored in `/fix/resolve`'s `gross` preview flag.
|
|
153
|
+
- **Training Mode is suspended while the resolved current is tier 5
|
|
154
|
+
(zero vector — current unknown)** (SPEC §6.1/§6.2): training with an
|
|
155
|
+
unknown current bakes it into the leeway/speed bins as fake
|
|
156
|
+
corrections and over-applies them later (Huahine→Aitutaki backtest:
|
|
157
|
+
72 vs 65 nm cold; both trials' regressions). With the new tier-2
|
|
158
|
+
derived current self-bootstrapping in the first minutes of a
|
|
159
|
+
passage, a current-resolved matrix train no longer requires a
|
|
160
|
+
weather provider. The replay tool's learning variants accordingly
|
|
161
|
+
run with the derived current ("training with zero current" is no
|
|
162
|
+
longer a reachable live configuration), and a new `derived` variant
|
|
163
|
+
(cold + tier 2, no training) isolates the tier's contribution in
|
|
164
|
+
backtests.
|
|
165
|
+
- **Logbook write-through no longer floods the server when
|
|
166
|
+
authentication cannot be obtained** (reported 2026-09-11 on
|
|
167
|
+
lille-oe-pi: a continuous ~10 req/s stream of
|
|
168
|
+
`POST /plugins/signalk-logbook/logs` 401s at anchor; the 91-byte
|
|
169
|
+
bodies identified the server's plugin-route admin gate answering a
|
|
170
|
+
tokenless "open server" probe). The access-request client read
|
|
171
|
+
*any* non-OK response (403 disallowed device requests, 429 rate
|
|
172
|
+
limit, 400 duplicate, 503, …) as "no access-request flow", built a
|
|
173
|
+
tokenless client, ate the admin gate's 401, and re-ran the whole
|
|
174
|
+
cycle forever. The client now distinguishes: 403 → "forbidden"
|
|
175
|
+
(park; the plugin status names the two ways out — allow device
|
|
176
|
+
access requests on the server, or paste a token); 404/501 → a
|
|
177
|
+
single tokenless probe, terminal on rejection; everything else →
|
|
178
|
+
"unreachable", retried on an exponential backoff (30 s doubling to
|
|
179
|
+
a 16 min cap, configurable via `logbook.retryBackoffMs`) — never an
|
|
180
|
+
open-server assumption, never a loop. A rejected config token or
|
|
181
|
+
tokenless probe parks instead of resurrecting the same credentials
|
|
182
|
+
in a loop. A pending access request is persisted and resumed across
|
|
183
|
+
restarts instead of re-filed (the server rejects duplicates), and an
|
|
184
|
+
approval granted while the plugin was down is picked up on the
|
|
185
|
+
first poll. Queued entries are never dropped by any of this.
|
|
186
|
+
- **The "paddlewheel appears fouled" alert no longer fires on breeze
|
|
187
|
+
over a moored boat** while `navigation.state` hasn't landed in the
|
|
188
|
+
delta cache yet (or on installs without an autostate source): a live
|
|
189
|
+
SOG reading below the moving threshold is now an outright "not
|
|
190
|
+
making way" verdict, and wind only corroborates fouling when GPS is
|
|
191
|
+
silent.
|
|
192
|
+
|
|
193
|
+
### Added
|
|
194
|
+
- **Polar speed fallback now also works without
|
|
195
|
+
signalk-polar-performance-plugin**: when its `performance.polarSpeed`
|
|
196
|
+
feed is absent, DR computes the speed in-process from the active
|
|
197
|
+
`polars` resource (the signalk-polar-management contract — the
|
|
198
|
+
`polars.activePolar` pointer and `polars.performanceFactor` derating
|
|
199
|
+
multiplier) interpolated from water-referenced true wind
|
|
200
|
+
(`environment.wind.speedTrue` / `angleTrueWater`). The resource path
|
|
201
|
+
shares the existing running-average window and staleness cutoff, is
|
|
202
|
+
zero-configuration (engages when an active polar is selected, stays
|
|
203
|
+
dormant otherwise), and a live `performance.polarSpeed` feed still
|
|
204
|
+
outranks it. The DR state's `speedSource` distinguishes `polar`
|
|
205
|
+
(delta feed) from `polar-resource`, the sensor-health alert names the
|
|
206
|
+
active resource id, and GET /status mirrors the loaded polar. A dead
|
|
207
|
+
wind instrument ages the average out to the honest idle branch
|
|
208
|
+
instead of integrating a frozen wind at a frozen speed.
|
|
209
|
+
|
|
10
210
|
## [0.8.0] - 2026-09-09
|
|
11
211
|
|
|
12
212
|
### Fixed
|
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):
|
|
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,8 +290,8 @@ 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.
|
|
290
|
-
3. Sparse Forecast — bilinear/temporal-interpolated radio GRIB vectors.
|
|
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.
|
|
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).
|
|
293
297
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meri-imperiumi/signalk-dead-reckoning",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"recommends": [
|
|
35
35
|
"@meri-imperiumi/signalk-autostate",
|
|
36
36
|
"@meri-imperiumi/signalk-logbook",
|
|
37
|
-
"signalk-polar-performance-plugin"
|
|
37
|
+
"signalk-polar-performance-plugin",
|
|
38
|
+
"signalk-polar-management"
|
|
38
39
|
],
|
|
39
40
|
"screenshots": [
|
|
40
41
|
"./doc/dr-map.png",
|
package/plugin/current.js
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* 1. **Manual override** — watchstander input with a valid TTL
|
|
8
8
|
* (`environment.current`). Not yet wired to an input path; the
|
|
9
9
|
* resolver accepts it so the precedence is explicit and testable.
|
|
10
|
-
* 2.
|
|
10
|
+
* 2. **Derived residual** — EWMA of the boat's own GPS-vs-water-track
|
|
11
|
+
* residual (`derived-current.js`), learned while GPS is trusted and
|
|
12
|
+
* carried forward with decay when GPS degrades. The boat's own
|
|
13
|
+
* observation outranks model products (sea trial 2026-08-30→09-05:
|
|
14
|
+
* ~11 nm DR error over 622 nm vs 47 nm for tier 3).
|
|
11
15
|
* 3. **Signal K Weather API** (`/signalk/v2/api/weather/forecasts/point`)
|
|
12
16
|
* — a provider (e.g. a GRIB another process already downloaded)
|
|
13
17
|
* serves point forecasts carrying `current: {set (rad), drift (m/s)}`.
|
|
@@ -102,13 +106,17 @@ function parseWeatherCurrent(points, nowMs) {
|
|
|
102
106
|
* @param {object} [input]
|
|
103
107
|
* @param {{setTrue: number, drift: number, validUntilMs: number}|null} [input.manual]
|
|
104
108
|
* tier 1: watchstander override, honored while its TTL lasts
|
|
109
|
+
* @param {{setTrue: number, drift: number, validUntilMs: number}|null} [input.derived]
|
|
110
|
+
* tier 2: EWMA of the boat's own GPS-vs-water-track residual
|
|
111
|
+
* (`derived-current.js`), TTL-bounded and decayed while GPS is
|
|
112
|
+
* degraded — the boat's own observation outranks model products
|
|
105
113
|
* @param {{setTrue: number, drift: number, validUntilMs: number}|null} [input.weather]
|
|
106
114
|
* tier 3: Weather API cache entry from `WeatherCurrentClient.currentAt`
|
|
107
115
|
* @param {((ctx: object) => ({setTrue: number, drift: number}|null))|null} [input.pilotLookup]
|
|
108
116
|
* tier 4: (month,lat,lon)→vector lookup into `offline_pilot_currents`
|
|
109
117
|
* @param {object} [input.pilotCtx] - context for the pilot lookup
|
|
110
118
|
* @param {number} [input.nowMs] - epoch ms; defaults to Date.now()
|
|
111
|
-
* @returns {{setTrue: number, drift: number, tier: 1|3|4|5, source: string}}
|
|
119
|
+
* @returns {{setTrue: number, drift: number, tier: 1|2|3|4|5, source: string}}
|
|
112
120
|
* drift in knots, setTrue in deg true (direction the current flows toward)
|
|
113
121
|
*/
|
|
114
122
|
function resolveCurrent(input = {}) {
|
|
@@ -125,6 +133,15 @@ function resolveCurrent(input = {}) {
|
|
|
125
133
|
source: "manual",
|
|
126
134
|
};
|
|
127
135
|
}
|
|
136
|
+
// Tier 2: derived residual — the boat's own GPS-vs-water-track EWMA.
|
|
137
|
+
if (valid(input.derived)) {
|
|
138
|
+
return {
|
|
139
|
+
setTrue: normalizeDeg360(input.derived.setTrue ?? 0),
|
|
140
|
+
drift: input.derived.drift,
|
|
141
|
+
tier: 2,
|
|
142
|
+
source: "derived",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
128
145
|
// Tier 3: Weather API (sparse forecast GRIB via a weather provider).
|
|
129
146
|
if (valid(input.weather)) {
|
|
130
147
|
return {
|
|
@@ -157,34 +174,40 @@ function resolveCurrent(input = {}) {
|
|
|
157
174
|
* 30 min) with a hard timeout, failures keep the previous cache until
|
|
158
175
|
* its TTL lapses, and `currentAt` is a synchronous cache read.
|
|
159
176
|
*
|
|
177
|
+
* The call is **in-process** (mirrors signalk-energy-predictor): the
|
|
178
|
+
* plugin always talks to the Signal K server it runs inside —
|
|
179
|
+
* `app.weatherApi.getForecasts()` is the same WeatherApi instance the
|
|
180
|
+
* `/signalk/v2/api/weather` REST routes wrap — so weather providers
|
|
181
|
+
* (e.g. a GRIB provider) registered by other plugins answer without
|
|
182
|
+
* HTTP, auth tokens or port guessing. The old HTTP loopback defaulted
|
|
183
|
+
* to `localhost:3000`, which on this install is Grafana and 404s every
|
|
184
|
+
* `/signalk/...` path. On servers without the Weather API the client
|
|
185
|
+
* stays idle and the §6.2 resolver never sees tier 3.
|
|
186
|
+
*
|
|
160
187
|
* The response is a WeatherDataModel array; `current.set` is radians
|
|
161
188
|
* and `current.drift` m/s (converted here to deg/kn).
|
|
162
189
|
*/
|
|
163
190
|
class WeatherCurrentClient {
|
|
164
191
|
/**
|
|
165
192
|
* @param {object} [opts]
|
|
166
|
-
* @param {
|
|
193
|
+
* @param {object} [opts.weatherApi] - the server's `app.weatherApi`
|
|
194
|
+
* (needs a `getForecasts` function)
|
|
167
195
|
* @param {number} [opts.intervalMs=1800000] - poll interval
|
|
168
196
|
* @param {number} [opts.count=6] - forecast entries to request
|
|
169
|
-
* @param {number} [opts.timeoutMs=10000] - per-fetch
|
|
197
|
+
* @param {number} [opts.timeoutMs=10000] - per-fetch timeout
|
|
170
198
|
* @param {number} [opts.validityFactor=4] - cache TTL = interval × this
|
|
171
199
|
* @param {() => ({latitude: number, longitude: number}|null)} [opts.getPosition]
|
|
172
200
|
* @param {(message: string) => void} [opts.onStatus]
|
|
173
|
-
* @param {typeof fetch} [opts.fetchFn=globalThis.fetch]
|
|
174
201
|
* @param {() => number} [opts.now=Date.now]
|
|
175
202
|
*/
|
|
176
203
|
constructor(opts = {}) {
|
|
177
|
-
this.
|
|
178
|
-
/\/+$/,
|
|
179
|
-
"",
|
|
180
|
-
);
|
|
204
|
+
this.weatherApi = opts.weatherApi ?? null;
|
|
181
205
|
this.intervalMs = opts.intervalMs ?? 30 * 60 * 1000;
|
|
182
206
|
this.count = opts.count ?? 6;
|
|
183
207
|
this.timeoutMs = opts.timeoutMs ?? 10000;
|
|
184
208
|
this.validityFactor = opts.validityFactor ?? 4;
|
|
185
209
|
this.getPosition = opts.getPosition ?? (() => null);
|
|
186
210
|
this.onStatus = opts.onStatus ?? null;
|
|
187
|
-
this.fetchFn = opts.fetchFn ?? globalThis.fetch?.bind(globalThis);
|
|
188
211
|
this.now = opts.now ?? (() => Date.now());
|
|
189
212
|
/** @type {{setTrue: number, drift: number, fetchedAt: number, validUntilMs: number}|null} */
|
|
190
213
|
this.cache = null;
|
|
@@ -226,48 +249,51 @@ class WeatherCurrentClient {
|
|
|
226
249
|
* @returns {Promise<void>}
|
|
227
250
|
*/
|
|
228
251
|
async poll() {
|
|
229
|
-
if (this.fetching
|
|
252
|
+
if (this.fetching) return;
|
|
253
|
+
if (
|
|
254
|
+
!this.weatherApi ||
|
|
255
|
+
typeof this.weatherApi.getForecasts !== "function"
|
|
256
|
+
) {
|
|
257
|
+
return; // server has no Weather API — tier 3 simply never resolves
|
|
258
|
+
}
|
|
230
259
|
const pos = this.getPosition();
|
|
231
260
|
if (!pos) return; // no position yet — retry on the next interval
|
|
232
261
|
this.fetching = true;
|
|
233
262
|
try {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
const now = this.now();
|
|
259
|
-
this.cache = {
|
|
260
|
-
setTrue: parsed.setTrue,
|
|
261
|
-
drift: parsed.drift,
|
|
262
|
-
fetchedAt: now,
|
|
263
|
-
validUntilMs: now + this.intervalMs * this.validityFactor,
|
|
264
|
-
};
|
|
265
|
-
this.onStatus?.(
|
|
266
|
-
`Weather current: set ${parsed.setTrue.toFixed(0)}° true, drift ${parsed.drift.toFixed(2)} kn`,
|
|
267
|
-
);
|
|
268
|
-
} finally {
|
|
269
|
-
clearTimeout(timeoutId);
|
|
263
|
+
// In-process (same object the REST routes wrap): providers
|
|
264
|
+
// registered by other plugins answer directly. The timeout guards
|
|
265
|
+
// a provider doing its own network fetch under the hood.
|
|
266
|
+
let timeoutId;
|
|
267
|
+
const data = await Promise.race([
|
|
268
|
+
this.weatherApi.getForecasts(
|
|
269
|
+
{ latitude: pos.latitude, longitude: pos.longitude },
|
|
270
|
+
"point",
|
|
271
|
+
{ maxCount: this.count },
|
|
272
|
+
),
|
|
273
|
+
new Promise((_resolve, reject) => {
|
|
274
|
+
timeoutId = setTimeout(
|
|
275
|
+
() => reject(new Error("weather API timed out")),
|
|
276
|
+
this.timeoutMs,
|
|
277
|
+
);
|
|
278
|
+
}),
|
|
279
|
+
]).finally(() => clearTimeout(timeoutId));
|
|
280
|
+
if (!Array.isArray(data)) {
|
|
281
|
+
throw new Error("weather API response is not an array");
|
|
282
|
+
}
|
|
283
|
+
const parsed = parseWeatherCurrent(data, this.now());
|
|
284
|
+
if (!parsed) {
|
|
285
|
+
throw new Error("forecast carries no current data");
|
|
270
286
|
}
|
|
287
|
+
const now = this.now();
|
|
288
|
+
this.cache = {
|
|
289
|
+
setTrue: parsed.setTrue,
|
|
290
|
+
drift: parsed.drift,
|
|
291
|
+
fetchedAt: now,
|
|
292
|
+
validUntilMs: now + this.intervalMs * this.validityFactor,
|
|
293
|
+
};
|
|
294
|
+
this.onStatus?.(
|
|
295
|
+
`Weather current: set ${parsed.setTrue.toFixed(0)}° true, drift ${parsed.drift.toFixed(2)} kn`,
|
|
296
|
+
);
|
|
271
297
|
} catch (err) {
|
|
272
298
|
// Keep any previous cache; the TTL decides when it stops being
|
|
273
299
|
// trusted. Surface the failure once per poll cycle.
|
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
|
*
|