@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 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. Live High-Res — Starlink-cached coastal NetCDF vectors.
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.8.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. 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.
package/plugin/db.js CHANGED
@@ -20,7 +20,7 @@ const { mkdirSync } = require("node:fs");
20
20
  /** Schema version, bumped when a migration is needed. Persisted in dr_state_store.
21
21
  * v2: `fixes.derived_from_fix_id` — provenance for single-observation
22
22
  * running fixes advanced from a previous confirmed fix. */
23
- const SCHEMA_VERSION = 2;
23
+ const SCHEMA_VERSION = 3;
24
24
 
25
25
  /**
26
26
  * DDL for every table in SPEC §4. Statements run in order; all are
@@ -202,6 +202,7 @@ function openDatabase(dbPath) {
202
202
  db.exec(stmt);
203
203
  }
204
204
  migrate(db);
205
+ migrateData(db);
205
206
  // Record schema version so future migrations can branch on it.
206
207
  setState(db, "schema_version", String(SCHEMA_VERSION));
207
208
  return db;
@@ -283,6 +284,47 @@ function migrate(db) {
283
284
  }
284
285
  }
285
286
 
287
+ /**
288
+ * One-time, version-gated *data* migrations. Unlike the column steps
289
+ * above (idempotent by construction, safe on every open), these are
290
+ * gated on the stored `schema_version` so each runs exactly once per
291
+ * database. Bumping SCHEMA_VERSION and adding an entry here upgrades
292
+ * an existing install on its first open.
293
+ *
294
+ * v3 — poisoned-leeway cleanup (sea trials 2026-08-30…09-05 and
295
+ * 2026-09-11…14): both trials trained while the resolved current was
296
+ * wrong (zero vector, then the phantom cross-receiver tier-2 vector),
297
+ * and the trainer absorbed the current error as leeway — bins carrying
298
+ * 10–32° of leeway on broad reaches, physically impossible for this
299
+ * hull (real leeway on a run is ≈0°, close-hauled in a gale ≈10–13°).
300
+ * Those bins mis-rotate the water track on every later passage until
301
+ * retrained, so they are removed outright: the EMA would blend them
302
+ * out only over many more hours than a clean restart. The current-tier
303
+ * training gate (§6.1) and the computeObservation plausibility bound
304
+ * prevent new poison of this shape.
305
+ */
306
+ const DATA_MIGRATIONS = [
307
+ {
308
+ version: 3,
309
+ run: (db) => {
310
+ db.exec("DELETE FROM dr_matrix_bins WHERE ABS(leeway_angle) > 10");
311
+ },
312
+ },
313
+ ];
314
+
315
+ /**
316
+ * Applies pending data migrations, gated on the stored schema version.
317
+ *
318
+ * @param {import("node:sqlite").DatabaseSync} db
319
+ * @returns {void}
320
+ */
321
+ function migrateData(db) {
322
+ const stored = Number(getState(db, "schema_version") ?? 0);
323
+ for (const step of DATA_MIGRATIONS) {
324
+ if (stored < step.version) step.run(db);
325
+ }
326
+ }
327
+
286
328
  /**
287
329
  * Reads a scalar value from `dr_state_store`.
288
330
  *