@meri-imperiumi/signalk-dead-reckoning 0.8.0 → 0.9.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 +98 -0
- package/SPEC.md +3 -3
- package/package.json +3 -2
- package/plugin/current.js +73 -47
- package/plugin/derived-current.js +180 -0
- package/plugin/fix-pipeline.js +23 -0
- package/plugin/index.js +575 -131
- package/plugin/logbook.js +17 -7
- package/plugin/resource-polar.js +316 -0
- package/plugin/training.js +26 -6
- package/plugin/uncertainty.js +4 -0
- package/tests/current.test.js +96 -27
- package/tests/derived-current.test.js +261 -0
- package/tests/fake-app.js +11 -0
- package/tests/fix-pipeline.test.js +18 -0
- package/tests/logbook.test.js +47 -0
- package/tests/plugin.test.js +1021 -70
- package/tests/resource-polar.test.js +279 -0
- package/tests/training.test.js +43 -1
- package/tests/uncertainty.test.js +14 -0
- package/tools/replay-passage.js +76 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,104 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-09-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Derived-current tier (SPEC §6.2 tier 2)**: an exponentially-weighted
|
|
14
|
+
mean of the boat's own GPS-vs-water-track residual, sampled every
|
|
15
|
+
tick while GPS is trusted and the water track is usable (STW floor,
|
|
16
|
+
interval, glitch/outlier bounds), carried forward with exponential
|
|
17
|
+
decay when GPS degrades and TTL-bounded (24 h) so the resolver falls
|
|
18
|
+
through to the model tiers after. Zero-configuration: it outranks
|
|
19
|
+
the Weather API and pilot charts as soon as ~10 samples exist (the
|
|
20
|
+
first minutes of a passage), `environment.current` and `/status`
|
|
21
|
+
surface it as source `derived`, and the uncertainty cone uses a
|
|
22
|
+
tighter per-tier residual (0.2 kn). Sea-trial replay
|
|
23
|
+
(Aitutaki→Niue 2026-08-30→09-05): final DR error 10.9 nm over 622 nm
|
|
24
|
+
vs 47.3 nm for the live tier-3 configuration and 85.2 nm for the
|
|
25
|
+
zero vector — landfall-visible DR on a trade-wind passage.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- **The weather-current client calls the Weather API in-process**
|
|
29
|
+
(`app.weatherApi.getForecasts()`, the same instance the REST routes
|
|
30
|
+
wrap — mirrors signalk-energy-predictor) instead of an HTTP loopback
|
|
31
|
+
that defaulted to `localhost:3000` — a port Grafana answers on this
|
|
32
|
+
install, so every poll 404ed and tier 3 never resolved
|
|
33
|
+
("Weather current fetch failed: weather API returned 404 — using
|
|
34
|
+
zero current"). No base URL, ports or auth tokens involved; the
|
|
35
|
+
`weatherCurrent.baseUrl` config option is gone.
|
|
36
|
+
- **The celestial sight plausibility gate measures the distance from
|
|
37
|
+
the DR origin, not from the sight's assumed position** (sea trial
|
|
38
|
+
2026-08-31 root-cause follow-up): a small intercept at a wrong
|
|
39
|
+
assumed position drew the LOP an ocean away and the gate never saw
|
|
40
|
+
it. Non-noon sights now gate on the perpendicular distance from the
|
|
41
|
+
DR origin to the LOP (floored by the intercept magnitude), mirroring
|
|
42
|
+
the `/fix/lop` bearing gate; noon sights gate on the origin-vs-
|
|
43
|
+
reduction latitude difference.
|
|
44
|
+
- **The fix-confirm sanity cap now grows with DR time-since-origin**
|
|
45
|
+
instead of being a flat 100 NM: legitimate fixes after days GPS-less
|
|
46
|
+
(DR drifts ~0.5–1 NM/h; the trials measured 47 NM in 4.5 days with a
|
|
47
|
+
current tier, 85 NM cold) were being rejected, training crews to
|
|
48
|
+
habitually force-confirm — defeating the guard for the teleport
|
|
49
|
+
case it exists for. The cap is now `max(100 NM, 1.5 kn × hours since
|
|
50
|
+
origin)`, mirrored in `/fix/resolve`'s `gross` preview flag.
|
|
51
|
+
- **Training Mode is suspended while the resolved current is tier 5
|
|
52
|
+
(zero vector — current unknown)** (SPEC §6.1/§6.2): training with an
|
|
53
|
+
unknown current bakes it into the leeway/speed bins as fake
|
|
54
|
+
corrections and over-applies them later (Huahine→Aitutaki backtest:
|
|
55
|
+
72 vs 65 nm cold; both trials' regressions). With the new tier-2
|
|
56
|
+
derived current self-bootstrapping in the first minutes of a
|
|
57
|
+
passage, a current-resolved matrix train no longer requires a
|
|
58
|
+
weather provider. The replay tool's learning variants accordingly
|
|
59
|
+
run with the derived current ("training with zero current" is no
|
|
60
|
+
longer a reachable live configuration), and a new `derived` variant
|
|
61
|
+
(cold + tier 2, no training) isolates the tier's contribution in
|
|
62
|
+
backtests.
|
|
63
|
+
- **Logbook write-through no longer floods the server when
|
|
64
|
+
authentication cannot be obtained** (reported 2026-09-11 on
|
|
65
|
+
lille-oe-pi: a continuous ~10 req/s stream of
|
|
66
|
+
`POST /plugins/signalk-logbook/logs` 401s at anchor; the 91-byte
|
|
67
|
+
bodies identified the server's plugin-route admin gate answering a
|
|
68
|
+
tokenless "open server" probe). The access-request client read
|
|
69
|
+
*any* non-OK response (403 disallowed device requests, 429 rate
|
|
70
|
+
limit, 400 duplicate, 503, …) as "no access-request flow", built a
|
|
71
|
+
tokenless client, ate the admin gate's 401, and re-ran the whole
|
|
72
|
+
cycle forever. The client now distinguishes: 403 → "forbidden"
|
|
73
|
+
(park; the plugin status names the two ways out — allow device
|
|
74
|
+
access requests on the server, or paste a token); 404/501 → a
|
|
75
|
+
single tokenless probe, terminal on rejection; everything else →
|
|
76
|
+
"unreachable", retried on an exponential backoff (30 s doubling to
|
|
77
|
+
a 16 min cap, configurable via `logbook.retryBackoffMs`) — never an
|
|
78
|
+
open-server assumption, never a loop. A rejected config token or
|
|
79
|
+
tokenless probe parks instead of resurrecting the same credentials
|
|
80
|
+
in a loop. A pending access request is persisted and resumed across
|
|
81
|
+
restarts instead of re-filed (the server rejects duplicates), and an
|
|
82
|
+
approval granted while the plugin was down is picked up on the
|
|
83
|
+
first poll. Queued entries are never dropped by any of this.
|
|
84
|
+
- **The "paddlewheel appears fouled" alert no longer fires on breeze
|
|
85
|
+
over a moored boat** while `navigation.state` hasn't landed in the
|
|
86
|
+
delta cache yet (or on installs without an autostate source): a live
|
|
87
|
+
SOG reading below the moving threshold is now an outright "not
|
|
88
|
+
making way" verdict, and wind only corroborates fouling when GPS is
|
|
89
|
+
silent.
|
|
90
|
+
|
|
91
|
+
### Added
|
|
92
|
+
- **Polar speed fallback now also works without
|
|
93
|
+
signalk-polar-performance-plugin**: when its `performance.polarSpeed`
|
|
94
|
+
feed is absent, DR computes the speed in-process from the active
|
|
95
|
+
`polars` resource (the signalk-polar-management contract — the
|
|
96
|
+
`polars.activePolar` pointer and `polars.performanceFactor` derating
|
|
97
|
+
multiplier) interpolated from water-referenced true wind
|
|
98
|
+
(`environment.wind.speedTrue` / `angleTrueWater`). The resource path
|
|
99
|
+
shares the existing running-average window and staleness cutoff, is
|
|
100
|
+
zero-configuration (engages when an active polar is selected, stays
|
|
101
|
+
dormant otherwise), and a live `performance.polarSpeed` feed still
|
|
102
|
+
outranks it. The DR state's `speedSource` distinguishes `polar`
|
|
103
|
+
(delta feed) from `polar-resource`, the sensor-health alert names the
|
|
104
|
+
active resource id, and GET /status mirrors the loaded polar. A dead
|
|
105
|
+
wind instrument ages the average out to the honest idle branch
|
|
106
|
+
instead of integrating a frozen wind at a frozen speed.
|
|
107
|
+
|
|
10
108
|
## [0.8.0] - 2026-09-09
|
|
11
109
|
|
|
12
110
|
### Fixed
|
package/SPEC.md
CHANGED
|
@@ -269,7 +269,7 @@ Worker Thread (DR Physics)
|
|
|
269
269
|
|
|
270
270
|
### 6.1 Training Mode vs. Inference Mode
|
|
271
271
|
|
|
272
|
-
**Training Mode** — active when `isGpsReliable = true` AND `propulsion.main.state = stopped` AND paddlewheel not fouled (§6.3):
|
|
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):
|
|
273
273
|
- 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`.
|
|
274
274
|
|
|
275
275
|
**Inference Mode** — active when `isGpsReliable = false` OR OVERRIDE is manually engaged:
|
|
@@ -286,8 +286,8 @@ The paddlewheel-failure fallback is a distinct branch from the "GPS unreliable"
|
|
|
286
286
|
### 6.2 Current Hierarchy of Truth
|
|
287
287
|
|
|
288
288
|
1. Manual Override — watchstander input with valid TTL (`environment.current`).
|
|
289
|
-
2.
|
|
290
|
-
3. Sparse Forecast — bilinear/temporal-interpolated radio GRIB vectors.
|
|
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).
|
|
290
|
+
3. Sparse Forecast — bilinear/temporal-interpolated radio GRIB vectors (Signal K Weather API).
|
|
291
291
|
4. Offline Pilot Charts — static SQLite monthly historical averages (`offline_pilot_currents`).
|
|
292
292
|
5. Zero Vector — pure inertial water track (U: 0, V: 0).
|
|
293
293
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meri-imperiumi/signalk-dead-reckoning",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derived current from the boat's own GPS-vs-water-track residual
|
|
3
|
+
* (SPEC §6.2 tier 2).
|
|
4
|
+
*
|
|
5
|
+
* While GPS is trusted, every position fix minus the water-track
|
|
6
|
+
* displacement is a direct observation of the current vector. An
|
|
7
|
+
* exponentially-weighted mean of these residuals — updated whenever a
|
|
8
|
+
* GPS fix and a usable water track coexist — tracks the real ocean
|
|
9
|
+
* current far better than the model products (sea trial
|
|
10
|
+
* 2026-08-30→09-05, Aitutaki→Niue: EWMA tier held DR within ~11 nm
|
|
11
|
+
* over 622 nm vs 47 nm for the tier-3 weather API and 85 nm for the
|
|
12
|
+
* zero vector).
|
|
13
|
+
*
|
|
14
|
+
* When GPS degrades (jamming, spoofing, the Red Sea case) the state
|
|
15
|
+
* carries forward with exponential decay until its TTL lapses, at
|
|
16
|
+
* which point the §6.2 resolver falls through to the model tiers. The
|
|
17
|
+
* update gates are deliberately conservative: only sample when the
|
|
18
|
+
* water track carries direction (STW above the usability floor), only
|
|
19
|
+
* accept residuals within ocean-current bounds (a 3+ kn "current" is a
|
|
20
|
+
* GPS glitch or a lagoon transit, not an observation to learn from).
|
|
21
|
+
*
|
|
22
|
+
* Pure logic over an explicit state object — unit-testable without
|
|
23
|
+
* Signal K plumbing. The plugin entry point samples it once per tick
|
|
24
|
+
* with the latest GPS fix and water track, and reads a snapshot for
|
|
25
|
+
* the resolver.
|
|
26
|
+
*
|
|
27
|
+
* @file derived-current.js
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Minimum seconds between GPS fixes used for ground-vector sampling. */
|
|
31
|
+
const MIN_GPS_INTERVAL_S = 5;
|
|
32
|
+
|
|
33
|
+
/** STW (kn) above which the water track carries usable direction. */
|
|
34
|
+
const MIN_STW_KN = 1.0;
|
|
35
|
+
|
|
36
|
+
/** Residual magnitude (kn) above which a sample is discarded as a glitch. */
|
|
37
|
+
const MAX_RESIDUAL_KN = 3.0;
|
|
38
|
+
|
|
39
|
+
/** SOG (kn) above which a GPS displacement is a glitch, not sailing. */
|
|
40
|
+
const MAX_SOG_KN = 60;
|
|
41
|
+
|
|
42
|
+
/** EWMA time constant (s) for residual samples (sea-trial-validated 6 h). */
|
|
43
|
+
const TAU_S = 6 * 3600;
|
|
44
|
+
|
|
45
|
+
/** Carry decay time constant (s) once GPS sampling stops. */
|
|
46
|
+
const CARRY_TAU_S = 24 * 3600;
|
|
47
|
+
|
|
48
|
+
/** How long (ms) after the last sample the snapshot stays valid. */
|
|
49
|
+
const MAX_AGE_MS = 24 * 3600 * 1000;
|
|
50
|
+
|
|
51
|
+
/** Samples required before the snapshot resolves (below: not a current). */
|
|
52
|
+
const MIN_SAMPLES = 10;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Creates the derived-current state.
|
|
56
|
+
*
|
|
57
|
+
* @returns {{lastGps: {latitude:number, longitude:number, tMs:number}|null,
|
|
58
|
+
* uKn: number, vKn: number, lastSampleMs: number, sampleCount: number}}
|
|
59
|
+
*/
|
|
60
|
+
function createDerivedCurrentState() {
|
|
61
|
+
return {
|
|
62
|
+
lastGps: null,
|
|
63
|
+
uKn: 0,
|
|
64
|
+
vKn: 0,
|
|
65
|
+
lastSampleMs: 0,
|
|
66
|
+
sampleCount: 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Feeds one tick's sensors into the EWMA. The ground vector comes from
|
|
72
|
+
* consecutive GPS fixes (position-differential SOG/COG — the ground
|
|
73
|
+
* truth, per the calibration report §7); the water vector from STW and
|
|
74
|
+
* true heading. Both must be present and sane for a sample; the stored
|
|
75
|
+
* fix refreshes either way so the next differential starts here.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} st - state from {@link createDerivedCurrentState}
|
|
78
|
+
* @param {object} s - per-tick snapshot
|
|
79
|
+
* @param {number} s.tMs - epoch ms of this tick
|
|
80
|
+
* @param {{latitude:number, longitude:number}|null} s.gps - latest GPS fix
|
|
81
|
+
* @param {number|null} s.stwKn - speed through water (kn)
|
|
82
|
+
* @param {number|null} s.headingTrueDeg - true heading (deg [0,360))
|
|
83
|
+
* @returns {{sampled: boolean, reason: string|null}} why no sample was
|
|
84
|
+
* taken, when it wasn't
|
|
85
|
+
*/
|
|
86
|
+
function updateDerivedCurrent(st, s) {
|
|
87
|
+
if (!s.gps) return { sampled: false, reason: "no-gps" };
|
|
88
|
+
const dtS = (s.tMs - (st.lastGps?.tMs ?? 0)) / 1000;
|
|
89
|
+
const prev = st.lastGps;
|
|
90
|
+
st.lastGps = {
|
|
91
|
+
latitude: s.gps.latitude,
|
|
92
|
+
longitude: s.gps.longitude,
|
|
93
|
+
tMs: s.tMs,
|
|
94
|
+
};
|
|
95
|
+
if (!prev || dtS < MIN_GPS_INTERVAL_S) {
|
|
96
|
+
return { sampled: false, reason: "interval" };
|
|
97
|
+
}
|
|
98
|
+
if (s.stwKn == null || s.stwKn < MIN_STW_KN) {
|
|
99
|
+
return { sampled: false, reason: "stw" };
|
|
100
|
+
}
|
|
101
|
+
if (s.headingTrueDeg == null || !Number.isFinite(s.headingTrueDeg)) {
|
|
102
|
+
return { sampled: false, reason: "heading" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const rad = Math.PI / 180;
|
|
106
|
+
const latAvg = ((prev.latitude + s.gps.latitude) / 2) * rad;
|
|
107
|
+
const meanLatNm = (s.gps.latitude - prev.latitude) * 60;
|
|
108
|
+
const eastNm = (s.gps.longitude - prev.longitude) * 60 * Math.cos(latAvg);
|
|
109
|
+
const distNm = Math.hypot(meanLatNm, eastNm);
|
|
110
|
+
const sogKn = distNm / (dtS / 3600);
|
|
111
|
+
if (sogKn > MAX_SOG_KN) {
|
|
112
|
+
return { sampled: false, reason: "gps-glitch" };
|
|
113
|
+
}
|
|
114
|
+
const cogDeg =
|
|
115
|
+
distNm > 1e-9 ? (Math.atan2(eastNm, meanLatNm) * 180) / Math.PI : 0;
|
|
116
|
+
|
|
117
|
+
// Residual = ground velocity − water velocity, in kn components
|
|
118
|
+
// (u = east, v = north).
|
|
119
|
+
const u =
|
|
120
|
+
sogKn * Math.sin(cogDeg * rad) - s.stwKn * Math.sin(s.headingTrueDeg * rad);
|
|
121
|
+
const v =
|
|
122
|
+
sogKn * Math.cos(cogDeg * rad) - s.stwKn * Math.cos(s.headingTrueDeg * rad);
|
|
123
|
+
if (Math.hypot(u, v) > MAX_RESIDUAL_KN) {
|
|
124
|
+
return { sampled: false, reason: "residual-outlier" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (st.sampleCount === 0) {
|
|
128
|
+
st.uKn = u;
|
|
129
|
+
st.vKn = v;
|
|
130
|
+
} else {
|
|
131
|
+
// Continuous-time first-order filter: alpha from the sample gap.
|
|
132
|
+
const alpha = 1 - Math.exp(-dtS / TAU_S);
|
|
133
|
+
st.uKn += (u - st.uKn) * alpha;
|
|
134
|
+
st.vKn += (v - st.vKn) * alpha;
|
|
135
|
+
}
|
|
136
|
+
st.sampleCount += 1;
|
|
137
|
+
st.lastSampleMs = s.tMs;
|
|
138
|
+
return { sampled: true, reason: null };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Snapshot for the §6.2 resolver. The drift magnitude decays with the
|
|
143
|
+
* age of the last sample (current persistence is hours, not days); the
|
|
144
|
+
* TTL bounds it outright, after which the resolver falls through to the
|
|
145
|
+
* model tiers.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} st - state from {@link createDerivedCurrentState}
|
|
148
|
+
* @param {number} nowMs - epoch ms
|
|
149
|
+
* @returns {{setTrue: number, drift: number, validUntilMs: number}|null}
|
|
150
|
+
* setTrue in deg true (direction the current flows toward), drift in
|
|
151
|
+
* kn; null when there is no usable derived current yet
|
|
152
|
+
*/
|
|
153
|
+
function derivedCurrentSnapshot(st, nowMs) {
|
|
154
|
+
if (st.sampleCount < MIN_SAMPLES) return null;
|
|
155
|
+
const ageMs = nowMs - st.lastSampleMs;
|
|
156
|
+
if (ageMs < 0 || ageMs > MAX_AGE_MS) return null;
|
|
157
|
+
const drift =
|
|
158
|
+
Math.hypot(st.uKn, st.vKn) * Math.exp(-ageMs / (CARRY_TAU_S * 1000));
|
|
159
|
+
if (!Number.isFinite(drift) || drift < 1e-6) {
|
|
160
|
+
return { setTrue: 0, drift: 0, validUntilMs: st.lastSampleMs + MAX_AGE_MS };
|
|
161
|
+
}
|
|
162
|
+
const setTrue =
|
|
163
|
+
((((Math.atan2(st.uKn, st.vKn) * 180) / Math.PI) % 360) + 360) % 360;
|
|
164
|
+
return { setTrue, drift, validUntilMs: st.lastSampleMs + MAX_AGE_MS };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
createDerivedCurrentState,
|
|
169
|
+
updateDerivedCurrent,
|
|
170
|
+
derivedCurrentSnapshot,
|
|
171
|
+
// tunables exported for tests
|
|
172
|
+
MIN_GPS_INTERVAL_S,
|
|
173
|
+
MIN_STW_KN,
|
|
174
|
+
MAX_RESIDUAL_KN,
|
|
175
|
+
MAX_SOG_KN,
|
|
176
|
+
TAU_S,
|
|
177
|
+
CARRY_TAU_S,
|
|
178
|
+
MAX_AGE_MS,
|
|
179
|
+
MIN_SAMPLES,
|
|
180
|
+
};
|
package/plugin/fix-pipeline.js
CHANGED
|
@@ -256,6 +256,28 @@ function advanceToLatest(observations, advance) {
|
|
|
256
256
|
return { observations: out, advancements };
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Gross-displacement sanity cap for fix confirmation (sea trial
|
|
261
|
+
* 2026-08-31): a candidate a whole ocean away from the DR origin is a
|
|
262
|
+
* bad sight or bad input, not navigation — 69°S implied ~140 kn. But
|
|
263
|
+
* legitimate DR drift accumulates ~0.5–1 nm/h (sea trials measured
|
|
264
|
+
* 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
|
+
* crews to force-confirm, defeating the guard for the teleport case
|
|
267
|
+
* it exists for.
|
|
268
|
+
*
|
|
269
|
+
* @param {number} maxDisplacementNm - configured flat cap (nm)
|
|
270
|
+
* @param {number} elapsedSinceOriginS - seconds since the origin was
|
|
271
|
+
* set (integration time)
|
|
272
|
+
* @returns {number} cap in nm
|
|
273
|
+
*/
|
|
274
|
+
function fixSanityCapNm(maxDisplacementNm, elapsedSinceOriginS) {
|
|
275
|
+
const hours = Number.isFinite(elapsedSinceOriginS)
|
|
276
|
+
? Math.max(0, elapsedSinceOriginS) / 3600
|
|
277
|
+
: 0;
|
|
278
|
+
return Math.max(maxDisplacementNm, 1.5 * hours);
|
|
279
|
+
}
|
|
280
|
+
|
|
259
281
|
function resolveCandidateFix(input) {
|
|
260
282
|
const sourceType = input.source_type;
|
|
261
283
|
const lopIds = input.observationIds?.lopIds ?? [];
|
|
@@ -542,6 +564,7 @@ module.exports = {
|
|
|
542
564
|
advanceToLatest,
|
|
543
565
|
defaultOriginErrorNm,
|
|
544
566
|
evaluateObservationPlausibility,
|
|
567
|
+
fixSanityCapNm,
|
|
545
568
|
MAX_IMPLIED_SPEED_KN,
|
|
546
569
|
MIN_GATE_DISPLACEMENT_NM,
|
|
547
570
|
};
|