@meri-imperiumi/signalk-dead-reckoning 0.7.0 → 0.8.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 +95 -0
- package/package.json +1 -1
- package/plugin/celestial.js +41 -0
- package/plugin/db.js +101 -13
- package/plugin/divergence.js +7 -5
- package/plugin/engine.js +11 -2
- package/plugin/fix-pipeline.js +76 -4
- package/plugin/index.js +170 -4
- package/plugin/training.js +112 -21
- package/plugin/uncertainty.js +128 -78
- package/public/dr-app.js +9 -3
- package/public/dr-fix-panel.js +5 -2
- package/public/dr-sight-panel.js +9 -1
- package/tests/celestial.test.js +67 -0
- package/tests/divergence.test.js +15 -5
- package/tests/fix-pipeline.test.js +53 -0
- package/tests/plugin.test.js +39 -20
- package/tests/replay-passage.test.js +318 -0
- package/tests/training.test.js +332 -176
- package/tests/uncertainty.test.js +154 -85
- package/tools/replay-passage.js +1090 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,100 @@ 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.8.0] - 2026-09-09
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Celestial sights are no longer silently forced through the noon
|
|
14
|
+
(meridian-altitude) reducer** (sea trial 2026-08-31, Aitutaki→Niue:
|
|
15
|
+
both Sun sights reduced to −69.3°/−31.9°). The sight panel's
|
|
16
|
+
`readForm()` read checkboxes via `el.value` — always the string
|
|
17
|
+
`"on"` regardless of checked state — so every sight was POSTed with
|
|
18
|
+
`noon: true`. Checkboxes are now read via `el.checked` (both panels).
|
|
19
|
+
- **The webapp no longer overwrites its own-vessel GPS position with
|
|
20
|
+
the DR shadow boat's** (sea trial: the map boat flipped ~50 NM
|
|
21
|
+
between GPS and the ghost, and fix #9 recorded the ghost position as
|
|
22
|
+
a "GNSS" fix 87.5 km from the boat). Deltas whose context is the
|
|
23
|
+
shadow vessel now route to neither the AIS target store nor the
|
|
24
|
+
own-vessel view-model.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- **The uncertainty cone now grows with current-knowledge**, not just
|
|
28
|
+
distance run (sea trial: 88 km of DR-vs-GPS divergence while the
|
|
29
|
+
polygon "expected" ~2 NM). Radius combines the distance-run error
|
|
30
|
+
and a per-tier current residual (manual 0.25 kn, weather/pilot
|
|
31
|
+
0.3 kn, zero-vector 1.0 kn) root-sum-square; the fallback margin
|
|
32
|
+
rose from 1° to 4° per NM run (measured open-loop rates were
|
|
33
|
+
7–12× the old value); the empirical rate is now a median with a
|
|
34
|
+
2 kn per-row cap (the spec's original intent — a garbage 3058 NM
|
|
35
|
+
correction previously poisoned an EWMA at "145 kn"); the floor rose
|
|
36
|
+
to 0.05 NM and the origin's own error radius seeds it (a celestial
|
|
37
|
+
fix is realistically ~5 nm — the cone never claims GPS confidence
|
|
38
|
+
below that).
|
|
39
|
+
- **The divergence advisory no longer flaps after every fix or print
|
|
40
|
+
"0.27 nm exceeds expected 0.27 nm"**: the exceedance deadband is
|
|
41
|
+
instrument-scale (0.005 NM) instead of float epsilon, and the alert
|
|
42
|
+
message states the exceedance margin.
|
|
43
|
+
- **"Tack/gybe in progress" no longer latches open for the whole
|
|
44
|
+
passage** in ss3–4 seaway: rate-of-turn is measured over a 6 s
|
|
45
|
+
rolling window (a single second of wave yaw can't open it), the
|
|
46
|
+
re-stabilization tolerances scale with sea state, and a window still
|
|
47
|
+
open after 5 minutes force-closes without classifying a maneuver.
|
|
48
|
+
The pre-maneuver AWA for tack/gybe classification is now taken from
|
|
49
|
+
the base of the ROT window (the previous tick's AWA has already
|
|
50
|
+
flipped with the bow by the time the window opens).
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
- **Passage replay backtest tool** (`tools/replay-passage.js`, minimal
|
|
54
|
+
SPEC §10.2 scope): replays a historical passage from the Signal K
|
|
55
|
+
History API through the real DR engine and reports how the shadow
|
|
56
|
+
boat diverged from GPS. Fetches a range in chunks at native 10s
|
|
57
|
+
resolution, forward-fills sparse sensors (unwrapping the signed AWA
|
|
58
|
+
across ±π through gybes), queries `navigation.attitude.roll`
|
|
59
|
+
(radians) directly — the attitude object itself is not recorded, only
|
|
60
|
+
its component paths — so heel reaches the matrix bins, seeds the DR
|
|
61
|
+
origin at the first GPS fix and never re-anchors it. Four variants run side by side: cold (no
|
|
62
|
+
training, tier-5 zero current), learning (Training Mode on, mirroring
|
|
63
|
+
a first live passage), plus the same two with a SCUD satellite-derived
|
|
64
|
+
current source — daily 0.25° fields from the PacIOOS ERDDAP,
|
|
65
|
+
nearest-cell in space, time-interpolated over source holes (and held
|
|
66
|
+
at the span ends), resolved at the shadow boat's own position. Also
|
|
67
|
+
derives the implied current (ground vector minus water-track vector)
|
|
68
|
+
as a diagnostic of what DR is missing. Outputs an hourly divergence
|
|
69
|
+
table, an implied-vs-SCUD current comparison, a GeoJSON track file,
|
|
70
|
+
full-resolution samples, and a self-contained SVG HTML report. A
|
|
71
|
+
`--stw-scale` option multiplies STW end-to-end (engine, training,
|
|
72
|
+
matrix lookup) to test paddlewheel-calibration hypotheses against a
|
|
73
|
+
passage.
|
|
74
|
+
Smoketests cover URL building, row parsing/filling, the current-grid
|
|
75
|
+
interpolation, and three synthetic passages (no-drift, known-current,
|
|
76
|
+
leeway absorbed by training).
|
|
77
|
+
- **Observation submission is speed-plausibility-gated**: a sight,
|
|
78
|
+
bearing or vertical-angle observation whose reduction implies the
|
|
79
|
+
vessel traveled faster than 50 kn since the last fix (e.g. the
|
|
80
|
+
trial's 3058 NM Antarctica sight ≈ 140 kn) is rejected with an
|
|
81
|
+
explanatory message in the form, so the user can fix the entry.
|
|
82
|
+
Displacements within realistic observation quality (~5 NM) always
|
|
83
|
+
pass.
|
|
84
|
+
- **Noon sights carry meridian and sanity guards**: a sight whose Sun
|
|
85
|
+
is more than 20° from the meridian (LHA), or whose computed latitude
|
|
86
|
+
lands more than 12° from the assumed/DR position, is refused rather
|
|
87
|
+
than reduced into garbage.
|
|
88
|
+
- **Fix confirmation carries a gross-displacement guard**: confirming
|
|
89
|
+
a fix more than 100 NM (configurable, `fixes.maxDisplacementNm`)
|
|
90
|
+
from the current DR origin is rejected with 422 unless the request
|
|
91
|
+
carries `force: true`; `/fix/resolve` previews the candidate's
|
|
92
|
+
displacement and flags gross candidates.
|
|
93
|
+
- **Raw sight inputs are persisted** (`raw_hs_deg`, index correction,
|
|
94
|
+
eye height, limb, computed Ho/Hc on lines_of_position; angle/height
|
|
95
|
+
on circular_position_lines) so reductions can be re-run and
|
|
96
|
+
backtested — reconstructing the trial's sights from the stored
|
|
97
|
+
noon results required algebraic archaeology.
|
|
98
|
+
- **`elapsedSinceOriginS` and the origin error radius survive plugin
|
|
99
|
+
restarts** (persisted alongside `last_known_good_fix`), so "since
|
|
100
|
+
last fix" and the cone no longer reset mid-excursion on a restart.
|
|
101
|
+
|
|
8
102
|
## [0.7.0] - 2026-08-29
|
|
9
103
|
|
|
10
104
|
### Added
|
|
@@ -137,6 +231,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
137
231
|
anchors: a from-scratch Meeus reduction for the Sun (≤0.7′, the
|
|
138
232
|
anchor's own accuracy class), the AA low-precision series for the
|
|
139
233
|
Moon (≤20′), and paper-almanac star values.
|
|
234
|
+
|
|
140
235
|
- **The webapp's "Ghost Track" heading above the map is gone.** It
|
|
141
236
|
wasted vertical space the map could use — the map card now opens with
|
|
142
237
|
no chrome above it, so the chart starts higher on the page. The
|
package/package.json
CHANGED
package/plugin/celestial.js
CHANGED
|
@@ -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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/plugin/divergence.js
CHANGED
|
@@ -44,12 +44,14 @@ const DEFAULT_SUSTAIN_S = 30;
|
|
|
44
44
|
const DEFAULT_CLEAR_S = 30;
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* Deadband (nm) on the exceedance comparison
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* Deadband (nm) on the exceedance comparison — instrument resolution,
|
|
48
|
+
* not floating-point epsilon. At 2 mm (the previous value) a divergence
|
|
49
|
+
* a hair over the threshold prints as "0.27 nm exceeds expected 0.27 nm"
|
|
50
|
+
* (sea trial 2026-08-30…09-05), and a zero-radius fresh fix lets GPS
|
|
51
|
+
* noise raise the advisory as "0.00 nm exceeds expected 0.00 nm". At
|
|
52
|
+
* ~9 m real disagreement is required before it counts as exceeding.
|
|
51
53
|
*/
|
|
52
|
-
const EPS_NM =
|
|
54
|
+
const EPS_NM = 0.005;
|
|
53
55
|
|
|
54
56
|
/**
|
|
55
57
|
* Creates a fresh monitor state.
|
package/plugin/engine.js
CHANGED
|
@@ -51,6 +51,10 @@ class DeadReckoningEngine {
|
|
|
51
51
|
* @param {{latitude: number, longitude: number}|null} [opts.origin]
|
|
52
52
|
* @param {number} [opts.logNm] - cumulative water-track log (nm)
|
|
53
53
|
* @param {number} [opts.tripLogNm]
|
|
54
|
+
* @param {number} [opts.originErrorNm] - error radius of the fix that
|
|
55
|
+
* seeded the origin (nm); floors the uncertainty cone until distance
|
|
56
|
+
* run outgrows it (a celestial fix is realistically ~5 nm accurate —
|
|
57
|
+
* the cone must not collapse to GPS-level confidence below that)
|
|
54
58
|
*/
|
|
55
59
|
constructor(opts = {}) {
|
|
56
60
|
/** @type {{latitude: number, longitude: number}|null} */
|
|
@@ -66,6 +70,8 @@ class DeadReckoningEngine {
|
|
|
66
70
|
* uncertainty-polygon growth axis (DR error compounds with distance
|
|
67
71
|
* more honestly than with time). */
|
|
68
72
|
this.logNmSinceOrigin = 0;
|
|
73
|
+
/** @type {number} error radius of the origin-seeding fix (nm) */
|
|
74
|
+
this.originErrorNm = opts.originErrorNm ?? 0;
|
|
69
75
|
/** @type {string} active calculation method (SPEC §3.1) */
|
|
70
76
|
this.method = "inertial-paddlewheel";
|
|
71
77
|
/** @type {boolean} whether DR is authoritative for navigation.position */
|
|
@@ -75,15 +81,18 @@ class DeadReckoningEngine {
|
|
|
75
81
|
/**
|
|
76
82
|
* Snaps the DR origin to a confirmed fix, recording the elapsed time so
|
|
77
83
|
* that deviation-rate can be computed later (SPEC §4.5, §9.3). Does not
|
|
78
|
-
* touch the running logs.
|
|
84
|
+
* touch the running logs. `originErrorNm` seeds the uncertainty cone's
|
|
85
|
+
* floor with the fix's own error radius.
|
|
79
86
|
*
|
|
80
87
|
* @param {{latitude: number, longitude: number}} fix
|
|
88
|
+
* @param {number} [originErrorNm] - the fix's error radius (nm)
|
|
81
89
|
* @returns {void}
|
|
82
90
|
*/
|
|
83
|
-
snapToFix(fix) {
|
|
91
|
+
snapToFix(fix, originErrorNm = 0) {
|
|
84
92
|
this.origin = { latitude: fix.latitude, longitude: fix.longitude };
|
|
85
93
|
this.elapsedSinceOriginS = 0;
|
|
86
94
|
this.logNmSinceOrigin = 0;
|
|
95
|
+
this.originErrorNm = Math.max(0, originErrorNm);
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
/**
|
package/plugin/fix-pipeline.js
CHANGED
|
@@ -453,10 +453,10 @@ function confirmFix(db, candidate, engine, helpers, opts = {}) {
|
|
|
453
453
|
}
|
|
454
454
|
|
|
455
455
|
if (resets && engine) {
|
|
456
|
-
engine.snapToFix(
|
|
457
|
-
latitude: candidate.latitude,
|
|
458
|
-
|
|
459
|
-
|
|
456
|
+
engine.snapToFix(
|
|
457
|
+
{ latitude: candidate.latitude, longitude: candidate.longitude },
|
|
458
|
+
opts.estimatedErrorRadius ?? defaultOriginErrorNm(candidate.source_type),
|
|
459
|
+
);
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
return {
|
|
@@ -467,9 +467,81 @@ function confirmFix(db, candidate, engine, helpers, opts = {}) {
|
|
|
467
467
|
};
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Default origin error radius (nm) by fix source when the client doesn't
|
|
472
|
+
* supply one. GNSS positions are metre-scale; every human observation
|
|
473
|
+
* (celestial sight, bearing, vertical angle, manual entry) is realistically
|
|
474
|
+
* ~5 nm in seagoing conditions — the uncertainty cone must not collapse to
|
|
475
|
+
* GPS-level confidence after a sight fix (sea-trial discussion 2026-09-06:
|
|
476
|
+
* a celestial sight is rarely more accurate than ~5 nm).
|
|
477
|
+
*
|
|
478
|
+
* @param {string} sourceType
|
|
479
|
+
* @returns {number}
|
|
480
|
+
*/
|
|
481
|
+
function defaultOriginErrorNm(sourceType) {
|
|
482
|
+
return sourceType === "gps" ? 0.05 : 5;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Maximum implied vessel speed (kn) for an observation to be physically
|
|
487
|
+
* plausible: how fast the boat would have had to travel from the last
|
|
488
|
+
* origin-reset fix to be where the reduced observation puts it (sea
|
|
489
|
+
* trial 2026-08-31: the Antarctica sight implied ~140 kn).
|
|
490
|
+
*/
|
|
491
|
+
const MAX_IMPLIED_SPEED_KN = 50;
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Displacement (nm) below which the gate never rejects: a real-world
|
|
495
|
+
* celestial sight or bearing is rarely better than ~5 nm accurate, so a
|
|
496
|
+
* displacement inside that band is normal observation quality and none
|
|
497
|
+
* of the gate's business (it also keeps right-after-a-fix geometry —
|
|
498
|
+
* elapsed ≈ 0, small offsets — from implying absurd speeds). Beyond it,
|
|
499
|
+
* the implied-speed test applies.
|
|
500
|
+
*/
|
|
501
|
+
const MIN_GATE_DISPLACEMENT_NM = 5;
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Speed-plausibility gate for observation submission. Given the
|
|
505
|
+
* displacement a reduced observation implies (perpendicular distance from
|
|
506
|
+
* the DR origin to the LOP/CPL, or the intercept magnitude for a celestial
|
|
507
|
+
* sight) and the seconds elapsed from the last origin-reset fix *at the
|
|
508
|
+
* observation time*, returns whether the implied speed is physically
|
|
509
|
+
* plausible. Displacements below MIN_GATE_DISPLACEMENT_NM always pass.
|
|
510
|
+
*
|
|
511
|
+
* Pure logic — unit-testable without Signal K.
|
|
512
|
+
*
|
|
513
|
+
* @param {object} input
|
|
514
|
+
* @param {number} [input.displacementNm] - implied displacement (nm);
|
|
515
|
+
* null/undefined skips the gate (no reduction to judge)
|
|
516
|
+
* @param {number|null} [input.elapsedS] - seconds since the last
|
|
517
|
+
* origin-reset fix at the observation time; null/negative (observation
|
|
518
|
+
* predates the origin, e.g. a backfill) skips the gate
|
|
519
|
+
* @returns {{ok: boolean, skipped: boolean, impliedKn: number|null}}
|
|
520
|
+
*/
|
|
521
|
+
function evaluateObservationPlausibility(input) {
|
|
522
|
+
const disp = input.displacementNm;
|
|
523
|
+
if (disp == null || !Number.isFinite(disp) || disp < 0) {
|
|
524
|
+
return { ok: true, skipped: true, impliedKn: null };
|
|
525
|
+
}
|
|
526
|
+
if (disp <= MIN_GATE_DISPLACEMENT_NM) {
|
|
527
|
+
return { ok: true, skipped: false, impliedKn: null };
|
|
528
|
+
}
|
|
529
|
+
const elapsedS = input.elapsedS;
|
|
530
|
+
if (elapsedS == null || !Number.isFinite(elapsedS) || elapsedS < 0) {
|
|
531
|
+
return { ok: true, skipped: true, impliedKn: null };
|
|
532
|
+
}
|
|
533
|
+
// Zero elapsed with a large displacement implies infinite speed.
|
|
534
|
+
const impliedKn = elapsedS > 0 ? disp / (elapsedS / 3600) : Infinity;
|
|
535
|
+
return { ok: impliedKn <= MAX_IMPLIED_SPEED_KN, skipped: false, impliedKn };
|
|
536
|
+
}
|
|
537
|
+
|
|
470
538
|
module.exports = {
|
|
471
539
|
resolveCandidateFix,
|
|
472
540
|
confirmFix,
|
|
473
541
|
loadObservationsById,
|
|
474
542
|
advanceToLatest,
|
|
543
|
+
defaultOriginErrorNm,
|
|
544
|
+
evaluateObservationPlausibility,
|
|
545
|
+
MAX_IMPLIED_SPEED_KN,
|
|
546
|
+
MIN_GATE_DISPLACEMENT_NM,
|
|
475
547
|
};
|