@meri-imperiumi/signalk-dead-reckoning 0.7.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
@@ -5,6 +5,198 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
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
+
108
+ ## [0.8.0] - 2026-09-09
109
+
110
+ ### Fixed
111
+ - **Celestial sights are no longer silently forced through the noon
112
+ (meridian-altitude) reducer** (sea trial 2026-08-31, Aitutaki→Niue:
113
+ both Sun sights reduced to −69.3°/−31.9°). The sight panel's
114
+ `readForm()` read checkboxes via `el.value` — always the string
115
+ `"on"` regardless of checked state — so every sight was POSTed with
116
+ `noon: true`. Checkboxes are now read via `el.checked` (both panels).
117
+ - **The webapp no longer overwrites its own-vessel GPS position with
118
+ the DR shadow boat's** (sea trial: the map boat flipped ~50 NM
119
+ between GPS and the ghost, and fix #9 recorded the ghost position as
120
+ a "GNSS" fix 87.5 km from the boat). Deltas whose context is the
121
+ shadow vessel now route to neither the AIS target store nor the
122
+ own-vessel view-model.
123
+
124
+ ### Changed
125
+ - **The uncertainty cone now grows with current-knowledge**, not just
126
+ distance run (sea trial: 88 km of DR-vs-GPS divergence while the
127
+ polygon "expected" ~2 NM). Radius combines the distance-run error
128
+ and a per-tier current residual (manual 0.25 kn, weather/pilot
129
+ 0.3 kn, zero-vector 1.0 kn) root-sum-square; the fallback margin
130
+ rose from 1° to 4° per NM run (measured open-loop rates were
131
+ 7–12× the old value); the empirical rate is now a median with a
132
+ 2 kn per-row cap (the spec's original intent — a garbage 3058 NM
133
+ correction previously poisoned an EWMA at "145 kn"); the floor rose
134
+ to 0.05 NM and the origin's own error radius seeds it (a celestial
135
+ fix is realistically ~5 nm — the cone never claims GPS confidence
136
+ below that).
137
+ - **The divergence advisory no longer flaps after every fix or print
138
+ "0.27 nm exceeds expected 0.27 nm"**: the exceedance deadband is
139
+ instrument-scale (0.005 NM) instead of float epsilon, and the alert
140
+ message states the exceedance margin.
141
+ - **"Tack/gybe in progress" no longer latches open for the whole
142
+ passage** in ss3–4 seaway: rate-of-turn is measured over a 6 s
143
+ rolling window (a single second of wave yaw can't open it), the
144
+ re-stabilization tolerances scale with sea state, and a window still
145
+ open after 5 minutes force-closes without classifying a maneuver.
146
+ The pre-maneuver AWA for tack/gybe classification is now taken from
147
+ the base of the ROT window (the previous tick's AWA has already
148
+ flipped with the bow by the time the window opens).
149
+
150
+ ### Added
151
+ - **Passage replay backtest tool** (`tools/replay-passage.js`, minimal
152
+ SPEC §10.2 scope): replays a historical passage from the Signal K
153
+ History API through the real DR engine and reports how the shadow
154
+ boat diverged from GPS. Fetches a range in chunks at native 10s
155
+ resolution, forward-fills sparse sensors (unwrapping the signed AWA
156
+ across ±π through gybes), queries `navigation.attitude.roll`
157
+ (radians) directly — the attitude object itself is not recorded, only
158
+ its component paths — so heel reaches the matrix bins, seeds the DR
159
+ origin at the first GPS fix and never re-anchors it. Four variants run side by side: cold (no
160
+ training, tier-5 zero current), learning (Training Mode on, mirroring
161
+ a first live passage), plus the same two with a SCUD satellite-derived
162
+ current source — daily 0.25° fields from the PacIOOS ERDDAP,
163
+ nearest-cell in space, time-interpolated over source holes (and held
164
+ at the span ends), resolved at the shadow boat's own position. Also
165
+ derives the implied current (ground vector minus water-track vector)
166
+ as a diagnostic of what DR is missing. Outputs an hourly divergence
167
+ table, an implied-vs-SCUD current comparison, a GeoJSON track file,
168
+ full-resolution samples, and a self-contained SVG HTML report. A
169
+ `--stw-scale` option multiplies STW end-to-end (engine, training,
170
+ matrix lookup) to test paddlewheel-calibration hypotheses against a
171
+ passage.
172
+ Smoketests cover URL building, row parsing/filling, the current-grid
173
+ interpolation, and three synthetic passages (no-drift, known-current,
174
+ leeway absorbed by training).
175
+ - **Observation submission is speed-plausibility-gated**: a sight,
176
+ bearing or vertical-angle observation whose reduction implies the
177
+ vessel traveled faster than 50 kn since the last fix (e.g. the
178
+ trial's 3058 NM Antarctica sight ≈ 140 kn) is rejected with an
179
+ explanatory message in the form, so the user can fix the entry.
180
+ Displacements within realistic observation quality (~5 NM) always
181
+ pass.
182
+ - **Noon sights carry meridian and sanity guards**: a sight whose Sun
183
+ is more than 20° from the meridian (LHA), or whose computed latitude
184
+ lands more than 12° from the assumed/DR position, is refused rather
185
+ than reduced into garbage.
186
+ - **Fix confirmation carries a gross-displacement guard**: confirming
187
+ a fix more than 100 NM (configurable, `fixes.maxDisplacementNm`)
188
+ from the current DR origin is rejected with 422 unless the request
189
+ carries `force: true`; `/fix/resolve` previews the candidate's
190
+ displacement and flags gross candidates.
191
+ - **Raw sight inputs are persisted** (`raw_hs_deg`, index correction,
192
+ eye height, limb, computed Ho/Hc on lines_of_position; angle/height
193
+ on circular_position_lines) so reductions can be re-run and
194
+ backtested — reconstructing the trial's sights from the stored
195
+ noon results required algebraic archaeology.
196
+ - **`elapsedSinceOriginS` and the origin error radius survive plugin
197
+ restarts** (persisted alongside `last_known_good_fix`), so "since
198
+ last fix" and the cone no longer reset mid-excursion on a restart.
199
+
8
200
  ## [0.7.0] - 2026-08-29
9
201
 
10
202
  ### Added
@@ -137,6 +329,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
137
329
  anchors: a from-scratch Meeus reduction for the Sun (≤0.7′, the
138
330
  anchor's own accuracy class), the AA low-precision series for the
139
331
  Moon (≤20′), and paper-almanac star values.
332
+
140
333
  - **The webapp's "Ghost Track" heading above the map is gone.** It
141
334
  wasted vertical space the map could use — the map card now opens with
142
335
  no chrome above it, so the chart starts higher on the page. The
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.7.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",
@@ -57,6 +57,22 @@ const MOON_RADIUS_KM = 1737.4;
57
57
  /** Earth equatorial radius, km. */
58
58
  const EARTH_EQ_RADIUS_KM = 6378.14;
59
59
 
60
+ /**
61
+ /**
62
+ * Maximum distance of the Sun's LHA from the meridian (deg) for a
63
+ * sight to be accepted as a noon sight (sea trial 2026-08-31 guard).
64
+ * ~20° ≈ 80 minutes from transit — generous versus the ~30 min a
65
+ * navigator would actually shoot around LAN, but far below the ~100°
66
+ * the trial's 07:05-local sights carried.
67
+ */
68
+ const NOON_MAX_LHA_DEG = 20;
69
+
70
+ /**
71
+ * Maximum computed-vs-assumed latitude discrepancy (deg) accepted from
72
+ * a noon sight before it is refused as implausible (~720 NM).
73
+ */
74
+ const NOON_MAX_LAT_DELTA_DEG = 12;
75
+
60
76
  /**
61
77
  * Reduces an ecliptic longitude/latitude to right ascension (degrees),
62
78
  * for a given obliquity (degrees).
@@ -437,6 +453,18 @@ function reduceNoonSight(input) {
437
453
  const gp = sunGeographicPosition(epochMs);
438
454
  const dec = gp.declination_deg;
439
455
 
456
+ // 1b. Meridian-transit guard (sea trial 2026-08-31): a sight taken
457
+ // hours from local noon reduces to garbage latitudes (both trial
458
+ // sights were morning sights that landed at −69°/−32°). LHA is 0
459
+ // (or 360) exactly on the meridian; refuse sights far from transit.
460
+ const lhaDeg = normalizeDeg360(gp.gha_deg + assumed.longitude);
461
+ const fromMeridianDeg = Math.min(lhaDeg, 360 - lhaDeg);
462
+ if (fromMeridianDeg > NOON_MAX_LHA_DEG) {
463
+ throw new Error(
464
+ `Sun is ${fromMeridianDeg.toFixed(0)}° from the meridian (LHA ${lhaDeg.toFixed(1)}°) at the sight time — not a noon sight. Uncheck "Noon sight" for an intercept-method reduction, or verify the sight time`,
465
+ );
466
+ }
467
+
440
468
  // 2. Ho from Hs (same corrections as a normal sight; limb sights apply).
441
469
  const sd = 0.2666;
442
470
  const semiDiameterDeg =
@@ -462,6 +490,17 @@ function reduceNoonSight(input) {
462
490
  const sunSouth = dec < assumed.latitude;
463
491
  const latitude = sunSouth ? dec + z : dec - z;
464
492
 
493
+ // 3b. Computed-vs-assumed sanity (sea trial 2026-08-31): even a sight
494
+ // near transit can be mis-entered (wrong limb, IC, time). A computed
495
+ // latitude a whole ocean away from the assumed/DR position is far
496
+ // more likely a bad sight than a 700 NM DR error — refuse it.
497
+ const latDeltaDeg = Math.abs(latitude - assumed.latitude);
498
+ if (latDeltaDeg > NOON_MAX_LAT_DELTA_DEG) {
499
+ throw new Error(
500
+ `Noon sight latitude ${latitude.toFixed(1)}° is ${latDeltaDeg.toFixed(1)}° from the assumed ${assumed.latitude.toFixed(1)}° — check the sight altitude, limb and index correction`,
501
+ );
502
+ }
503
+
465
504
  // Azimuth: 180° (due south) when the Sun is south of the observer,
466
505
  // 0° (due north) when it's north. Either yields an east-west LOP.
467
506
  const azimuth = sunSouth ? 180 : 0;
@@ -484,6 +523,8 @@ function reduceNoonSight(input) {
484
523
  }
485
524
 
486
525
  module.exports = {
526
+ NOON_MAX_LHA_DEG,
527
+ NOON_MAX_LAT_DELTA_DEG,
487
528
  raFromEcliptic,
488
529
  decFromEcliptic,
489
530
  sunGeographicPosition,
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
@@ -88,6 +88,12 @@ const SCHEMA_DDL = [
88
88
  body_or_object TEXT,
89
89
  confirmed_by TEXT,
90
90
  used_in_fix_id INTEGER,
91
+ raw_hs_deg REAL,
92
+ index_correction_deg REAL,
93
+ eye_height_m REAL,
94
+ limb TEXT,
95
+ ho_deg REAL,
96
+ hc_deg REAL,
91
97
  FOREIGN KEY (used_in_fix_id) REFERENCES fixes(fix_id)
92
98
  )`,
93
99
 
@@ -102,6 +108,8 @@ const SCHEMA_DDL = [
102
108
  source_object TEXT,
103
109
  confirmed_by TEXT,
104
110
  used_in_fix_id INTEGER,
111
+ raw_angle_deg REAL,
112
+ object_height_m REAL,
105
113
  FOREIGN KEY (used_in_fix_id) REFERENCES fixes(fix_id)
106
114
  )`,
107
115
 
@@ -193,20 +201,88 @@ function openDatabase(dbPath) {
193
201
  for (const stmt of SCHEMA_DDL) {
194
202
  db.exec(stmt);
195
203
  }
196
- // v1 → v2: running-fix provenance column on `fixes`. Fresh databases
197
- // get the column from the DDL above; existing ones are altered in
198
- // place. The stored version is read *after* the DDL (it creates
199
- // dr_state_store on a fresh database, where there is nothing to
200
- // migrate) and *before* the new version is recorded below.
201
- const storedVersion = getState(db, "schema_version");
202
- if (storedVersion != null && Number(storedVersion) < 2) {
203
- db.exec("ALTER TABLE fixes ADD COLUMN derived_from_fix_id INTEGER");
204
- }
204
+ migrate(db);
205
205
  // Record schema version so future migrations can branch on it.
206
206
  setState(db, "schema_version", String(SCHEMA_VERSION));
207
207
  return db;
208
208
  }
209
209
 
210
+ /**
211
+ * Column additions applied via ALTER TABLE, guarded by PRAGMA table_info
212
+ * so each is idempotent. Run unconditionally on open — the sea-trial
213
+ * database carried a schema_version written by a build whose numbering
214
+ * doesn't match this tree's history, so version-gated migrations would
215
+ * silently skip. Each step is cheap (one PRAGMA read).
216
+ *
217
+ * @type {Array<{table: string, column: string, ddl: string}>}
218
+ */
219
+ const COLUMN_MIGRATIONS = [
220
+ // Schema v2 (running fix): provenance for single-observation running
221
+ // fixes advanced from a previous confirmed fix.
222
+ {
223
+ table: "fixes",
224
+ column: "derived_from_fix_id",
225
+ ddl: "ALTER TABLE fixes ADD COLUMN derived_from_fix_id INTEGER",
226
+ },
227
+ // Schema v2 (sea trial 2026-09-06): persist the raw user-entered sight
228
+ // inputs so reductions can be re-run/backtested without algebraic
229
+ // archaeology.
230
+ {
231
+ table: "lines_of_position",
232
+ column: "raw_hs_deg",
233
+ ddl: "ALTER TABLE lines_of_position ADD COLUMN raw_hs_deg REAL",
234
+ },
235
+ {
236
+ table: "lines_of_position",
237
+ column: "index_correction_deg",
238
+ ddl: "ALTER TABLE lines_of_position ADD COLUMN index_correction_deg REAL",
239
+ },
240
+ {
241
+ table: "lines_of_position",
242
+ column: "eye_height_m",
243
+ ddl: "ALTER TABLE lines_of_position ADD COLUMN eye_height_m REAL",
244
+ },
245
+ {
246
+ table: "lines_of_position",
247
+ column: "limb",
248
+ ddl: "ALTER TABLE lines_of_position ADD COLUMN limb TEXT",
249
+ },
250
+ {
251
+ table: "lines_of_position",
252
+ column: "ho_deg",
253
+ ddl: "ALTER TABLE lines_of_position ADD COLUMN ho_deg REAL",
254
+ },
255
+ {
256
+ table: "lines_of_position",
257
+ column: "hc_deg",
258
+ ddl: "ALTER TABLE lines_of_position ADD COLUMN hc_deg REAL",
259
+ },
260
+ {
261
+ table: "circular_position_lines",
262
+ column: "raw_angle_deg",
263
+ ddl: "ALTER TABLE circular_position_lines ADD COLUMN raw_angle_deg REAL",
264
+ },
265
+ {
266
+ table: "circular_position_lines",
267
+ column: "object_height_m",
268
+ ddl: "ALTER TABLE circular_position_lines ADD COLUMN object_height_m REAL",
269
+ },
270
+ ];
271
+
272
+ /**
273
+ * Applies pending column migrations.
274
+ *
275
+ * @param {import("node:sqlite").DatabaseSync} db
276
+ * @returns {void}
277
+ */
278
+ function migrate(db) {
279
+ for (const step of COLUMN_MIGRATIONS) {
280
+ const columns = db.prepare(`PRAGMA table_info(${step.table})`).all();
281
+ const exists = columns.some((c) => c.name === step.column);
282
+ if (!exists) db.exec(step.ddl);
283
+ }
284
+ }
285
+
210
286
  /**
211
287
  * Reads a scalar value from `dr_state_store`.
212
288
  *
@@ -348,8 +424,9 @@ function recordLineOfPosition(db, r) {
348
424
  const stmt = db.prepare(
349
425
  `INSERT INTO lines_of_position (
350
426
  timestamp, lop_type, assumed_lat, assumed_lon, azimuth_true,
351
- intercept_nm, body_or_object, confirmed_by
352
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
427
+ intercept_nm, body_or_object, confirmed_by,
428
+ raw_hs_deg, index_correction_deg, eye_height_m, limb, ho_deg, hc_deg
429
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
353
430
  );
354
431
  const info = stmt.run(
355
432
  r.timestamp,
@@ -360,6 +437,14 @@ function recordLineOfPosition(db, r) {
360
437
  r.intercept_nm ?? null,
361
438
  r.body_or_object ?? null,
362
439
  r.confirmed_by ?? null,
440
+ // Raw user-entered sight inputs (schema v2): so a reduction can be
441
+ // re-run/backtested later without reconstructing Hs from the result.
442
+ r.raw_hs_deg ?? null,
443
+ r.index_correction_deg ?? null,
444
+ r.eye_height_m ?? null,
445
+ r.limb ?? null,
446
+ r.ho_deg ?? null,
447
+ r.hc_deg ?? null,
363
448
  );
364
449
  return Number(info.lastInsertRowid);
365
450
  }
@@ -386,8 +471,9 @@ function recordCircularPositionLine(db, r) {
386
471
  const stmt = db.prepare(
387
472
  `INSERT INTO circular_position_lines (
388
473
  timestamp, cpl_type, center_lat, center_lon, radius_nm,
389
- radius_uncertainty_nm, source_object, confirmed_by
390
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
474
+ radius_uncertainty_nm, source_object, confirmed_by,
475
+ raw_angle_deg, object_height_m
476
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
391
477
  );
392
478
  const info = stmt.run(
393
479
  r.timestamp,
@@ -398,6 +484,8 @@ function recordCircularPositionLine(db, r) {
398
484
  r.radius_uncertainty_nm ?? null,
399
485
  r.source_object ?? null,
400
486
  r.confirmed_by ?? null,
487
+ r.raw_angle_deg ?? null,
488
+ r.object_height_m ?? null,
401
489
  );
402
490
  return Number(info.lastInsertRowid);
403
491
  }