@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 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. Live High-Res — Starlink-cached coastal NetCDF vectors.
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.8.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. Live high-res NetCDF (Starlink-cached coastal) — future.
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 {string} [opts.baseUrl] - server base URL (no trailing slash)
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 abort timeout
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.baseUrl = (opts.baseUrl ?? "http://localhost:3000").replace(
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 || !this.fetchFn) return;
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
- const url = new URL(
235
- `${this.baseUrl}/signalk/v2/api/weather/forecasts/point`,
236
- );
237
- url.searchParams.set("lat", String(pos.latitude));
238
- url.searchParams.set("lon", String(pos.longitude));
239
- url.searchParams.set("count", String(this.count));
240
-
241
- const controller = new AbortController();
242
- const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
243
- try {
244
- const res = await this.fetchFn(url.toString(), {
245
- signal: controller.signal,
246
- });
247
- if (!res.ok) {
248
- throw new Error(`weather API returned ${res.status}`);
249
- }
250
- const data = await res.json();
251
- if (!Array.isArray(data)) {
252
- throw new Error("weather API response is not an array");
253
- }
254
- const parsed = parseWeatherCurrent(data, this.now());
255
- if (!parsed) {
256
- throw new Error("forecast carries no current data");
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
+ };
@@ -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
  };