@meri-imperiumi/signalk-dead-reckoning 0.9.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,108 @@ 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
+
10
112
  ## [0.9.0] - 2026-09-10
11
113
 
12
114
  ### Added
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) 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):
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,7 +290,7 @@ 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. 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).
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.
290
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.9.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": {
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
  *
@@ -51,6 +51,14 @@ const MAX_AGE_MS = 24 * 3600 * 1000;
51
51
  /** Samples required before the snapshot resolves (below: not a current). */
52
52
  const MIN_SAMPLES = 10;
53
53
 
54
+ /**
55
+ * How long (ms) a source-mismatched fix is ignored before the sampler
56
+ * gives up on the baseline's source and adopts the new one (the old
57
+ * receiver went silent — better to restart the differential on the
58
+ * surviving antenna than to never sample again).
59
+ */
60
+ const MAX_BASELINE_STALE_MS = 60 * 1000;
61
+
54
62
  /**
55
63
  * Creates the derived-current state.
56
64
  *
@@ -68,16 +76,32 @@ function createDerivedCurrentState() {
68
76
  }
69
77
 
70
78
  /**
71
- * Feeds one tick's sensors into the EWMA. The ground vector comes from
79
+ * Feeds one GPS fix into the EWMA. The ground vector comes from
72
80
  * consecutive GPS fixes (position-differential SOG/COG — the ground
73
81
  * truth, per the calibration report §7); the water vector from STW and
74
82
  * true heading. Both must be present and sane for a sample; the stored
75
- * fix refreshes either way so the next differential starts here.
83
+ * fix refreshes only when a sample is actually taken (or the baseline
84
+ * is first adopted) so the ≥ MIN_GPS_INTERVAL_S differential can form
85
+ * across a healthy 1 Hz fix stream — refreshing the baseline on every
86
+ * cadence-rejected tick meant the EWMA could only sample across
87
+ * position-null gaps — and those paired fixes from different receivers,
88
+ * producing a phantom 1.1 kn @ ~131° "current" for 55 h on the
89
+ * 2026-09-11…14 Niue→Vava'u trial (see the sampler rules below).
90
+ *
91
+ * Fixes are attributed to their `$source` when the caller supplies one
92
+ * (multi-GPS installs flit between antennas metres apart at 1 Hz each —
93
+ * a cross-source differential is antenna separation, not current), and
94
+ * timestamps come from the fix itself (`fixTimeMs`) rather than the
95
+ * tick clock, so a paused feed never yields a zero-displacement
96
+ * "sample" against a refreshed clock.
76
97
  *
77
98
  * @param {object} st - state from {@link createDerivedCurrentState}
78
99
  * @param {object} s - per-tick snapshot
79
100
  * @param {number} s.tMs - epoch ms of this tick
80
101
  * @param {{latitude:number, longitude:number}|null} s.gps - latest GPS fix
102
+ * @param {number|null} [s.fixTimeMs] - epoch ms of the fix itself; falls
103
+ * back to s.tMs when the caller has no fix timestamp
104
+ * @param {string|null} [s.source] - `$source` of the fix delta, when known
81
105
  * @param {number|null} s.stwKn - speed through water (kn)
82
106
  * @param {number|null} s.headingTrueDeg - true heading (deg [0,360))
83
107
  * @returns {{sampled: boolean, reason: string|null}} why no sample was
@@ -85,16 +109,44 @@ function createDerivedCurrentState() {
85
109
  */
86
110
  function updateDerivedCurrent(st, s) {
87
111
  if (!s.gps) return { sampled: false, reason: "no-gps" };
88
- const dtS = (s.tMs - (st.lastGps?.tMs ?? 0)) / 1000;
112
+ const fixTMs = Number.isFinite(s.fixTimeMs) ? s.fixTimeMs : s.tMs;
113
+ const source = typeof s.source === "string" && s.source ? s.source : null;
114
+
115
+ // Antenna flapping: a differential between two different receivers is
116
+ // antenna separation measured as speed, not current. Hold the baseline
117
+ // (it belongs to the source that is still streaming) and ignore the
118
+ // interloper — unless the baseline's own source has gone quiet for so
119
+ // long that adopting the new source is the only way forward.
120
+ const baselineStaleMs = fixTMs - (st.lastGps?.tMs ?? 0);
121
+ if (
122
+ st.lastGps &&
123
+ source != null &&
124
+ st.lastGps.source != null &&
125
+ source !== st.lastGps.source &&
126
+ baselineStaleMs < MAX_BASELINE_STALE_MS
127
+ ) {
128
+ return { sampled: false, reason: "source" };
129
+ }
130
+
131
+ const dtS = (fixTMs - (st.lastGps?.tMs ?? 0)) / 1000;
132
+ if (!st.lastGps || dtS < MIN_GPS_INTERVAL_S) {
133
+ if (!st.lastGps) {
134
+ st.lastGps = {
135
+ latitude: s.gps.latitude,
136
+ longitude: s.gps.longitude,
137
+ tMs: fixTMs,
138
+ source,
139
+ };
140
+ }
141
+ return { sampled: false, reason: "interval" };
142
+ }
89
143
  const prev = st.lastGps;
90
144
  st.lastGps = {
91
145
  latitude: s.gps.latitude,
92
146
  longitude: s.gps.longitude,
93
- tMs: s.tMs,
147
+ tMs: fixTMs,
148
+ source,
94
149
  };
95
- if (!prev || dtS < MIN_GPS_INTERVAL_S) {
96
- return { sampled: false, reason: "interval" };
97
- }
98
150
  if (s.stwKn == null || s.stwKn < MIN_STW_KN) {
99
151
  return { sampled: false, reason: "stw" };
100
152
  }
@@ -134,7 +186,7 @@ function updateDerivedCurrent(st, s) {
134
186
  st.vKn += (v - st.vKn) * alpha;
135
187
  }
136
188
  st.sampleCount += 1;
137
- st.lastSampleMs = s.tMs;
189
+ st.lastSampleMs = fixTMs;
138
190
  return { sampled: true, reason: null };
139
191
  }
140
192
 
@@ -152,8 +204,11 @@ function updateDerivedCurrent(st, s) {
152
204
  */
153
205
  function derivedCurrentSnapshot(st, nowMs) {
154
206
  if (st.sampleCount < MIN_SAMPLES) return null;
155
- const ageMs = nowMs - st.lastSampleMs;
156
- if (ageMs < 0 || ageMs > MAX_AGE_MS) return null;
207
+ // Fix timestamps can sit slightly ahead of the wall clock (GPS time
208
+ // vs Pi time): clamp rather than treat a small negative age as
209
+ // "impossible" — the carry decay must still flow.
210
+ const ageMs = Math.max(0, nowMs - st.lastSampleMs);
211
+ if (ageMs > MAX_AGE_MS) return null;
157
212
  const drift =
158
213
  Math.hypot(st.uKn, st.vKn) * Math.exp(-ageMs / (CARRY_TAU_S * 1000));
159
214
  if (!Number.isFinite(drift) || drift < 1e-6) {
@@ -177,4 +232,5 @@ module.exports = {
177
232
  CARRY_TAU_S,
178
233
  MAX_AGE_MS,
179
234
  MIN_SAMPLES,
235
+ MAX_BASELINE_STALE_MS,
180
236
  };
package/plugin/engine.js CHANGED
@@ -65,6 +65,12 @@ class DeadReckoningEngine {
65
65
  this.tripLogNm = opts.tripLogNm ?? 0;
66
66
  /** @type {number|null} seconds since the DR origin was last reset */
67
67
  this.elapsedSinceOriginS = 0;
68
+ /** @type {number} seconds spent under way since the DR origin was last
69
+ * reset — the uncertainty cone's current-knowledge term grows only
70
+ * while the vessel is making way: at anchor/moored the ground holds
71
+ * the boat, so unknown current cannot accumulate DR error (a week on
72
+ * the hook must not paint a 100+ NM circle around the boat). */
73
+ this.underwaySinceOriginS = 0;
68
74
  /** @type {number} water-track log accumulated since the last snap-to-fix (nm)
69
75
  * — SPEC §8 uses elapsed *distance* run, not clock time, as the
70
76
  * uncertainty-polygon growth axis (DR error compounds with distance
@@ -91,6 +97,7 @@ class DeadReckoningEngine {
91
97
  snapToFix(fix, originErrorNm = 0) {
92
98
  this.origin = { latitude: fix.latitude, longitude: fix.longitude };
93
99
  this.elapsedSinceOriginS = 0;
100
+ this.underwaySinceOriginS = 0;
94
101
  this.logNmSinceOrigin = 0;
95
102
  this.originErrorNm = Math.max(0, originErrorNm);
96
103
  }
@@ -121,6 +128,9 @@ class DeadReckoningEngine {
121
128
  * @param {number} inputs.leewayDeg - learned leeway angle (degrees, + to leeward)
122
129
  * @param {number} inputs.speedLoss - learned speed-loss fraction [0,1]
123
130
  * @param {{setTrue: number, drift: number}} [inputs.current] - resolved current (deg true, kn)
131
+ * @param {boolean} [inputs.underway=true] - whether the vessel is under
132
+ * way this tick (from `navigation.state`); gates the under-way clock
133
+ * the uncertainty cone grows on, not the wall-clock one
124
134
  * @param {number} [inputs.dtS=1] - tick interval in seconds
125
135
  * @returns {{latitude: number, longitude: number}|null} new position, or null if no origin
126
136
  */
@@ -150,6 +160,7 @@ class DeadReckoningEngine {
150
160
  this.logNm += effectiveStw * hours;
151
161
  this.tripLogNm += effectiveStw * hours;
152
162
  this.elapsedSinceOriginS += dtS;
163
+ if (inputs.underway ?? true) this.underwaySinceOriginS += dtS;
153
164
  this.logNmSinceOrigin += effectiveStw * hours;
154
165
  return pos;
155
166
  }
@@ -134,6 +134,7 @@ function loadObservationsById(db, helpers, lopIds, cplIds) {
134
134
  assumed_lon: row.assumed_lon,
135
135
  azimuth_true: row.azimuth_true,
136
136
  intercept_nm: row.intercept_nm ?? 0,
137
+ lop_type: row.lop_type ?? null,
137
138
  timestamp_ms: Date.parse(row.timestamp),
138
139
  });
139
140
  }
@@ -262,18 +263,19 @@ function advanceToLatest(observations, advance) {
262
263
  * bad sight or bad input, not navigation — 69°S implied ~140 kn. But
263
264
  * legitimate DR drift accumulates ~0.5–1 nm/h (sea trials measured
264
265
  * 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
+ * grow with the hours under way since the origin was set — anchored
267
+ * time doesn't count (the ground holds the boat) — a flat cap trains
266
268
  * crews to force-confirm, defeating the guard for the teleport case
267
269
  * it exists for.
268
270
  *
269
271
  * @param {number} maxDisplacementNm - configured flat cap (nm)
270
- * @param {number} elapsedSinceOriginS - seconds since the origin was
271
- * set (integration time)
272
+ * @param {number} underwaySinceOriginS - seconds under way since the
273
+ * origin was set (integration time)
272
274
  * @returns {number} cap in nm
273
275
  */
274
- function fixSanityCapNm(maxDisplacementNm, elapsedSinceOriginS) {
275
- const hours = Number.isFinite(elapsedSinceOriginS)
276
- ? Math.max(0, elapsedSinceOriginS) / 3600
276
+ function fixSanityCapNm(maxDisplacementNm, underwaySinceOriginS) {
277
+ const hours = Number.isFinite(underwaySinceOriginS)
278
+ ? Math.max(0, underwaySinceOriginS) / 3600
277
279
  : 0;
278
280
  return Math.max(maxDisplacementNm, 1.5 * hours);
279
281
  }
package/plugin/fixes.js CHANGED
@@ -38,6 +38,7 @@ const {
38
38
  degToRad,
39
39
  radToDeg,
40
40
  normalizeDeg180,
41
+ normalizeDeg360,
41
42
  destinationPoint,
42
43
  } = require("./geo.js");
43
44
 
@@ -265,6 +266,9 @@ function distanceToCircle(center, radiusM, p) {
265
266
  * @property {number} assumed_lon
266
267
  * @property {number} azimuth_true
267
268
  * @property {number} [intercept_nm]
269
+ * @property {string} [lop_type] - 'bearing' marks a bearing to a known
270
+ * object: the constraint is the observer-side ray, not the full line
271
+ * (see {@link onObserverRay})
268
272
  *
269
273
  * @typedef {Object} CplInput
270
274
  * @property {'cpl'} kind
@@ -529,6 +533,50 @@ function gaussNewtonRefine(lops, circles, start) {
529
533
  * @param {{latitude: number, longitude: number}|null} [drPosition] - to pick the plausible side; defaults to projectionCenter
530
534
  * @returns {ResolveResult|null}
531
535
  */
536
+ /**
537
+ * Along-ray slack for the bearing constraint: a foot landing this close
538
+ * to the object's anchor is ordinary bearing/run disagreement on a
539
+ * close-range sight (compass slop, DR-run error over a short interval),
540
+ * not the mirrored-position failure the guard exists for. The drawing
541
+ * keeps a 1 nm stub past the object for visibility; the resolver is
542
+ * stricter because a "fix" well past the object is navigationally wrong.
543
+ */
544
+ const RAY_TOLERANCE_M = 100;
545
+
546
+ /**
547
+ * Bearing-LOP ray constraint (sea trial 2026-09-13, bearing to AIS
548
+ * vessel MATILDA): a bearing to a *charted/known object* is a ray, not
549
+ * an infinite line — the observer is on the reciprocal side of the
550
+ * object, because the bearing itself says which side. The stored
551
+ * azimuth is the line's normal (= measured bearing + 90° in the UI's
552
+ * convention), so the observer-side direction from the anchor is
553
+ * `azimuth + 90°`. A resolved point on the away side would place the
554
+ * vessel *past* the object, from where the object would bear opposite
555
+ * the sighted bearing — geometrically on the line, navigationally
556
+ * wrong (the running fix landed 2.7 nm beyond MATILDA and "reversed"
557
+ * the bearing). Celestial intercept LOPs stay full lines: any point on
558
+ * the line is equally valid.
559
+ *
560
+ * @param {object} obs - resolver-shaped LOP observation (needs
561
+ * `lop_type`, `assumed_lat/lon`, `azimuth_true`)
562
+ * @param {{latitude:number, longitude:number}} projectionCenter
563
+ * @param {{x:number, y:number}} p - candidate point, local plane
564
+ * @param {number} [toleranceM=RAY_TOLERANCE_M] along-ray slack
565
+ * @returns {boolean} true when the point is on the observer's ray (or
566
+ * the observation is not a bearing — no constraint)
567
+ */
568
+ function onObserverRay(obs, projectionCenter, p, toleranceM = RAY_TOLERANCE_M) {
569
+ if (obs.lop_type !== "bearing") return true;
570
+ const anchor = projectToLocal(projectionCenter, {
571
+ latitude: obs.assumed_lat,
572
+ longitude: obs.assumed_lon,
573
+ });
574
+ const obsSide = degToRad(normalizeDeg360(obs.azimuth_true + 90));
575
+ const dir = { x: Math.sin(obsSide), y: Math.cos(obsSide) };
576
+ const along = (p.x - anchor.x) * dir.x + (p.y - anchor.y) * dir.y;
577
+ return along >= -toleranceM;
578
+ }
579
+
532
580
  function resolveFix(observations, projectionCenter, drPosition = null) {
533
581
  const dr = drPosition ?? projectionCenter;
534
582
  const drLocal = projectToLocal(projectionCenter, dr);
@@ -590,6 +638,20 @@ function resolveFix(observations, projectionCenter, drPosition = null) {
590
638
  const lop = lops[0];
591
639
  const d = signedDistanceToLop(lop, q);
592
640
  const foot = { x: q.x - d * lop.n.x, y: q.y - d * lop.n.y };
641
+ // A bearing constrains to the observer's ray: a foot past the
642
+ // object means the run and the bearing disagree by more than the
643
+ // geometry can absorb — the honest failure, not a mirrored fix.
644
+ if (
645
+ !onObserverRay(
646
+ observations.find((o) => o.kind === "lop"),
647
+ projectionCenter,
648
+ foot,
649
+ )
650
+ ) {
651
+ throw new Error(
652
+ "running fix falls past the bearing's object — the advanced fix and the observed bearing disagree by more than 90°; check the run (DR track) or re-take the bearing",
653
+ );
654
+ }
593
655
  const ll = unprojectFromLocal(projectionCenter, foot);
594
656
  return {
595
657
  latitude: ll.latitude,
@@ -639,6 +701,7 @@ function resolveFix(observations, projectionCenter, drPosition = null) {
639
701
 
640
702
  // Exactly two inputs.
641
703
  if (lops.length === 2 && cplCenters.length === 0) {
704
+ const lopObs = observations.filter((o) => o.kind === "lop");
642
705
  const pt = intersectLopLop(lops[0], lops[1]);
643
706
  if (!pt) {
644
707
  // Parallel LOPs: the loss is flat along the common line direction,
@@ -659,6 +722,13 @@ function resolveFix(observations, projectionCenter, drPosition = null) {
659
722
  x: foot.x + tAlong * along.x,
660
723
  y: foot.y + tAlong * along.y,
661
724
  };
725
+ for (let i = 0; i < lopObs.length; i++) {
726
+ if (!onObserverRay(lopObs[i], projectionCenter, pt2)) {
727
+ throw new Error(
728
+ "parallel bearing fix falls past a bearing's object — the DR position and the observed bearings disagree",
729
+ );
730
+ }
731
+ }
662
732
  const ll = unprojectFromLocal(projectionCenter, pt2);
663
733
  const residual = residualSpreadNm(lops, circles, pt2);
664
734
  return {
@@ -668,6 +738,13 @@ function resolveFix(observations, projectionCenter, drPosition = null) {
668
738
  alternate: null,
669
739
  };
670
740
  }
741
+ for (let i = 0; i < lopObs.length; i++) {
742
+ if (!onObserverRay(lopObs[i], projectionCenter, pt)) {
743
+ throw new Error(
744
+ "bearing intersection falls past a bearing's object — the observed bearings disagree with each other (or one was taken to the wrong object)",
745
+ );
746
+ }
747
+ }
671
748
  const ll = unprojectFromLocal(projectionCenter, pt);
672
749
  const residual = residualSpreadNm(lops, circles, pt);
673
750
  return {
@@ -680,6 +757,7 @@ function resolveFix(observations, projectionCenter, drPosition = null) {
680
757
 
681
758
  if (lops.length === 1 && cplCenters.length === 1) {
682
759
  const pts = intersectLopCircle(lops[0], cplCenters[0], cplRadii[0]);
760
+ const lopObs = observations.find((o) => o.kind === "lop");
683
761
  if (pts.length === 0) {
684
762
  // Non-intersecting: least-squares closest approach.
685
763
  const { point, residual } = leastSquaresFit(lops, circles, drLocal);
@@ -691,9 +769,19 @@ function resolveFix(observations, projectionCenter, drPosition = null) {
691
769
  alternate: null,
692
770
  };
693
771
  }
694
- if (pts.length === 1) {
695
- const ll = unprojectFromLocal(projectionCenter, pts[0]);
696
- const residual = residualSpreadNm(lops, circles, pts[0]);
772
+ // A bearing LOP constrains both circle candidates to the observer's
773
+ // ray — drop the past-the-object one before picking nearest-DR.
774
+ const onRay = pts.map((p) => onObserverRay(lopObs, projectionCenter, p));
775
+ const valid = pts.filter((_, i) => onRay[i]);
776
+ if (valid.length === 0) {
777
+ throw new Error(
778
+ "bearing×range fix falls past the bearing's object — the bearing and the range disagree",
779
+ );
780
+ }
781
+ if (valid.length === 1 || pts.length === 1) {
782
+ const p = valid[0];
783
+ const ll = unprojectFromLocal(projectionCenter, p);
784
+ const residual = residualSpreadNm(lops, circles, p);
697
785
  return {
698
786
  latitude: ll.latitude,
699
787
  longitude: ll.longitude,
@@ -702,9 +790,10 @@ function resolveFix(observations, projectionCenter, drPosition = null) {
702
790
  };
703
791
  }
704
792
  // Two candidates: nearest DR is primary, the other is alternate.
705
- const d0 = Math.hypot(pts[0].x - drLocal.x, pts[0].y - drLocal.y);
706
- const d1 = Math.hypot(pts[1].x - drLocal.x, pts[1].y - drLocal.y);
707
- const [primary, alt] = d0 <= d1 ? [pts[0], pts[1]] : [pts[1], pts[0]];
793
+ const d0 = Math.hypot(valid[0].x - drLocal.x, valid[0].y - drLocal.y);
794
+ const d1 = Math.hypot(valid[1].x - drLocal.x, valid[1].y - drLocal.y);
795
+ const [primary, alt] =
796
+ d0 <= d1 ? [valid[0], valid[1]] : [valid[1], valid[0]];
708
797
  const ll = unprojectFromLocal(projectionCenter, primary);
709
798
  const altLl = unprojectFromLocal(projectionCenter, alt);
710
799
  const residual = residualSpreadNm(lops, circles, primary);
@@ -815,6 +904,7 @@ function advanceObservation(obs, displacement) {
815
904
  assumed_lon: moved.longitude,
816
905
  azimuth_true: obs.azimuth_true,
817
906
  intercept_nm: obs.intercept_nm,
907
+ lop_type: obs.lop_type,
818
908
  };
819
909
  }
820
910
  // CPL: advance the circle's center; radius is unchanged (a circle of
@@ -850,4 +940,5 @@ module.exports = {
850
940
  leastSquaresFit,
851
941
  resolveFix,
852
942
  advanceObservation,
943
+ onObserverRay,
853
944
  };