@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 +102 -0
- package/SPEC.md +6 -2
- package/package.json +1 -1
- package/plugin/db.js +43 -1
- package/plugin/derived-current.js +66 -10
- package/plugin/engine.js +11 -0
- package/plugin/fix-pipeline.js +8 -6
- package/plugin/fixes.js +97 -6
- package/plugin/index.js +262 -75
- package/plugin/publish-gate.js +121 -0
- package/plugin/training.js +30 -6
- package/tests/db.test.js +41 -1
- package/tests/derived-current.test.js +128 -0
- package/tests/engine.test.js +19 -0
- package/tests/fix-pipeline.test.js +4 -1
- package/tests/fixes.test.js +155 -0
- package/tests/plugin.test.js +317 -11
- package/tests/publish-gate.test.js +104 -0
- package/tests/training.test.js +45 -0
- package/tools/replay-passage.js +1 -0
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
|
|
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
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 =
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
156
|
-
|
|
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
|
}
|
package/plugin/fix-pipeline.js
CHANGED
|
@@ -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 —
|
|
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}
|
|
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,
|
|
275
|
-
const hours = Number.isFinite(
|
|
276
|
-
? Math.max(0,
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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(
|
|
706
|
-
const d1 = Math.hypot(
|
|
707
|
-
const [primary, alt] =
|
|
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
|
};
|