@meri-imperiumi/signalk-dead-reckoning 0.1.0 → 0.2.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 +62 -0
- package/package.json +1 -1
- package/plugin/hysteresis.js +82 -0
- package/plugin/index.js +131 -43
- package/plugin/uncertainty.js +12 -1
- package/public/dr-map-view.js +21 -12
- package/public/dr-viewmodel.js +3 -0
- package/tests/dr-viewmodel.test.js +14 -0
- package/tests/hysteresis.test.js +62 -0
- package/tests/plugin.test.js +314 -15
- package/tests/uncertainty.test.js +25 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,68 @@ 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
|
+
## [0.2.0] - 2026-08-27
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Dockside false positives while moored/anchored** — three alerts
|
|
12
|
+
that misfire on a tied-up boat are now suppressed in the moored/
|
|
13
|
+
anchored regime (consistent with the divergence monitor's existing
|
|
14
|
+
§7.1–2 suppression):
|
|
15
|
+
- *Divergence advisory* no longer raises on GPS wander at the dock:
|
|
16
|
+
the uncertainty radius previously collapsed to exactly 0.0 nm at
|
|
17
|
+
zero distance run, so a few metres of marina multipath "exceeded
|
|
18
|
+
expected uncertainty × 1.5" and sustained for 30 s raised
|
|
19
|
+
`DR-GPS divergence 0.00 nm exceeds expected 0.00 nm`. The radius
|
|
20
|
+
now floors at `MIN_RADIUS_NM` (0.02 nm ≈ 37 m, GNSS noise scale)
|
|
21
|
+
— DR error can never honestly be smaller than the GPS it was
|
|
22
|
+
seeded from.
|
|
23
|
+
- *Phantom "paddlewheel fouled" alert* — the fouling detector saw
|
|
24
|
+
STW≈0 (a moored paddlewheel's honest reading) plus wind on the
|
|
25
|
+
mast or GPS jitter and concluded the boat was making way. The
|
|
26
|
+
detector (and the whole training/matrix path, incl. auto tack
|
|
27
|
+
logging) is skipped while moored/anchored, and any earlier verdict
|
|
28
|
+
is withdrawn.
|
|
29
|
+
- *Sticky advisories* — a divergence advisory raised just before
|
|
30
|
+
mooring could never clear (the monitor isn't fed while suppressed).
|
|
31
|
+
It is now withdrawn with a `DR monitoring suspended while
|
|
32
|
+
moored/anchored` notification, and the divergence readout publishes
|
|
33
|
+
an explicit `null` while suppressed so the UI drops the stale
|
|
34
|
+
figure instead of showing a frozen `0.00 nm / 306°`.
|
|
35
|
+
- **Sensor-health alerts no longer flap** — the fouling and
|
|
36
|
+
idle-but-making-way verdicts are raw per-tick threshold comparisons
|
|
37
|
+
(STW vs 0.3 kn, wind vs 3 kn, SOG vs 1 kn); a sensor hovering at a
|
|
38
|
+
threshold toggled the alert on and off every tick. Both now pass
|
|
39
|
+
through a reusable symmetric hysteresis
|
|
40
|
+
(`plugin/hysteresis.js`, `sensorHealth.sustainS`/`clearS`, default
|
|
41
|
+
10 s/10 s): a real fault still surfaces after the sustain window,
|
|
42
|
+
threshold hover raises nothing.
|
|
43
|
+
- **Water-track log survives restart** — `navigation.deadReckoning.log`
|
|
44
|
+
and the trip log were persisted every flush but never restored at
|
|
45
|
+
start, resetting the headline to 0.00 nm on every server restart
|
|
46
|
+
mid-passage. Both are now restored from the `dr_state_store`.
|
|
47
|
+
- **Map layers control usable again** — the divergence chip overlay sat
|
|
48
|
+
at the top-right corner on top of Leaflet's layers control (equal
|
|
49
|
+
z-index, later sibling wins), covering its toggle: broken-looking
|
|
50
|
+
styling and swallowed clicks meant selecting a chart provider did
|
|
51
|
+
nothing. The chip now lives at the bottom-right (attribution is off,
|
|
52
|
+
so the corner is free).
|
|
53
|
+
- The DR vs GPS headline no longer shows a bearing below display
|
|
54
|
+
resolution (`0.00 nm / 306°` → `0.00 nm`): the bearing of a
|
|
55
|
+
zero-length noise vector is meaningless.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
- When the server has no charts configured, the online OpenStreetMap
|
|
59
|
+
fallback layer is offered in the layers control but no longer
|
|
60
|
+
auto-added to the map (it was documented as opt-in; the tile-less
|
|
61
|
+
offline-first plot is the default again until selected).
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
- `plugin/hysteresis.js` — generic symmetric sustain/clear debounce for
|
|
65
|
+
boolean conditions, extracted from the divergence advisory's shape
|
|
66
|
+
and unit-tested; also used for the sensor-health alerts.
|
|
67
|
+
- Work doc #17 on the rngit board tracks this batch (dockside false
|
|
68
|
+
positives: divergence advisory, fouled paddlewheel, layers control).
|
|
69
|
+
|
|
8
70
|
## [0.1.0] - 2026-08-27
|
|
9
71
|
|
|
10
72
|
### Changed
|
package/package.json
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic symmetric hysteresis for a boolean condition (sustained raise,
|
|
3
|
+
* sustained clear), extracted from the divergence advisory monitor's
|
|
4
|
+
* debounce shape so other watchkeeper alerts (§6.3 paddlewheel fouling,
|
|
5
|
+
* idle-but-making-way) can't flap when a sensor hovers at a threshold.
|
|
6
|
+
*
|
|
7
|
+
* The condition must persist for `sustainS` before the debounced flag
|
|
8
|
+
* goes true, and stay false for `clearS` before it drops — a single
|
|
9
|
+
* jittery tick neither raises nor clears.
|
|
10
|
+
*
|
|
11
|
+
* Pure logic over an explicit state object — unit-testable without
|
|
12
|
+
* Signal K plumbing.
|
|
13
|
+
*
|
|
14
|
+
* @file hysteresis.js
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Default windows (seconds) for sensor-health style flags. Slower than
|
|
19
|
+
* the divergence advisory's 30 s — fouling and idle transitions are
|
|
20
|
+
* mechanical conditions that don't change meaningfully in a second, but
|
|
21
|
+
* fast enough that a real fault surfaces within a watch check.
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_SUSTAIN_S = 10;
|
|
24
|
+
const DEFAULT_CLEAR_S = 10;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a fresh debounce state.
|
|
28
|
+
*
|
|
29
|
+
* @returns {{active: boolean, exceedS: number, insideS: number}}
|
|
30
|
+
*/
|
|
31
|
+
function createFlagState() {
|
|
32
|
+
return {
|
|
33
|
+
/** debounced flag currently raised */
|
|
34
|
+
active: false,
|
|
35
|
+
/** sustained seconds of the raw condition accumulated */
|
|
36
|
+
exceedS: 0,
|
|
37
|
+
/** sustained seconds of the raw condition being absent */
|
|
38
|
+
insideS: 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Advances the debounce one tick.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} state - from createFlagState(), mutated
|
|
46
|
+
* @param {boolean} raw - the undebounced per-tick condition
|
|
47
|
+
* @param {number} dtS - tick interval in seconds
|
|
48
|
+
* @param {object} [opts]
|
|
49
|
+
* @param {number} [opts.sustainS=DEFAULT_SUSTAIN_S]
|
|
50
|
+
* @param {number} [opts.clearS=DEFAULT_CLEAR_S]
|
|
51
|
+
* @returns {{active: boolean, transition: "raise"|"clear"|null}}
|
|
52
|
+
*/
|
|
53
|
+
function flagTick(state, raw, dtS, opts = {}) {
|
|
54
|
+
const sustainS = opts.sustainS ?? DEFAULT_SUSTAIN_S;
|
|
55
|
+
const clearS = opts.clearS ?? DEFAULT_CLEAR_S;
|
|
56
|
+
let transition = null;
|
|
57
|
+
|
|
58
|
+
if (raw) {
|
|
59
|
+
state.insideS = 0;
|
|
60
|
+
state.exceedS += dtS;
|
|
61
|
+
if (!state.active && state.exceedS >= sustainS) {
|
|
62
|
+
state.active = true;
|
|
63
|
+
transition = "raise";
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
state.exceedS = 0;
|
|
67
|
+
state.insideS += dtS;
|
|
68
|
+
if (state.active && state.insideS >= clearS) {
|
|
69
|
+
state.active = false;
|
|
70
|
+
transition = "clear";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { active: state.active, transition };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
DEFAULT_SUSTAIN_S,
|
|
79
|
+
DEFAULT_CLEAR_S,
|
|
80
|
+
createFlagState,
|
|
81
|
+
flagTick,
|
|
82
|
+
};
|
package/plugin/index.js
CHANGED
|
@@ -70,6 +70,7 @@ const {
|
|
|
70
70
|
createDivergenceState,
|
|
71
71
|
divergenceTick,
|
|
72
72
|
} = require("./divergence.js");
|
|
73
|
+
const { createFlagState, flagTick } = require("./hysteresis.js");
|
|
73
74
|
const {
|
|
74
75
|
composeFixEntry,
|
|
75
76
|
composeTackEntry,
|
|
@@ -220,6 +221,17 @@ const DEFAULT_CONFIG = {
|
|
|
220
221
|
sustainS: DEFAULT_SUSTAIN_S,
|
|
221
222
|
clearS: DEFAULT_CLEAR_S,
|
|
222
223
|
},
|
|
224
|
+
/**
|
|
225
|
+
* Sensor-health alert hysteresis (§6.3 fouling, idle-moving): the raw
|
|
226
|
+
* per-tick verdicts compare live sensors against fixed thresholds, so
|
|
227
|
+
* a gust or GPS wander hovering at a threshold would flap the alert
|
|
228
|
+
* on every tick. Both windows are symmetric — a real fault surfaces
|
|
229
|
+
* after sustainS, withdraws after clearS.
|
|
230
|
+
*/
|
|
231
|
+
sensorHealth: {
|
|
232
|
+
sustainS: 10,
|
|
233
|
+
clearS: 10,
|
|
234
|
+
},
|
|
223
235
|
logbook: {
|
|
224
236
|
enabled: false,
|
|
225
237
|
url: "http://localhost:3000/plugins/signalk-logbook/logs",
|
|
@@ -257,6 +269,8 @@ const deps = {
|
|
|
257
269
|
computeRadius,
|
|
258
270
|
createDivergenceState,
|
|
259
271
|
divergenceTick,
|
|
272
|
+
createFlagState,
|
|
273
|
+
flagTick,
|
|
260
274
|
composeFixEntry,
|
|
261
275
|
composeTackEntry,
|
|
262
276
|
composeObservationEntry,
|
|
@@ -383,6 +397,17 @@ module.exports = (app) => {
|
|
|
383
397
|
/** @type {ReturnType<typeof createDivergenceState>|null} §7.3 divergence monitor */
|
|
384
398
|
let divergence = null;
|
|
385
399
|
|
|
400
|
+
/**
|
|
401
|
+
* §6.3 sensor-health alert hysteresis: raw fouling / idle-moving
|
|
402
|
+
* verdicts debounce through these so threshold-hovering sensors
|
|
403
|
+
* can't flap notifications on every tick.
|
|
404
|
+
* @type {ReturnType<typeof createFlagState>|null}
|
|
405
|
+
*/
|
|
406
|
+
let fouledFlag = null;
|
|
407
|
+
|
|
408
|
+
/** @type {ReturnType<typeof createFlagState>|null} */
|
|
409
|
+
let movingFlag = null;
|
|
410
|
+
|
|
386
411
|
/** @type {object|null} §9.5 logbook write-through client */
|
|
387
412
|
let logbook = null;
|
|
388
413
|
|
|
@@ -476,6 +501,10 @@ module.exports = (app) => {
|
|
|
476
501
|
...DEFAULT_CONFIG.divergence,
|
|
477
502
|
...(options?.divergence ?? {}),
|
|
478
503
|
};
|
|
504
|
+
config.sensorHealth = {
|
|
505
|
+
...DEFAULT_CONFIG.sensorHealth,
|
|
506
|
+
...(options?.sensorHealth ?? {}),
|
|
507
|
+
};
|
|
479
508
|
config.logbook = {
|
|
480
509
|
...DEFAULT_CONFIG.logbook,
|
|
481
510
|
...(options?.logbook ?? {}),
|
|
@@ -492,7 +521,15 @@ module.exports = (app) => {
|
|
|
492
521
|
dbPath = join(app.getDataDirPath(), "dead-reckoning.sqlite");
|
|
493
522
|
db = deps.openDatabase(dbPath);
|
|
494
523
|
matrix = new deps.MatrixStore(db);
|
|
495
|
-
|
|
524
|
+
// Restart survival: the cumulative water-track logs are persisted
|
|
525
|
+
// every flush — restore them so the headline log doesn't reset to
|
|
526
|
+
// 0.00 on every server restart mid-passage.
|
|
527
|
+
const savedLogNm = Number(deps.getState(db, "dr_log_nm"));
|
|
528
|
+
const savedTripNm = Number(deps.getState(db, "dr_trip_log_nm"));
|
|
529
|
+
engine = new deps.DeadReckoningEngine({
|
|
530
|
+
logNm: Number.isFinite(savedLogNm) ? savedLogNm : 0,
|
|
531
|
+
tripLogNm: Number.isFinite(savedTripNm) ? savedTripNm : 0,
|
|
532
|
+
});
|
|
496
533
|
groundTrack = new deps.GroundTrack({
|
|
497
534
|
capacity: Math.max(
|
|
498
535
|
60,
|
|
@@ -541,6 +578,8 @@ module.exports = (app) => {
|
|
|
541
578
|
settleSustainS: config.training.settleSustainS,
|
|
542
579
|
});
|
|
543
580
|
divergence = deps.createDivergenceState();
|
|
581
|
+
fouledFlag = deps.createFlagState();
|
|
582
|
+
movingFlag = deps.createFlagState();
|
|
544
583
|
clockS = 0;
|
|
545
584
|
|
|
546
585
|
// Seed the engine origin from the last known good position, if any.
|
|
@@ -663,6 +702,8 @@ module.exports = (app) => {
|
|
|
663
702
|
});
|
|
664
703
|
}
|
|
665
704
|
divergence = null;
|
|
705
|
+
fouledFlag = null;
|
|
706
|
+
movingFlag = null;
|
|
666
707
|
if (sensorHealthIssue) {
|
|
667
708
|
publishNotification(PATHS.sensorHealth, {
|
|
668
709
|
state: "normal",
|
|
@@ -784,6 +825,12 @@ module.exports = (app) => {
|
|
|
784
825
|
const aws = unwrapNumber(deltaState.get("environment.wind.speedApparent"));
|
|
785
826
|
const heel = unwrapHeel(deltaState.get("navigation.attitude"));
|
|
786
827
|
const gps = unwrapPosition(deltaState.get("navigation.position"));
|
|
828
|
+
// One underway verdict for the whole tick (§7.1–2: moored/anchored is
|
|
829
|
+
// a different regime — divergence advisories, fouling suspicion and
|
|
830
|
+
// training are all suppressed there). Unknown state is treated as
|
|
831
|
+
// underway: not every install runs an autostate source.
|
|
832
|
+
const navState = deltaState.get("navigation.state");
|
|
833
|
+
const underway = navState !== "anchored" && navState !== "moored";
|
|
787
834
|
|
|
788
835
|
// GPS-derived motion (independent of the water-track sensors):
|
|
789
836
|
// updated every tick, used to detect "idle but making way" below.
|
|
@@ -801,7 +848,6 @@ module.exports = (app) => {
|
|
|
801
848
|
// Publish why DR is idle so the UI (SPEC §14.1) can explain the empty
|
|
802
849
|
// readout instead of leaving the user to guess. The vessel isn't
|
|
803
850
|
// necessarily broken — moored/anchored with no STW/heading is normal.
|
|
804
|
-
const navState = deltaState.get("navigation.state");
|
|
805
851
|
let reason;
|
|
806
852
|
if (navState === "moored" || navState === "anchored") {
|
|
807
853
|
reason = navState;
|
|
@@ -819,12 +865,25 @@ module.exports = (app) => {
|
|
|
819
865
|
// polygon must keep growing — by the ground distance travelled with
|
|
820
866
|
// no water track — or it falsely advertises confidence. GPS stays
|
|
821
867
|
// authoritative until proven faulty or OVERRIDE (§7), so the
|
|
822
|
-
// watchkeeper is alerted, not left guessing.
|
|
823
|
-
|
|
824
|
-
|
|
868
|
+
// watchkeeper is alerted, not left guessing. Moored/anchored is the
|
|
869
|
+
// honest-stopped regime — dockside GPS wander is not "making way".
|
|
870
|
+
// The alert itself is debounced (config.sensorHealth): GPS wander
|
|
871
|
+
// hovering at the threshold must not flap it on and off.
|
|
872
|
+
const movingRaw =
|
|
873
|
+
underway &&
|
|
874
|
+
gpsMotion.speedKn != null &&
|
|
875
|
+
gpsMotion.speedKn > SOG_MOVING_KN;
|
|
876
|
+
const moving = deps.flagTick(
|
|
877
|
+
movingFlag,
|
|
878
|
+
movingRaw,
|
|
879
|
+
config.tickIntervalMs / 1000,
|
|
880
|
+
config.sensorHealth,
|
|
881
|
+
).active;
|
|
882
|
+
if (movingRaw) {
|
|
883
|
+
idleRunNm += (gpsMotion.speedKn * config.tickIntervalMs) / 3600000;
|
|
884
|
+
}
|
|
825
885
|
let uncertaintyValue = null;
|
|
826
886
|
if (moving) {
|
|
827
|
-
idleRunNm += (gpsMotion.speedKn * config.tickIntervalMs) / 3600000;
|
|
828
887
|
const u = deps.computeRadius({
|
|
829
888
|
elapsedDistanceNm: engine.logNmSinceOrigin + idleRunNm,
|
|
830
889
|
effectiveHitCount: 0, // unknown bin while idle — fallback growth
|
|
@@ -900,40 +959,57 @@ module.exports = (app) => {
|
|
|
900
959
|
// When GPS is reliable and we're sailing (not motoring, paddlewheel
|
|
901
960
|
// not fouled, not in a tack/gybe transient), compute the error vector
|
|
902
961
|
// vs. GPS ground truth and EMA-merge it into the matching bin.
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
962
|
+
// Moored/anchored is excluded entirely (§7.1–2 different regime):
|
|
963
|
+
// a tied-up boat has STW≈0 by design while wind blows on the mast
|
|
964
|
+
// and GPS wanders at the dock — running the trainer there would
|
|
965
|
+
// flag a phantom fouled paddlewheel, write GPS-jitter observations
|
|
966
|
+
// into the matrix, and log mooring-yaw wind shifts as fake tacks.
|
|
967
|
+
let tr = { transient: false, fouled: false };
|
|
968
|
+
if (underway) {
|
|
969
|
+
tr = deps.trainingTick(training, {
|
|
970
|
+
timestampS: clockS,
|
|
971
|
+
gps,
|
|
972
|
+
stwKn: rawStw,
|
|
973
|
+
headingTrueDeg,
|
|
974
|
+
awaDeg,
|
|
975
|
+
awsKn: aws,
|
|
976
|
+
heelDeg: heel ?? 0,
|
|
977
|
+
propulsionState: deltaState.get("propulsion.main.state"),
|
|
978
|
+
current,
|
|
979
|
+
lookupLeewayDeg: corrections.leeway_angle,
|
|
980
|
+
lookupSpeedLoss: corrections.speed_loss,
|
|
981
|
+
});
|
|
982
|
+
if (tr.observation) {
|
|
983
|
+
matrix.update(
|
|
984
|
+
{
|
|
985
|
+
sail_state: sailState,
|
|
986
|
+
sea_state: seaState,
|
|
987
|
+
stwKn: rawStw,
|
|
988
|
+
awaDeg,
|
|
989
|
+
heelDeg: heel ?? 0,
|
|
990
|
+
},
|
|
991
|
+
tr.observation,
|
|
992
|
+
{ source: "live" },
|
|
993
|
+
);
|
|
994
|
+
}
|
|
928
995
|
}
|
|
929
996
|
|
|
930
997
|
// §6.3: the fouling detector's verdict (STW≈0 while SOG/wind say
|
|
931
998
|
// the boat moves) is surfaced, not just used to gate training — a
|
|
932
999
|
// fouled paddlewheel silently integrating 0 speed is the classic
|
|
933
|
-
// way DR freezes while the vessel sails on.
|
|
1000
|
+
// way DR freezes while the vessel sails on. While moored/anchored
|
|
1001
|
+
// the detector doesn't run, so any earlier verdict is withdrawn.
|
|
1002
|
+
// Debounced through config.sensorHealth: a paddlewheel or gust
|
|
1003
|
+
// hovering at a threshold must not flap the alert on and off.
|
|
1004
|
+
const fouled = deps.flagTick(
|
|
1005
|
+
fouledFlag,
|
|
1006
|
+
Boolean(tr.fouled),
|
|
1007
|
+
config.tickIntervalMs / 1000,
|
|
1008
|
+
config.sensorHealth,
|
|
1009
|
+
).active;
|
|
934
1010
|
setSensorHealth(
|
|
935
|
-
|
|
936
|
-
|
|
1011
|
+
fouled ? "fouled" : null,
|
|
1012
|
+
fouled
|
|
937
1013
|
? "Paddlewheel appears fouled — STW≈0 while the vessel makes way. DR is integrating near-zero speed; verify position by other means"
|
|
938
1014
|
: null,
|
|
939
1015
|
);
|
|
@@ -942,10 +1018,12 @@ module.exports = (app) => {
|
|
|
942
1018
|
// edge is a completed maneuver; classify from the AWA change across
|
|
943
1019
|
// the window. Debounced so a beat's rapid back-to-back maneuvers
|
|
944
1020
|
// (or a botched tack immediately redone) log as one event.
|
|
945
|
-
const maneuver =
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1021
|
+
const maneuver = underway
|
|
1022
|
+
? deps.detectManeuver(training, {
|
|
1023
|
+
awaDeg,
|
|
1024
|
+
headingDeg: headingTrueDeg,
|
|
1025
|
+
})
|
|
1026
|
+
: null;
|
|
949
1027
|
if (maneuver) {
|
|
950
1028
|
const now = Date.now();
|
|
951
1029
|
const debounceMs = (config.logbook?.tackDebounceS ?? 120) * 1000;
|
|
@@ -984,9 +1062,11 @@ module.exports = (app) => {
|
|
|
984
1062
|
// expected growth (§8), with sustained-interval hysteresis so a
|
|
985
1063
|
// single jittery fix neither raises nor flaps the advisory.
|
|
986
1064
|
// §14.1's live divergence readout (distance + bearing) is published
|
|
987
|
-
// alongside. Suppressed at anchor/moored (different regime, §7.1–2)
|
|
988
|
-
|
|
989
|
-
|
|
1065
|
+
// alongside. Suppressed at anchor/moored (different regime, §7.1–2):
|
|
1066
|
+
// while suppressed the monitor is not fed, an advisory raised just
|
|
1067
|
+
// before the transition is withdrawn (it can never clear on its own
|
|
1068
|
+
// otherwise), and the readout is nulled so the UI doesn't show a
|
|
1069
|
+
// stale figure from the last underway tick.
|
|
990
1070
|
let dvg = null;
|
|
991
1071
|
if (gps && underway) {
|
|
992
1072
|
const distNm = deps.distanceNm(pos, gps);
|
|
@@ -1012,6 +1092,12 @@ module.exports = (app) => {
|
|
|
1012
1092
|
message: `DR-GPS divergence back within expected uncertainty (${d.divergenceNm.toFixed(2)} nm)`,
|
|
1013
1093
|
});
|
|
1014
1094
|
}
|
|
1095
|
+
} else if (divergence.active) {
|
|
1096
|
+
divergence = deps.createDivergenceState();
|
|
1097
|
+
publishNotification(PATHS.divergenceAdvisory, {
|
|
1098
|
+
state: "normal",
|
|
1099
|
+
message: "DR monitoring suspended while moored/anchored",
|
|
1100
|
+
});
|
|
1015
1101
|
}
|
|
1016
1102
|
|
|
1017
1103
|
publish({
|
|
@@ -1033,10 +1119,12 @@ module.exports = (app) => {
|
|
|
1033
1119
|
[PATHS.state]: {
|
|
1034
1120
|
status: "underway",
|
|
1035
1121
|
transient: tr.transient,
|
|
1036
|
-
fouled
|
|
1122
|
+
fouled,
|
|
1037
1123
|
},
|
|
1038
1124
|
[PATHS.elapsedSinceFix]: engine.elapsedSinceOriginS,
|
|
1039
|
-
|
|
1125
|
+
// Always published: null while suppressed (moored/anchored or no
|
|
1126
|
+
// GPS) so consumers drop any stale underway figure.
|
|
1127
|
+
[PATHS.divergence]: dvg,
|
|
1040
1128
|
});
|
|
1041
1129
|
}
|
|
1042
1130
|
}
|
package/plugin/uncertainty.js
CHANGED
|
@@ -33,6 +33,16 @@
|
|
|
33
33
|
* @file uncertainty.js
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Minimum published radius (nm) ≈ 37 m: GNSS position noise at the
|
|
38
|
+
* dock. With zero distance run the model would otherwise report a
|
|
39
|
+
* point-exact DR position (radius 0), and any GPS wander — however
|
|
40
|
+
* small — then "exceeds expected uncertainty", firing the divergence
|
|
41
|
+
* advisory on a moored boat. DR error can never honestly be smaller
|
|
42
|
+
* than the GPS it was seeded from.
|
|
43
|
+
*/
|
|
44
|
+
const MIN_RADIUS_NM = 0.02;
|
|
45
|
+
|
|
36
46
|
/**
|
|
37
47
|
* Conservative angular-error margin (degrees of DR error per nautical
|
|
38
48
|
* mile run) used by the fallback regime. ~1° over a mile is a deliberately
|
|
@@ -166,7 +176,7 @@ function computeRadius(input) {
|
|
|
166
176
|
|
|
167
177
|
const w = blendWeight(hits);
|
|
168
178
|
const rate = w * empiricalRate + (1 - w) * fallbackRate;
|
|
169
|
-
const radius_nm = rate * elapsed;
|
|
179
|
+
const radius_nm = Math.max(rate * elapsed, MIN_RADIUS_NM);
|
|
170
180
|
|
|
171
181
|
let method;
|
|
172
182
|
if (w <= 0) method = "fallback";
|
|
@@ -185,6 +195,7 @@ function computeRadius(input) {
|
|
|
185
195
|
|
|
186
196
|
module.exports = {
|
|
187
197
|
FALLBACK_DEG_PER_NM,
|
|
198
|
+
MIN_RADIUS_NM,
|
|
188
199
|
MIN_HITS_FOR_EMPIRICAL,
|
|
189
200
|
EWMA_N,
|
|
190
201
|
EWMA_ALPHA,
|
package/public/dr-map-view.js
CHANGED
|
@@ -41,10 +41,15 @@ class DrMapView extends HTMLElement {
|
|
|
41
41
|
.map-host { position: relative; width: 100%; height: 100%; }
|
|
42
42
|
.map-wrap { width: 100%; height: 100%; background: var(--bg-base, #080a0c); }
|
|
43
43
|
/* Floating overlays: semi-transparent dark panels with sharp 1px
|
|
44
|
-
borders so they stay legible over any tileset (UI spec §7).
|
|
44
|
+
borders so they stay legible over any tileset (UI spec §7).
|
|
45
|
+
Bottom-right: the top-right corner belongs to Leaflet's layers
|
|
46
|
+
control — stacking the chip there covered the control's toggle
|
|
47
|
+
(same z-index, later sibling wins), which both looked broken and
|
|
48
|
+
swallowed the clicks that were meant to switch chart providers.
|
|
49
|
+
The bottom-right corner is free here: attribution is off. */
|
|
45
50
|
.dr-chip {
|
|
46
51
|
position: absolute;
|
|
47
|
-
|
|
52
|
+
bottom: 8px;
|
|
48
53
|
right: 8px;
|
|
49
54
|
z-index: 1000;
|
|
50
55
|
display: flex;
|
|
@@ -216,17 +221,19 @@ class DrMapView extends HTMLElement {
|
|
|
216
221
|
);
|
|
217
222
|
|
|
218
223
|
// Basemap: Signal K configured charts (offline MBTiles / tile proxies)
|
|
219
|
-
// when available
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
224
|
+
// when available. The first *configured* chart is added to the map so
|
|
225
|
+
// the plot has context immediately; the rest are registered in the
|
|
226
|
+
// layers control for switching. When the server has no charts the
|
|
227
|
+
// online OSM fallback is offered in the control but NEVER auto-added
|
|
228
|
+
// (offline-first: the DR plot stays tile-less until the watchkeeper
|
|
229
|
+
// opts in — vm.DEFAULT_OSM_LAYER's documented contract).
|
|
223
230
|
fetch("/signalk/v1/api/resources/charts")
|
|
224
231
|
.then((r) => (r.ok ? r.json() : null))
|
|
225
232
|
.then((resource) => {
|
|
226
|
-
const
|
|
227
|
-
|
|
233
|
+
const configured = vm.parseChartLayers(resource);
|
|
234
|
+
const charts =
|
|
235
|
+
configured.length > 0 ? configured : [vm.DEFAULT_OSM_LAYER];
|
|
228
236
|
const bases = {};
|
|
229
|
-
let first = null;
|
|
230
237
|
for (const c of charts) {
|
|
231
238
|
const layer = L.tileLayer(c.url, {
|
|
232
239
|
minZoom: c.minZoom,
|
|
@@ -235,10 +242,12 @@ class DrMapView extends HTMLElement {
|
|
|
235
242
|
});
|
|
236
243
|
this.tileLayers[c.identifier] = layer;
|
|
237
244
|
bases[c.name] = layer;
|
|
238
|
-
if (!first) first = layer;
|
|
239
245
|
}
|
|
240
|
-
|
|
241
|
-
|
|
246
|
+
if (configured.length > 0) {
|
|
247
|
+
// Auto-select only a real configured chart, never the fallback.
|
|
248
|
+
Object.values(bases)[0].addTo(this.map);
|
|
249
|
+
}
|
|
250
|
+
if (Object.keys(bases).length > 1 || configured.length === 0) {
|
|
242
251
|
L.control.layers(bases, {}, { collapsed: true }).addTo(this.map);
|
|
243
252
|
}
|
|
244
253
|
})
|
package/public/dr-viewmodel.js
CHANGED
|
@@ -396,6 +396,9 @@ export class Sparkline {
|
|
|
396
396
|
*/
|
|
397
397
|
export function divergenceText(d) {
|
|
398
398
|
if (!d || !Number.isFinite(d.distance_nm)) return "— nm";
|
|
399
|
+
// Below display resolution the bearing is the direction of noise on a
|
|
400
|
+
// zero-length vector — meaningless, don't show it.
|
|
401
|
+
if (d.distance_nm < 0.005) return `${d.distance_nm.toFixed(2)} nm`;
|
|
399
402
|
const brg = String(Math.round(d.bearing_true)).padStart(3, "0");
|
|
400
403
|
return `${d.distance_nm.toFixed(2)} nm / ${brg}°`;
|
|
401
404
|
}
|
|
@@ -183,6 +183,20 @@ test("divergenceText: formats distance and zero-padded bearing", async () => {
|
|
|
183
183
|
assert.strictEqual(vm.divergenceText(null), "— nm");
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
test("divergenceText: no bearing below display resolution — it is the direction of noise", async () => {
|
|
187
|
+
const vm = await loadVm();
|
|
188
|
+
// A few metres of DR-GPS offset: bearing is meaningless, distance is
|
|
189
|
+
// honest at "0.00 nm".
|
|
190
|
+
assert.strictEqual(
|
|
191
|
+
vm.divergenceText({ distance_nm: 0.004, bearing_true: 248 }),
|
|
192
|
+
"0.00 nm",
|
|
193
|
+
);
|
|
194
|
+
assert.strictEqual(
|
|
195
|
+
vm.divergenceText({ distance_nm: 0.006, bearing_true: 97 }),
|
|
196
|
+
"0.01 nm / 097°",
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
186
200
|
test("uncertaintySpec: passes radius/method with DR center", async () => {
|
|
187
201
|
const vm = await loadVm();
|
|
188
202
|
const u = vm.uncertaintySpec([60, 24], {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the generic flag hysteresis (sensor-health alert debouncing).
|
|
3
|
+
* @file hysteresis.test.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const test = require("node:test");
|
|
7
|
+
const assert = require("node:assert/strict");
|
|
8
|
+
|
|
9
|
+
const { createFlagState, flagTick } = require("../plugin/hysteresis.js");
|
|
10
|
+
|
|
11
|
+
test("flagTick: raw condition must persist sustainS before raising", () => {
|
|
12
|
+
const st = createFlagState();
|
|
13
|
+
const opts = { sustainS: 10, clearS: 10 };
|
|
14
|
+
// 9 seconds of raw true — not yet.
|
|
15
|
+
for (let i = 0; i < 9; i++) {
|
|
16
|
+
assert.strictEqual(flagTick(st, true, 1, opts).transition, null);
|
|
17
|
+
assert.strictEqual(st.active, false);
|
|
18
|
+
}
|
|
19
|
+
// 10th second — raises.
|
|
20
|
+
const out = flagTick(st, true, 1, opts);
|
|
21
|
+
assert.strictEqual(out.transition, "raise");
|
|
22
|
+
assert.strictEqual(out.active, true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("flagTick: condition must stay false clearS before clearing", () => {
|
|
26
|
+
const st = createFlagState();
|
|
27
|
+
const opts = { sustainS: 1, clearS: 3 };
|
|
28
|
+
assert.strictEqual(flagTick(st, true, 1, opts).transition, "raise");
|
|
29
|
+
// 2 seconds of raw false — still active.
|
|
30
|
+
assert.strictEqual(flagTick(st, false, 1, opts).transition, null);
|
|
31
|
+
assert.strictEqual(st.active, true);
|
|
32
|
+
assert.strictEqual(flagTick(st, false, 1, opts).transition, null);
|
|
33
|
+
// 3rd second — clears.
|
|
34
|
+
const out = flagTick(st, false, 1, opts);
|
|
35
|
+
assert.strictEqual(out.transition, "clear");
|
|
36
|
+
assert.strictEqual(out.active, false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("flagTick: a jittery tick neither raises nor clears (no flapping)", () => {
|
|
40
|
+
const st = createFlagState();
|
|
41
|
+
const opts = { sustainS: 3, clearS: 3 };
|
|
42
|
+
// Raise first.
|
|
43
|
+
for (let i = 0; i < 3; i++) flagTick(st, true, 1, opts);
|
|
44
|
+
assert.strictEqual(st.active, true);
|
|
45
|
+
// Alternating raw true/false around a threshold: timers reset each
|
|
46
|
+
// side, so no clear transition ever fires and no re-raise flaps.
|
|
47
|
+
for (let i = 0; i < 20; i++) {
|
|
48
|
+
const out = flagTick(st, i % 2 === 0, 1, opts);
|
|
49
|
+
assert.strictEqual(out.transition, null);
|
|
50
|
+
}
|
|
51
|
+
assert.strictEqual(st.active, true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("flagTick: sub-second ticks accumulate fractionally", () => {
|
|
55
|
+
const st = createFlagState();
|
|
56
|
+
const opts = { sustainS: 0.3, clearS: 0.3 };
|
|
57
|
+
const a = flagTick(st, true, 0.1, opts);
|
|
58
|
+
const b = flagTick(st, true, 0.1, opts);
|
|
59
|
+
assert.strictEqual(a.transition, null);
|
|
60
|
+
assert.strictEqual(b.transition, null);
|
|
61
|
+
assert.strictEqual(flagTick(st, true, 0.1, opts).transition, "raise");
|
|
62
|
+
});
|
package/tests/plugin.test.js
CHANGED
|
@@ -993,6 +993,21 @@ test("uncertainty polygon publishes fallback method with no correction history",
|
|
|
993
993
|
test("uncertainty polygon radius grows monotonically with distance run", async () => {
|
|
994
994
|
// Isolated DB so no leftover dr_corrections push it into empirical mode.
|
|
995
995
|
const dir = await mkdtemp(join(tmpdir(), "dr-unc-grow-"));
|
|
996
|
+
// Pre-seed the restored per-excursion distance well above the GNSS
|
|
997
|
+
// noise floor (0.02 nm) so both samples measure growth, not the floor.
|
|
998
|
+
const seedDb = require("../plugin/db.js").openDatabase(
|
|
999
|
+
join(dir, "dead-reckoning.sqlite"),
|
|
1000
|
+
);
|
|
1001
|
+
const { setState } = require("../plugin/db.js");
|
|
1002
|
+
setState(seedDb, "dr_log_since_origin", "5");
|
|
1003
|
+
// Seed the origin too, or the first GPS delta snaps to it and resets
|
|
1004
|
+
// the excursion distance back to zero.
|
|
1005
|
+
setState(
|
|
1006
|
+
seedDb,
|
|
1007
|
+
"last_known_good_fix",
|
|
1008
|
+
JSON.stringify({ latitude: 60, longitude: 24 }),
|
|
1009
|
+
);
|
|
1010
|
+
seedDb.close();
|
|
996
1011
|
const app = new FakeSignalKApp();
|
|
997
1012
|
app.dataPath = dir;
|
|
998
1013
|
const plugin = makePlugin(app);
|
|
@@ -1024,12 +1039,21 @@ test("uncertainty polygon radius grows monotonically with distance run", async (
|
|
|
1024
1039
|
r2.radius_nm > r1.radius_nm,
|
|
1025
1040
|
`radius should grow: r1=${r1.radius_nm} r2=${r2.radius_nm}`,
|
|
1026
1041
|
);
|
|
1042
|
+
assert.ok(r1.radius_nm > 0.02, "seeded run should exceed the floor");
|
|
1027
1043
|
plugin.stop();
|
|
1028
1044
|
await rm(dir, { recursive: true, force: true });
|
|
1029
1045
|
});
|
|
1030
1046
|
|
|
1031
1047
|
test("uncertainty polygon tightens toward empirical after corrections + bin hits", async () => {
|
|
1032
1048
|
const dir = await mkdtemp(join(tmpdir(), "dr-unc-emp-"));
|
|
1049
|
+
// Seed the restored per-excursion distance so both regimes measure
|
|
1050
|
+
// rates, not the GNSS-noise floor.
|
|
1051
|
+
const seedDb = require("../plugin/db.js").openDatabase(
|
|
1052
|
+
join(dir, "dead-reckoning.sqlite"),
|
|
1053
|
+
);
|
|
1054
|
+
const { setState } = require("../plugin/db.js");
|
|
1055
|
+
setState(seedDb, "dr_log_since_origin", "5");
|
|
1056
|
+
seedDb.close();
|
|
1033
1057
|
const app = new FakeSignalKApp();
|
|
1034
1058
|
app.dataPath = dir;
|
|
1035
1059
|
const plugin = makePlugin(app);
|
|
@@ -1091,7 +1115,7 @@ test("uncertainty polygon tightens toward empirical after corrections + bin hits
|
|
|
1091
1115
|
// radius at the same distance (the tightening signal).
|
|
1092
1116
|
const { computeRadius } = require("../plugin/uncertainty.js");
|
|
1093
1117
|
const fallbackOnly = computeRadius({
|
|
1094
|
-
elapsedDistanceNm: 5
|
|
1118
|
+
elapsedDistanceNm: 5,
|
|
1095
1119
|
effectiveHitCount: 0,
|
|
1096
1120
|
deviationRows: [],
|
|
1097
1121
|
stwKn: 5,
|
|
@@ -1106,6 +1130,20 @@ test("uncertainty polygon tightens toward empirical after corrections + bin hits
|
|
|
1106
1130
|
|
|
1107
1131
|
test("uncertainty polygon radius drops after a snap-to-fix resets the excursion", async () => {
|
|
1108
1132
|
const dir = await mkdtemp(join(tmpdir(), "dr-unc-snap-"));
|
|
1133
|
+
// Seed the restored per-excursion distance so `before` is well above
|
|
1134
|
+
// the GNSS-noise floor; after the snap it must drop to the floor.
|
|
1135
|
+
const seedDb = require("../plugin/db.js").openDatabase(
|
|
1136
|
+
join(dir, "dead-reckoning.sqlite"),
|
|
1137
|
+
);
|
|
1138
|
+
const { setState } = require("../plugin/db.js");
|
|
1139
|
+
setState(seedDb, "dr_log_since_origin", "10");
|
|
1140
|
+
// Seed the origin so the first GPS delta doesn't reset the excursion.
|
|
1141
|
+
setState(
|
|
1142
|
+
seedDb,
|
|
1143
|
+
"last_known_good_fix",
|
|
1144
|
+
JSON.stringify({ latitude: 60, longitude: 24 }),
|
|
1145
|
+
);
|
|
1146
|
+
seedDb.close();
|
|
1109
1147
|
const app = new FakeSignalKApp();
|
|
1110
1148
|
app.dataPath = dir;
|
|
1111
1149
|
const plugin = makePlugin(app);
|
|
@@ -1174,8 +1212,10 @@ test("divergence advisory raises on sustained DR-GPS divergence and publishes th
|
|
|
1174
1212
|
});
|
|
1175
1213
|
const router = new FakeRouter();
|
|
1176
1214
|
plugin.registerWithRouter(router);
|
|
1177
|
-
// GPS held fixed at (60, 24) while the boat sails north at
|
|
1178
|
-
//
|
|
1215
|
+
// GPS held fixed at (60, 24) while the boat sails north at a
|
|
1216
|
+
// synthetic 120 kn from it — divergence must outrun the GNSS-noise
|
|
1217
|
+
// radius floor (0.02 nm) within the fast-hysteresis window; a real
|
|
1218
|
+
// 6 kn would take ~20 s to cross it.
|
|
1179
1219
|
app.emitDelta({
|
|
1180
1220
|
context: "vessels.self",
|
|
1181
1221
|
updates: [
|
|
@@ -1185,13 +1225,13 @@ test("divergence advisory raises on sustained DR-GPS divergence and publishes th
|
|
|
1185
1225
|
path: "navigation.position",
|
|
1186
1226
|
value: { latitude: 60, longitude: 24 },
|
|
1187
1227
|
},
|
|
1188
|
-
{ path: "navigation.speedThroughWater", value:
|
|
1228
|
+
{ path: "navigation.speedThroughWater", value: 120 },
|
|
1189
1229
|
{ path: "navigation.headingTrue", value: 0 },
|
|
1190
1230
|
],
|
|
1191
1231
|
},
|
|
1192
1232
|
],
|
|
1193
1233
|
});
|
|
1194
|
-
await new Promise((r) => setTimeout(r,
|
|
1234
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1195
1235
|
// Divergence readout published and growing.
|
|
1196
1236
|
const dvg = latestDivergence(app);
|
|
1197
1237
|
assert.ok(dvg.distance_nm > 0, "divergence readout should be non-zero");
|
|
@@ -1228,13 +1268,13 @@ test("divergence advisory clears after a confirmed fix snaps DR back to GPS", as
|
|
|
1228
1268
|
path: "navigation.position",
|
|
1229
1269
|
value: { latitude: 60, longitude: 24 },
|
|
1230
1270
|
},
|
|
1231
|
-
{ path: "navigation.speedThroughWater", value:
|
|
1271
|
+
{ path: "navigation.speedThroughWater", value: 120 },
|
|
1232
1272
|
{ path: "navigation.headingTrue", value: 0 },
|
|
1233
1273
|
],
|
|
1234
1274
|
},
|
|
1235
1275
|
],
|
|
1236
1276
|
});
|
|
1237
|
-
await new Promise((r) => setTimeout(r,
|
|
1277
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1238
1278
|
const raised = latestNotification(
|
|
1239
1279
|
app,
|
|
1240
1280
|
"notifications.navigation.deadReckoning.divergenceAdvisory",
|
|
@@ -1292,27 +1332,264 @@ test("divergence monitor is suppressed at anchor", async () => {
|
|
|
1292
1332
|
],
|
|
1293
1333
|
});
|
|
1294
1334
|
await new Promise((r) => setTimeout(r, 900));
|
|
1335
|
+
// Suppression now means an explicit null readout (so connected UIs drop
|
|
1336
|
+
// any stale underway figure), not silence.
|
|
1295
1337
|
const vals = app.handledMessages
|
|
1296
1338
|
.flatMap((m) => m.message?.updates ?? [])
|
|
1297
1339
|
.flatMap((u) => u.values ?? [])
|
|
1298
1340
|
.filter((v) => v.path === "navigation.deadReckoning.divergence");
|
|
1341
|
+
assert.ok(vals.length > 0, "null divergence readout should be published");
|
|
1299
1342
|
assert.strictEqual(
|
|
1300
|
-
vals.length,
|
|
1301
|
-
|
|
1302
|
-
"divergence readout should be
|
|
1343
|
+
vals[vals.length - 1].value,
|
|
1344
|
+
null,
|
|
1345
|
+
"divergence readout should be null at anchor",
|
|
1303
1346
|
);
|
|
1304
1347
|
const notif = app.handledMessages
|
|
1305
1348
|
.flatMap((m) => m.message?.updates ?? [])
|
|
1306
1349
|
.flatMap((u) => u.values ?? [])
|
|
1307
1350
|
.find(
|
|
1308
1351
|
(v) =>
|
|
1309
|
-
v.path ===
|
|
1352
|
+
v.path ===
|
|
1353
|
+
"notifications.navigation.deadReckoning.divergenceAdvisory" &&
|
|
1354
|
+
v.value.state === "alert",
|
|
1310
1355
|
);
|
|
1311
1356
|
assert.ok(!notif, "advisory should not fire at anchor");
|
|
1312
1357
|
plugin.stop();
|
|
1313
1358
|
await rm(dir, { recursive: true, force: true });
|
|
1314
1359
|
});
|
|
1315
1360
|
|
|
1361
|
+
test("moored with wind: no fouled paddlewheel alert, advisory withdrawn, null divergence", async () => {
|
|
1362
|
+
const dir = await mkdtemp(join(tmpdir(), "dr-moored-fouled-"));
|
|
1363
|
+
const app = new FakeSignalKApp();
|
|
1364
|
+
app.dataPath = dir;
|
|
1365
|
+
const plugin = makePlugin(app);
|
|
1366
|
+
plugin.start({
|
|
1367
|
+
tickIntervalMs: 100,
|
|
1368
|
+
divergence: { sustainS: 0.3, clearS: 0.3 },
|
|
1369
|
+
});
|
|
1370
|
+
const router = new FakeRouter();
|
|
1371
|
+
plugin.registerWithRouter(router);
|
|
1372
|
+
|
|
1373
|
+
// Phase 1 — underway, sailing away from a fixed GPS at a synthetic
|
|
1374
|
+
// 120 kn (divergence outruns the 0.02 nm radius floor quickly):
|
|
1375
|
+
// advisory raises.
|
|
1376
|
+
app.emitDelta({
|
|
1377
|
+
context: "vessels.self",
|
|
1378
|
+
updates: [
|
|
1379
|
+
{
|
|
1380
|
+
values: [
|
|
1381
|
+
{
|
|
1382
|
+
path: "navigation.position",
|
|
1383
|
+
value: { latitude: 60, longitude: 24 },
|
|
1384
|
+
},
|
|
1385
|
+
{ path: "navigation.speedThroughWater", value: 120 },
|
|
1386
|
+
{ path: "navigation.headingTrue", value: 0 },
|
|
1387
|
+
],
|
|
1388
|
+
},
|
|
1389
|
+
],
|
|
1390
|
+
});
|
|
1391
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1392
|
+
assert.strictEqual(
|
|
1393
|
+
latestNotification(
|
|
1394
|
+
app,
|
|
1395
|
+
"notifications.navigation.deadReckoning.divergenceAdvisory",
|
|
1396
|
+
).state,
|
|
1397
|
+
"alert",
|
|
1398
|
+
);
|
|
1399
|
+
|
|
1400
|
+
// Phase 2 — tie up: STW reads 0 (a moored paddlewheel's honest value),
|
|
1401
|
+
// wind blows 12 kn on the mast, GPS wanders a few metres. The old
|
|
1402
|
+
// behaviour: phantom "paddlewheel fouled" alert + stuck advisory.
|
|
1403
|
+
app.emitDelta({
|
|
1404
|
+
context: "vessels.self",
|
|
1405
|
+
updates: [
|
|
1406
|
+
{
|
|
1407
|
+
values: [
|
|
1408
|
+
{ path: "navigation.state", value: "moored" },
|
|
1409
|
+
{ path: "navigation.speedThroughWater", value: 0 },
|
|
1410
|
+
{ path: "environment.wind.speedApparent", value: 12 },
|
|
1411
|
+
{
|
|
1412
|
+
path: "navigation.position",
|
|
1413
|
+
value: { latitude: 60.0005, longitude: 24.0005 },
|
|
1414
|
+
},
|
|
1415
|
+
],
|
|
1416
|
+
},
|
|
1417
|
+
],
|
|
1418
|
+
});
|
|
1419
|
+
await new Promise((r) => setTimeout(r, 900));
|
|
1420
|
+
|
|
1421
|
+
// The advisory raised just before mooring is withdrawn, not stuck.
|
|
1422
|
+
const notif = latestNotification(
|
|
1423
|
+
app,
|
|
1424
|
+
"notifications.navigation.deadReckoning.divergenceAdvisory",
|
|
1425
|
+
);
|
|
1426
|
+
assert.strictEqual(notif.state, "normal");
|
|
1427
|
+
assert.ok(/suspended while moored/.test(notif.message));
|
|
1428
|
+
|
|
1429
|
+
// Divergence readout is an explicit null — no stale underway figure.
|
|
1430
|
+
assert.strictEqual(latestDivergence(app), null);
|
|
1431
|
+
|
|
1432
|
+
// No phantom fouled paddlewheel: wind on a tied-up boat is not making way.
|
|
1433
|
+
const health = latestNotification(
|
|
1434
|
+
app,
|
|
1435
|
+
"notifications.navigation.deadReckoning.status",
|
|
1436
|
+
);
|
|
1437
|
+
assert.ok(health?.state !== "alert", `got ${health?.message}`);
|
|
1438
|
+
plugin.stop();
|
|
1439
|
+
await rm(dir, { recursive: true, force: true });
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
test("moored from the start: DR state clean (no fouled, no transient), fouled detector idle", async () => {
|
|
1443
|
+
const dir = await mkdtemp(join(tmpdir(), "dr-moored-clean-"));
|
|
1444
|
+
const app = new FakeSignalKApp();
|
|
1445
|
+
app.dataPath = dir;
|
|
1446
|
+
const plugin = makePlugin(app);
|
|
1447
|
+
plugin.start({ tickIntervalMs: 100 });
|
|
1448
|
+
app.emitDelta({
|
|
1449
|
+
context: "vessels.self",
|
|
1450
|
+
updates: [
|
|
1451
|
+
{
|
|
1452
|
+
values: [
|
|
1453
|
+
{ path: "navigation.state", value: "moored" },
|
|
1454
|
+
{
|
|
1455
|
+
path: "navigation.position",
|
|
1456
|
+
value: { latitude: 60, longitude: 24 },
|
|
1457
|
+
},
|
|
1458
|
+
{ path: "navigation.speedThroughWater", value: 0 },
|
|
1459
|
+
{ path: "navigation.headingTrue", value: 10 },
|
|
1460
|
+
{ path: "environment.wind.speedApparent", value: 15 },
|
|
1461
|
+
{ path: "environment.wind.angleApparent", value: 1.2 },
|
|
1462
|
+
],
|
|
1463
|
+
},
|
|
1464
|
+
],
|
|
1465
|
+
});
|
|
1466
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1467
|
+
const stateDeltas = app.handledMessages
|
|
1468
|
+
.flatMap((m) => m.message?.updates ?? [])
|
|
1469
|
+
.flatMap((u) => u.values ?? [])
|
|
1470
|
+
.filter((v) => v.path === "navigation.deadReckoning.state");
|
|
1471
|
+
assert.ok(stateDeltas.length > 0, "no DR state delta published");
|
|
1472
|
+
const last = stateDeltas[stateDeltas.length - 1].value;
|
|
1473
|
+
assert.strictEqual(last.fouled, false);
|
|
1474
|
+
assert.strictEqual(last.transient, false);
|
|
1475
|
+
const health = app.handledMessages
|
|
1476
|
+
.flatMap((m) => m.message?.updates ?? [])
|
|
1477
|
+
.flatMap((u) => u.values ?? [])
|
|
1478
|
+
.find(
|
|
1479
|
+
(v) =>
|
|
1480
|
+
v.path === "notifications.navigation.deadReckoning.status" &&
|
|
1481
|
+
v.value.state === "alert",
|
|
1482
|
+
);
|
|
1483
|
+
assert.ok(!health, "no sensor-health alert while moored");
|
|
1484
|
+
plugin.stop();
|
|
1485
|
+
await rm(dir, { recursive: true, force: true });
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
test("fouling alert is debounced: threshold-hovering STW doesn't flap, sustained fouling raises", async () => {
|
|
1489
|
+
const dir = await mkdtemp(join(tmpdir(), "dr-fouled-debounce-"));
|
|
1490
|
+
const app = new FakeSignalKApp();
|
|
1491
|
+
app.dataPath = dir;
|
|
1492
|
+
const plugin = makePlugin(app);
|
|
1493
|
+
plugin.start({
|
|
1494
|
+
tickIntervalMs: 100,
|
|
1495
|
+
sensorHealth: { sustainS: 1, clearS: 1 },
|
|
1496
|
+
});
|
|
1497
|
+
app.emitDelta({
|
|
1498
|
+
context: "vessels.self",
|
|
1499
|
+
updates: [
|
|
1500
|
+
{
|
|
1501
|
+
values: [
|
|
1502
|
+
{
|
|
1503
|
+
path: "navigation.position",
|
|
1504
|
+
value: { latitude: 60, longitude: 24 },
|
|
1505
|
+
},
|
|
1506
|
+
{ path: "navigation.headingTrue", value: 0 },
|
|
1507
|
+
{ path: "environment.wind.speedApparent", value: 12 },
|
|
1508
|
+
],
|
|
1509
|
+
},
|
|
1510
|
+
],
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
// STW hovers at the detector's stop threshold, strictly alternating
|
|
1514
|
+
// every 37 ms (incommensurate with the 100 ms tick so consecutive
|
|
1515
|
+
// ticks never sample a stable parity — paddlewheel spinner
|
|
1516
|
+
// sticking/slipping): raw verdict flips far faster than the 1 s
|
|
1517
|
+
// sustain window, so the alert must not raise.
|
|
1518
|
+
let flip = 0;
|
|
1519
|
+
const flap = setInterval(() => {
|
|
1520
|
+
app.emitDelta({
|
|
1521
|
+
context: "vessels.self",
|
|
1522
|
+
updates: [
|
|
1523
|
+
{
|
|
1524
|
+
values: [
|
|
1525
|
+
{
|
|
1526
|
+
path: "navigation.speedThroughWater",
|
|
1527
|
+
value: flip++ % 2 === 0 ? 0 : 0.4,
|
|
1528
|
+
},
|
|
1529
|
+
],
|
|
1530
|
+
},
|
|
1531
|
+
],
|
|
1532
|
+
});
|
|
1533
|
+
}, 37);
|
|
1534
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
1535
|
+
clearInterval(flap);
|
|
1536
|
+
const healthAlerts = () =>
|
|
1537
|
+
app.handledMessages
|
|
1538
|
+
.flatMap((m) => m.message?.updates ?? [])
|
|
1539
|
+
.flatMap((u) => u.values ?? [])
|
|
1540
|
+
.filter(
|
|
1541
|
+
(v) =>
|
|
1542
|
+
v.path === "notifications.navigation.deadReckoning.status" &&
|
|
1543
|
+
v.value.state === "alert",
|
|
1544
|
+
);
|
|
1545
|
+
assert.strictEqual(
|
|
1546
|
+
healthAlerts().length,
|
|
1547
|
+
0,
|
|
1548
|
+
"fouling alert flapped during threshold hover",
|
|
1549
|
+
);
|
|
1550
|
+
|
|
1551
|
+
// A real fault — STW pinned at 0 while wind says 12 kn — must still
|
|
1552
|
+
// surface after the sustain window.
|
|
1553
|
+
app.emitDelta({
|
|
1554
|
+
context: "vessels.self",
|
|
1555
|
+
updates: [
|
|
1556
|
+
{
|
|
1557
|
+
values: [{ path: "navigation.speedThroughWater", value: 0 }],
|
|
1558
|
+
},
|
|
1559
|
+
],
|
|
1560
|
+
});
|
|
1561
|
+
await new Promise((r) => setTimeout(r, 1600));
|
|
1562
|
+
const alerts = healthAlerts();
|
|
1563
|
+
assert.strictEqual(alerts.length, 1, "sustained fouling did not raise");
|
|
1564
|
+
assert.ok(/fouled/.test(alerts[0].value.message));
|
|
1565
|
+
plugin.stop();
|
|
1566
|
+
await rm(dir, { recursive: true, force: true });
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
test("water-track log survives restart via dr_log_nm state", async () => {
|
|
1570
|
+
const dir = await mkdtemp(join(tmpdir(), "dr-log-restore-"));
|
|
1571
|
+
// Pre-seed the persisted state a previous run's flush would have left.
|
|
1572
|
+
const db = require("../plugin/db.js").openDatabase(
|
|
1573
|
+
join(dir, "dead-reckoning.sqlite"),
|
|
1574
|
+
);
|
|
1575
|
+
const { setState } = require("../plugin/db.js");
|
|
1576
|
+
setState(db, "dr_log_nm", "12.5");
|
|
1577
|
+
setState(db, "dr_trip_log_nm", "3.25");
|
|
1578
|
+
db.close();
|
|
1579
|
+
|
|
1580
|
+
const app = new FakeSignalKApp();
|
|
1581
|
+
app.dataPath = dir;
|
|
1582
|
+
const plugin = makePlugin(app);
|
|
1583
|
+
plugin.start({});
|
|
1584
|
+
const router = new FakeRouter();
|
|
1585
|
+
plugin.registerWithRouter(router);
|
|
1586
|
+
const { body } = router.invoke("get", "/status");
|
|
1587
|
+
assert.strictEqual(body.logNm, 12.5);
|
|
1588
|
+
assert.strictEqual(body.tripLogNm, 3.25);
|
|
1589
|
+
plugin.stop();
|
|
1590
|
+
await rm(dir, { recursive: true, force: true });
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1316
1593
|
/** Returns the latest published divergence readout, or throws. */
|
|
1317
1594
|
function latestDivergence(app) {
|
|
1318
1595
|
const vals = app.handledMessages
|
|
@@ -2331,13 +2608,31 @@ function lastValues(app, path) {
|
|
|
2331
2608
|
|
|
2332
2609
|
test("idle while making way: uncertainty grows, state carries moving, alert raised", async () => {
|
|
2333
2610
|
const dir = await mkdtemp(join(tmpdir(), "dr-idle-moving-"));
|
|
2611
|
+
// Seed the restored per-excursion distance above the GNSS-noise floor
|
|
2612
|
+
// so idle-run growth is observable above it within the test window.
|
|
2613
|
+
const seedDb = require("../plugin/db.js").openDatabase(
|
|
2614
|
+
join(dir, "dead-reckoning.sqlite"),
|
|
2615
|
+
);
|
|
2616
|
+
const { setState } = require("../plugin/db.js");
|
|
2617
|
+
setState(seedDb, "dr_log_since_origin", "1.5");
|
|
2618
|
+
// Seed the origin so the first GPS delta doesn't reset the excursion.
|
|
2619
|
+
setState(
|
|
2620
|
+
seedDb,
|
|
2621
|
+
"last_known_good_fix",
|
|
2622
|
+
JSON.stringify({ latitude: 60, longitude: 24 }),
|
|
2623
|
+
);
|
|
2624
|
+
seedDb.close();
|
|
2334
2625
|
const app = new FakeSignalKApp();
|
|
2335
2626
|
app.dataPath = dir;
|
|
2336
2627
|
const plugin = makePlugin(app);
|
|
2337
|
-
plugin.start({
|
|
2628
|
+
plugin.start({
|
|
2629
|
+
tickIntervalMs: 20,
|
|
2630
|
+
saveIntervalMs: 60000,
|
|
2631
|
+
sensorHealth: { sustainS: 0.1, clearS: 0.1 },
|
|
2632
|
+
});
|
|
2338
2633
|
try {
|
|
2339
|
-
// No STW/heading (idle), but GPS shows ~
|
|
2340
|
-
await emitMovingGps(app,
|
|
2634
|
+
// No STW/heading (idle), but GPS shows ~60 kn over ground.
|
|
2635
|
+
await emitMovingGps(app, 60, 20);
|
|
2341
2636
|
|
|
2342
2637
|
const states = lastValues(app, "navigation.deadReckoning.state");
|
|
2343
2638
|
assert.ok(states.length > 0, "no state deltas");
|
|
@@ -2414,7 +2709,11 @@ test("fouled paddlewheel: STW 0 while making way raises fouling alert + state fl
|
|
|
2414
2709
|
const app = new FakeSignalKApp();
|
|
2415
2710
|
app.dataPath = dir;
|
|
2416
2711
|
const plugin = makePlugin(app);
|
|
2417
|
-
plugin.start({
|
|
2712
|
+
plugin.start({
|
|
2713
|
+
tickIntervalMs: 20,
|
|
2714
|
+
saveIntervalMs: 60000,
|
|
2715
|
+
sensorHealth: { sustainS: 0.1, clearS: 0.1 },
|
|
2716
|
+
});
|
|
2418
2717
|
try {
|
|
2419
2718
|
// STW reads 0, heading present → underway branch; GPS moving ~6 kn.
|
|
2420
2719
|
app.emitDelta({
|
|
@@ -130,14 +130,37 @@ test("computeRadius: radius scales with elapsed distance, not time", () => {
|
|
|
130
130
|
assert.ok(Math.abs(b.radius_nm - 2 * a.radius_nm) < 1e-9);
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
-
test("computeRadius: radius
|
|
133
|
+
test("computeRadius: radius floors at GNSS noise at excursion start (no distance run)", () => {
|
|
134
|
+
// Zero distance run must not report a point-exact DR position: the
|
|
135
|
+
// radius can never honestly be smaller than the GPS noise the origin
|
|
136
|
+
// was seeded from, or any dockside GPS wander "exceeds expected
|
|
137
|
+
// uncertainty" and trips the divergence advisory on a moored boat.
|
|
134
138
|
const out = u.computeRadius({
|
|
135
139
|
elapsedDistanceNm: 0,
|
|
136
140
|
effectiveHitCount: 100,
|
|
137
141
|
deviationRows: [{ deviation_nm: 1, dr_elapsed_seconds: 1000 }],
|
|
138
142
|
stwKn: 5,
|
|
139
143
|
});
|
|
140
|
-
assert.strictEqual(out.radius_nm,
|
|
144
|
+
assert.strictEqual(out.radius_nm, u.MIN_RADIUS_NM);
|
|
145
|
+
assert.ok(u.MIN_RADIUS_NM >= 0.02, "floor below GNSS noise scale");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("computeRadius: floor only applies below it — growth is unchanged above", () => {
|
|
149
|
+
const far = u.computeRadius({
|
|
150
|
+
elapsedDistanceNm: 10,
|
|
151
|
+
effectiveHitCount: 0,
|
|
152
|
+
deviationRows: [],
|
|
153
|
+
stwKn: 5,
|
|
154
|
+
});
|
|
155
|
+
assert.ok(Math.abs(far.radius_nm - u.fallbackRateNmPerNm() * 10) < 1e-9);
|
|
156
|
+
const near = u.computeRadius({
|
|
157
|
+
elapsedDistanceNm: 0.01,
|
|
158
|
+
effectiveHitCount: 0,
|
|
159
|
+
deviationRows: [],
|
|
160
|
+
stwKn: 5,
|
|
161
|
+
});
|
|
162
|
+
// 0.01 nm run × 0.01745 ≈ 0.00017 nm — below floor → floored.
|
|
163
|
+
assert.strictEqual(near.radius_nm, u.MIN_RADIUS_NM);
|
|
141
164
|
});
|
|
142
165
|
|
|
143
166
|
test("computeRadius: blend regime between fallback and empirical", () => {
|