@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -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
- // v1 → v2: running-fix provenance column on `fixes`. Fresh databases
197
- // get the column from the DDL above; existing ones are altered in
198
- // place. The stored version is read *after* the DDL (it creates
199
- // dr_state_store on a fresh database, where there is nothing to
200
- // migrate) and *before* the new version is recorded below.
201
- const storedVersion = getState(db, "schema_version");
202
- if (storedVersion != null && Number(storedVersion) < 2) {
203
- db.exec("ALTER TABLE fixes ADD COLUMN derived_from_fix_id INTEGER");
204
- }
204
+ migrate(db);
205
205
  // Record schema version so future migrations can branch on it.
206
206
  setState(db, "schema_version", String(SCHEMA_VERSION));
207
207
  return db;
208
208
  }
209
209
 
210
+ /**
211
+ * Column additions applied via ALTER TABLE, guarded by PRAGMA table_info
212
+ * so each is idempotent. Run unconditionally on open — the sea-trial
213
+ * database carried a schema_version written by a build whose numbering
214
+ * doesn't match this tree's history, so version-gated migrations would
215
+ * silently skip. Each step is cheap (one PRAGMA read).
216
+ *
217
+ * @type {Array<{table: string, column: string, ddl: string}>}
218
+ */
219
+ const COLUMN_MIGRATIONS = [
220
+ // Schema v2 (running fix): provenance for single-observation running
221
+ // fixes advanced from a previous confirmed fix.
222
+ {
223
+ table: "fixes",
224
+ column: "derived_from_fix_id",
225
+ ddl: "ALTER TABLE fixes ADD COLUMN derived_from_fix_id INTEGER",
226
+ },
227
+ // Schema v2 (sea trial 2026-09-06): persist the raw user-entered sight
228
+ // inputs so reductions can be re-run/backtested without algebraic
229
+ // archaeology.
230
+ {
231
+ table: "lines_of_position",
232
+ column: "raw_hs_deg",
233
+ ddl: "ALTER TABLE lines_of_position ADD COLUMN raw_hs_deg REAL",
234
+ },
235
+ {
236
+ table: "lines_of_position",
237
+ column: "index_correction_deg",
238
+ ddl: "ALTER TABLE lines_of_position ADD COLUMN index_correction_deg REAL",
239
+ },
240
+ {
241
+ table: "lines_of_position",
242
+ column: "eye_height_m",
243
+ ddl: "ALTER TABLE lines_of_position ADD COLUMN eye_height_m REAL",
244
+ },
245
+ {
246
+ table: "lines_of_position",
247
+ column: "limb",
248
+ ddl: "ALTER TABLE lines_of_position ADD COLUMN limb TEXT",
249
+ },
250
+ {
251
+ table: "lines_of_position",
252
+ column: "ho_deg",
253
+ ddl: "ALTER TABLE lines_of_position ADD COLUMN ho_deg REAL",
254
+ },
255
+ {
256
+ table: "lines_of_position",
257
+ column: "hc_deg",
258
+ ddl: "ALTER TABLE lines_of_position ADD COLUMN hc_deg REAL",
259
+ },
260
+ {
261
+ table: "circular_position_lines",
262
+ column: "raw_angle_deg",
263
+ ddl: "ALTER TABLE circular_position_lines ADD COLUMN raw_angle_deg REAL",
264
+ },
265
+ {
266
+ table: "circular_position_lines",
267
+ column: "object_height_m",
268
+ ddl: "ALTER TABLE circular_position_lines ADD COLUMN object_height_m REAL",
269
+ },
270
+ ];
271
+
272
+ /**
273
+ * Applies pending column migrations.
274
+ *
275
+ * @param {import("node:sqlite").DatabaseSync} db
276
+ * @returns {void}
277
+ */
278
+ function migrate(db) {
279
+ for (const step of COLUMN_MIGRATIONS) {
280
+ const columns = db.prepare(`PRAGMA table_info(${step.table})`).all();
281
+ const exists = columns.some((c) => c.name === step.column);
282
+ if (!exists) db.exec(step.ddl);
283
+ }
284
+ }
285
+
210
286
  /**
211
287
  * Reads a scalar value from `dr_state_store`.
212
288
  *
@@ -348,8 +424,9 @@ function recordLineOfPosition(db, r) {
348
424
  const stmt = db.prepare(
349
425
  `INSERT INTO lines_of_position (
350
426
  timestamp, lop_type, assumed_lat, assumed_lon, azimuth_true,
351
- intercept_nm, body_or_object, confirmed_by
352
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
427
+ intercept_nm, body_or_object, confirmed_by,
428
+ raw_hs_deg, index_correction_deg, eye_height_m, limb, ho_deg, hc_deg
429
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
353
430
  );
354
431
  const info = stmt.run(
355
432
  r.timestamp,
@@ -360,6 +437,14 @@ function recordLineOfPosition(db, r) {
360
437
  r.intercept_nm ?? null,
361
438
  r.body_or_object ?? null,
362
439
  r.confirmed_by ?? null,
440
+ // Raw user-entered sight inputs (schema v2): so a reduction can be
441
+ // re-run/backtested later without reconstructing Hs from the result.
442
+ r.raw_hs_deg ?? null,
443
+ r.index_correction_deg ?? null,
444
+ r.eye_height_m ?? null,
445
+ r.limb ?? null,
446
+ r.ho_deg ?? null,
447
+ r.hc_deg ?? null,
363
448
  );
364
449
  return Number(info.lastInsertRowid);
365
450
  }
@@ -386,8 +471,9 @@ function recordCircularPositionLine(db, r) {
386
471
  const stmt = db.prepare(
387
472
  `INSERT INTO circular_position_lines (
388
473
  timestamp, cpl_type, center_lat, center_lon, radius_nm,
389
- radius_uncertainty_nm, source_object, confirmed_by
390
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
474
+ radius_uncertainty_nm, source_object, confirmed_by,
475
+ raw_angle_deg, object_height_m
476
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
391
477
  );
392
478
  const info = stmt.run(
393
479
  r.timestamp,
@@ -398,6 +484,8 @@ function recordCircularPositionLine(db, r) {
398
484
  r.radius_uncertainty_nm ?? null,
399
485
  r.source_object ?? null,
400
486
  r.confirmed_by ?? null,
487
+ r.raw_angle_deg ?? null,
488
+ r.object_height_m ?? null,
401
489
  );
402
490
  return Number(info.lastInsertRowid);
403
491
  }
@@ -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. At 2 mm this is far below
48
- * any instrument resolution; it exists so that a post-snap divergence of
49
- * floating-point epsilon (~1e-9 nm from repeated destinationPoint calls)
50
- * against an exactly-zero radius does not count as exceeding.
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 = 1e-6;
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
  /**
@@ -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
- longitude: candidate.longitude,
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
  };