speedandcurrent 2.2.1 → 2.3.1

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/README.md CHANGED
@@ -66,7 +66,7 @@ Once configured, all consumers on the Signal K bus — KIP, OpenCPN, Instrument
66
66
  | `environment.current.drift` | m/s | Estimated current speed. |
67
67
  | `environment.current.setTrue` | rad | Estimated current direction (the direction the water moves *toward*). |
68
68
 
69
- A 60-second stabilisation period applies after startup. No output is published during this window; the status bar shows **Stabilizing**.
69
+ A 60-second stabilisation period applies after startup. No output is published during this window.
70
70
 
71
71
  ---
72
72
 
@@ -132,7 +132,7 @@ The panel is organised into three groups:
132
132
  | Setting | Default | Description |
133
133
  |---------|---------|-------------|
134
134
  | **Estimate Boat Speed** | Off | Master toggle. Apply the correction table and publish corrected STW, leeway, and current. Turn on once the table has reasonable coverage. |
135
- | **Groundspeed Fallback** | Off | When the paddle wheel reads zero and SOG is above a minimum threshold, publish SOG as boatspeed. Primarily intended for clogged or stuck paddle wheels where the instrument is physically present but not turning — giving the rest of the instrument system a usable boat speed until the wheel is cleared. |
135
+ | **Groundspeed Fallback** | Off | When the paddle wheel reads zero and SOG is above the current table speed-step threshold, publish SOG as boatspeed. Primarily intended for clogged or stuck paddle wheels where the instrument is physically present but not turning — giving the rest of the instrument system a usable boat speed until the wheel is cleared. |
136
136
 
137
137
  ---
138
138
 
@@ -148,11 +148,14 @@ When **Assume Current** is enabled, the smoothed current estimate is also shown
148
148
 
149
149
  Warnings appear here if any smoothed input is unavailable.
150
150
 
151
+ The panel also shows whether learning is currently active, suspended, or skipped for the latest observation. Learning is automatically suspended when `navigation.state` is `anchored` or `moored` when that path is available. It can also be suspended for `motoring` if enabled in the settings. Observations below the current table speed-step threshold are skipped.
152
+
151
153
  ### Settings
152
154
 
153
155
  | Setting | Default | Description |
154
156
  |---------|---------|-------------|
155
- | **Update Correction Table** | On | Master toggle. Allow the table to update from current observations. Disable temporarily in conditions you do not want recorded — motoring, surf sailing, very confused seas. |
157
+ | **Update Correction Table** | On | Master toggle. Allow the table to update from current observations. |
158
+ | **Suspend on navigation.state = motoring** | Off | Suspend learning when `navigation.state` reports `motoring`. `anchored` and `moored` always suspend learning when that path is available. |
156
159
  | **Stability (1–20)** | 7 | How quickly the correction table adapts to new observations. Higher = slower, more conservative. Lower = faster but noisier. See the technical section for detail. |
157
160
  | **Assume Current (experimental)** | Off | Include the running current estimate in the table update calculation. Only enable once the current estimate has had time to stabilise and tidal conditions are relatively steady. |
158
161
  | **Show Statistics (σ)** | Off | Display standard deviation alongside each smoothed value. Useful for spotting noisy sensors. |
@@ -208,7 +211,7 @@ Multiple tables can coexist on disk; only the active one is used. This makes it
208
211
 
209
212
  **Be patient with the correction table.** A fresh table has no data and produces no corrections. Cover a range of speeds and heel angles over a few sails and the table fills in progressively. Upwind sailing covers the heel bins well; downwind and reaching fill the low-heel, varying-speed bins.
210
213
 
211
- **The table learns while sailing normally.** No dedicated calibration runs are needed. Just sail with **Update Correction Table** on.
214
+ **The table learns while sailing normally.** No dedicated calibration runs are needed. Just sail with **Update Correction Table** on. Learning pauses automatically during startup stabilisation, when `navigation.state` indicates `anchored` or `moored`, optionally when it indicates `motoring`, and when SOG or STW are below the current table speed-step threshold.
212
215
 
213
216
  **Port and starboard are tracked independently.** Heel is signed: starboard positive, port negative. An asymmetric paddle wheel installation will show different corrections on each tack, and the table captures this naturally.
214
217
 
package/index.js CHANGED
@@ -19,19 +19,134 @@ const {
19
19
 
20
20
  const { CorrectionTable } = require('./correctionTable.js');
21
21
 
22
+ const LONG_STABILIZING_MS = 60 * 1000;
23
+ const ALWAYS_BLOCKING_NAVIGATION_STATES = new Set(['anchored', 'moored']);
24
+ const OPTIONAL_BLOCKING_NAVIGATION_STATES = new Set(['motoring']);
25
+
26
+ function normalizeNavigationState(value) {
27
+ return typeof value === 'string' ? value.trim().toLowerCase() : null;
28
+ }
29
+
30
+ function getShortStabilizingMs(options = {}) {
31
+ return Math.max(0, Number(options.smootherTimeSpan) || 5) * 1000;
32
+ }
33
+
34
+ function isCogOverrideActive(groundSpeedPolar, speedThreshold) {
35
+ const sogHandler = groundSpeedPolar?.magnitudeHandler;
36
+ return !groundSpeedPolar?.ready
37
+ && sogHandler?.ready
38
+ && Number.isFinite(sogHandler.value)
39
+ && sogHandler.value < speedThreshold;
40
+ }
41
+
42
+ function evaluateLearningMode({ options = {}, navigationState, stabilizingUntil = 0, stabilizingReason = null, now = Date.now() }) {
43
+ const normalizedNavigationState = normalizeNavigationState(navigationState?.value);
44
+ const navigationGateReady = navigationState?.ready === true;
45
+ const navigationGateBlocking = navigationGateReady && (
46
+ ALWAYS_BLOCKING_NAVIGATION_STATES.has(normalizedNavigationState)
47
+ || (!!options.suspendLearningOnNavigationState && OPTIONAL_BLOCKING_NAVIGATION_STATES.has(normalizedNavigationState))
48
+ );
49
+
50
+ if (!options.updateCorrectionTable) {
51
+ return { state: 'off', reason: 'manual', navigationGateBlocking, normalizedNavigationState };
52
+ }
53
+
54
+ if (navigationGateBlocking) {
55
+ return { state: 'suspended', reason: 'nav_state', navigationGateBlocking, normalizedNavigationState };
56
+ }
57
+
58
+ if (now < stabilizingUntil) {
59
+ return { state: 'stabilizing', reason: stabilizingReason || 'startup', navigationGateBlocking, normalizedNavigationState };
60
+ }
61
+
62
+ return { state: 'active', reason: null, navigationGateBlocking, normalizedNavigationState };
63
+ }
64
+
65
+ function evaluateObservationGate({
66
+ learningMode,
67
+ inputsReady,
68
+ assumeCurrent,
69
+ currentReady,
70
+ stw,
71
+ sog,
72
+ speedThreshold,
73
+ }) {
74
+ if (learningMode.state === 'off') {
75
+ return { state: 'skipped', reason: 'learning_off' };
76
+ }
77
+ if (learningMode.state === 'suspended') {
78
+ return { state: 'skipped', reason: learningMode.reason === 'cog_override' ? 'cog_override_active' : 'nav_state_blocked' };
79
+ }
80
+ if (learningMode.state === 'stabilizing') {
81
+ return { state: 'skipped', reason: 'stabilizing' };
82
+ }
83
+ if (!inputsReady) {
84
+ return { state: 'invalid', reason: assumeCurrent && !currentReady ? 'missing_current_when_required' : 'missing_input' };
85
+ }
86
+ if (!(stw > speedThreshold)) {
87
+ return { state: 'skipped', reason: 'stw_below_threshold' };
88
+ }
89
+ if (!(sog >= speedThreshold)) {
90
+ return { state: 'skipped', reason: 'sog_below_threshold' };
91
+ }
92
+ return { state: 'pending', reason: null };
93
+ }
94
+
95
+ function buildNavigationStateStatus(handler, gateEnabled) {
96
+ if (!handler) {
97
+ return {
98
+ enabled: !!gateEnabled,
99
+ path: 'navigation.state',
100
+ pathKnown: false,
101
+ ready: false,
102
+ value: null,
103
+ blocking: false
104
+ };
105
+ }
106
+
107
+ const state = handler.state;
108
+ const value = handler.value ?? null;
109
+ const normalized = normalizeNavigationState(value);
110
+ return {
111
+ enabled: !!gateEnabled,
112
+ path: handler.path,
113
+ pathKnown: state.pathKnown,
114
+ ready: state.ready,
115
+ isStale: state.isStale,
116
+ value,
117
+ blocking: state.ready && (
118
+ ALWAYS_BLOCKING_NAVIGATION_STATES.has(normalized)
119
+ || (!!gateEnabled && OPTIONAL_BLOCKING_NAVIGATION_STATES.has(normalized))
120
+ )
121
+ };
122
+ }
123
+
124
+ function getDerivedObservationStatus(learningMode, lastState, lastReason) {
125
+ if (learningMode.state === 'off') {
126
+ return { state: 'skipped', reason: 'learning_off' };
127
+ }
128
+ if (learningMode.state === 'stabilizing') {
129
+ return { state: 'skipped', reason: 'stabilizing' };
130
+ }
131
+ if (learningMode.state === 'suspended') {
132
+ return { state: 'skipped', reason: learningMode.reason === 'cog_override' ? 'cog_override_active' : 'nav_state_blocked' };
133
+ }
134
+ return { state: lastState, reason: lastReason };
135
+ }
136
+
22
137
  module.exports = function (app) {
23
138
 
24
139
  const DEFAULT_DIMS = { maxSpeed: 9, speedStep: 1, maxHeel: 32, heelStep: 8 };
25
140
 
26
141
  let options = {};
27
142
  let changedOptions = {};
28
- let hasPendingChanges = false;
29
143
  const defaultOptions = {
30
144
  sogFallback: true,
31
145
  estimateBoatSpeed: false,
32
146
  updateCorrectionTable: true,
33
147
  stability: 7,
34
148
  assumeCurrent: false,
149
+ suspendLearningOnNavigationState: false,
35
150
  tableName: 'correctionTable',
36
151
  configVersion: 2,
37
152
  smootherClass: 'MovingAverageSmoother',
@@ -67,7 +182,7 @@ module.exports = function (app) {
67
182
  * writes it back if anything changed. Called once on every start().
68
183
  */
69
184
  function migrateConfig() {
70
- const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication'];
185
+ const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication', 'minSogForLearning'];
71
186
  const hadObsolete = obsoleteKeys.some(k => k in options);
72
187
  for (const k of obsoleteKeys) delete options[k];
73
188
  if (hadObsolete || (options.configVersion || 0) < 2) {
@@ -109,7 +224,7 @@ module.exports = function (app) {
109
224
 
110
225
  function swapTable(newTable) {
111
226
  table = newTable;
112
- minSpeed = table.step[0] / 2;
227
+ minSpeed = table.step[0];
113
228
  if (reportFull) reportFull.setTables([table]);
114
229
  lastSave = Date.now(); // explicit table operations already save; defer next periodic save
115
230
  }
@@ -136,12 +251,67 @@ module.exports = function (app) {
136
251
  let noCurrent = null;
137
252
  let rawBoatSpeed = null;
138
253
  let rawGroundSpeed = null;
139
- let started = null;
140
254
  let minSpeed = 0;
141
255
  let lastSave = 0;
256
+ let navigationStateHandler = null;
257
+ let lastNavigationStateValue = null;
258
+ let learningStabilizingUntil = 0;
259
+ let learningStabilizingReason = 'startup';
260
+ let lastObservationState = null;
261
+ let lastObservationReason = null;
142
262
  let lifecycleWarningMap = new Map();
143
263
  let lifecycleWarnings = [];
144
264
 
265
+ function setObservationStatus(state, reason = null) {
266
+ lastObservationState = state;
267
+ lastObservationReason = reason;
268
+ if (table) {
269
+ table.lastUpdateResult = state;
270
+ table.lastUpdateReason = reason;
271
+ }
272
+ }
273
+
274
+ function resetLearningStabilization(reason, durationMs) {
275
+ const delay = Math.max(0, Number(durationMs) || 0);
276
+ learningStabilizingUntil = Date.now() + delay;
277
+ learningStabilizingReason = reason;
278
+ }
279
+
280
+ function handleNavigationStateDelta() {
281
+ const normalized = normalizeNavigationState(navigationStateHandler?.value);
282
+ if (normalized === lastNavigationStateValue) return;
283
+ if (lastNavigationStateValue !== null) {
284
+ resetLearningStabilization('nav_state_change', LONG_STABILIZING_MS);
285
+ }
286
+ lastNavigationStateValue = normalized;
287
+ }
288
+
289
+ function getLearningStatePayload() {
290
+ const navigationState = buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState);
291
+ const learningMode = evaluateLearningMode({
292
+ options,
293
+ navigationState,
294
+ stabilizingUntil: learningStabilizingUntil,
295
+ stabilizingReason: learningStabilizingReason,
296
+ now: Date.now()
297
+ });
298
+ if (learningMode.state === 'active' && isCogOverrideActive(rawGroundSpeed, minSpeed)) {
299
+ learningMode.state = 'suspended';
300
+ learningMode.reason = 'cog_override';
301
+ }
302
+ const observation = getDerivedObservationStatus(learningMode, lastObservationState, lastObservationReason);
303
+
304
+ return {
305
+ state: learningMode.state,
306
+ reason: learningMode.reason,
307
+ observationState: observation.state,
308
+ observationReason: observation.reason,
309
+ minStwForLearning: minSpeed,
310
+ minSogForLearning: minSpeed,
311
+ navigationState
312
+ };
313
+ }
314
+
145
315
  function setLifecycleWarning(id, status, path) {
146
316
  const safePath = path || 'unknown path';
147
317
  const message = status === 'idle'
@@ -212,6 +382,7 @@ module.exports = function (app) {
212
382
  } else {
213
383
  const payload = reportFull.report();
214
384
  payload.lifecycleWarnings = lifecycleWarnings;
385
+ payload.learningState = getLearningStatePayload();
215
386
  res.json(payload);
216
387
  }
217
388
  });
@@ -227,7 +398,7 @@ module.exports = function (app) {
227
398
 
228
399
 
229
400
  router.get('/api/status', (req, res) => {
230
- res.json({ status: pluginStatus, isRunning, lifecycleWarnings });
401
+ res.json({ status: pluginStatus, isRunning, lifecycleWarnings, learningState: getLearningStatePayload() });
231
402
  });
232
403
 
233
404
  // --- Settings API ---
@@ -248,7 +419,6 @@ module.exports = function (app) {
248
419
  }
249
420
  }
250
421
  changedOptions = { ...changedOptions, ...body };
251
- hasPendingChanges = true;
252
422
  res.json({ ...options, ...changedOptions });
253
423
  });
254
424
 
@@ -363,7 +533,7 @@ module.exports = function (app) {
363
533
  const tableName = options.tableName || 'correctionTable';
364
534
  const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
365
535
  table = loadTable(options, tableFilePath);
366
- minSpeed = table.step[0] / 2;
536
+ minSpeed = table.step[0];
367
537
 
368
538
  //#region Handler and Polar Initialization
369
539
  const { SmootherClass, smootherOptions } = resolveSmootherConfig();
@@ -382,7 +552,7 @@ module.exports = function (app) {
382
552
  });
383
553
  rawHeading = smoothedHeading.handler;
384
554
 
385
- //attitude
555
+ // attitude
386
556
  smoothedAttitude = createSmoothedHandler({
387
557
  app, pluginId: plugin.id,
388
558
  id: 'attitude',
@@ -398,6 +568,13 @@ module.exports = function (app) {
398
568
  });
399
569
  rawAttitude = smoothedAttitude.handler;
400
570
 
571
+ navigationStateHandler = new MessageHandler(app, plugin.id, 'navigationState');
572
+ navigationStateHandler.configure('navigation.state');
573
+ navigationStateHandler.onDelta = () => {
574
+ handleNavigationStateDelta();
575
+ };
576
+ navigationStateHandler.subscribe();
577
+
401
578
 
402
579
  // current
403
580
  // send metadata for current
@@ -452,26 +629,26 @@ module.exports = function (app) {
452
629
  // Drain any pending option changes before calculating
453
630
  if (Object.keys(changedOptions).length) applyOptionChanges();
454
631
 
455
- const wellUnderway = started < new Date() - 60 * 1000;
632
+ const learningMode = evaluateLearningMode({
633
+ options,
634
+ navigationState: buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState),
635
+ stabilizingUntil: learningStabilizingUntil,
636
+ stabilizingReason: learningStabilizingReason,
637
+ now: Date.now()
638
+ });
456
639
 
457
- setStatus(wellUnderway ? 'Running' : 'Stabilizing');
640
+ const wellUnderway = learningMode.state !== 'stabilizing';
641
+ setStatus(learningMode.state === 'stabilizing' ? 'Stabilizing' : 'Running');
458
642
  if (options.estimateBoatSpeed) correct(wellUnderway);
459
- if (options.updateCorrectionTable) {
460
- if (wellUnderway) {
461
- updateTable();
462
- // Save correction table periodically (skip if table is empty to avoid overwriting good data on disk)
463
- const now = Date.now();
464
- if (now - lastSave > 60_000) {
465
- if (!isTableEmpty(table)) {
466
- saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
467
- }
468
- lastSave = now;
643
+ updateTable();
644
+ if (lastObservationState === 'accepted' && options.updateCorrectionTable) {
645
+ const now = Date.now();
646
+ if (now - lastSave > 60_000) {
647
+ if (!isTableEmpty(table)) {
648
+ saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
469
649
  }
470
- } else {
471
- table.lastUpdateResult = 'stabilizing';
650
+ lastSave = now;
472
651
  }
473
- } else {
474
- table.lastUpdateResult = null;
475
652
  }
476
653
  }
477
654
  ,
@@ -572,8 +749,11 @@ module.exports = function (app) {
572
749
  //#endregion
573
750
 
574
751
  isRunning = true;
575
- started = Date.now();
576
752
  lastSave = 0;
753
+ lastObservationState = null;
754
+ lastObservationReason = null;
755
+ lastNavigationStateValue = normalizeNavigationState(navigationStateHandler?.value);
756
+ resetLearningStabilization('startup', LONG_STABILIZING_MS);
577
757
  setStatus('Running');
578
758
  app.debug("Running");
579
759
 
@@ -592,6 +772,7 @@ module.exports = function (app) {
592
772
  smoothedAttitude = smoothedAttitude?.terminate();
593
773
  rawCurrent = rawCurrent?.terminate();
594
774
  smoothedCurrent = smoothedCurrent?.terminate?.();
775
+ navigationStateHandler = navigationStateHandler?.terminate();
595
776
  smoothedBoatSpeed = smoothedBoatSpeed?.terminate();
596
777
  correctedBoatSpeed = correctedBoatSpeed?.terminate();
597
778
  lrnBoatSpeed = lrnBoatSpeed?.terminate();
@@ -607,7 +788,11 @@ module.exports = function (app) {
607
788
  noCurrent = null;
608
789
  rawBoatSpeed = null;
609
790
  rawGroundSpeed = null;
610
- started = null;
791
+ lastNavigationStateValue = null;
792
+ learningStabilizingUntil = 0;
793
+ learningStabilizingReason = 'startup';
794
+ lastObservationState = null;
795
+ lastObservationReason = null;
611
796
  app.setPluginStatus("Stopped");
612
797
  app.debug("Stopped");
613
798
 
@@ -642,7 +827,7 @@ module.exports = function (app) {
642
827
 
643
828
  // Priority 1: SOG fallback when STW is stuck at zero
644
829
  // (onChange only fires when STW is ready, so a missing STW cannot reach here)
645
- if (options.sogFallback && rawGroundSpeed.ready && correctedBoatSpeed.magnitude === 0 && rawGroundSpeed.magnitude >= minSpeed) {
830
+ if (options.sogFallback && rawGroundSpeed.ready && rawBoatSpeed.ready && rawBoatSpeed.value === 0 && rawGroundSpeed.magnitude >= minSpeed) {
646
831
  correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
647
832
  }
648
833
  else if (rawAttitude.ready) {
@@ -653,11 +838,7 @@ module.exports = function (app) {
653
838
  }
654
839
  // Current estimation and residual also require heading (to rotate into ground frame).
655
840
  // Also handle near-zero SOG where COG is unavailable: treat groundspeed as zero vector.
656
- const sogHandler = rawGroundSpeed.magnitudeHandler;
657
- const nearZeroGroundSpeed = !rawGroundSpeed.ready &&
658
- sogHandler.ready &&
659
- Number.isFinite(sogHandler.value) &&
660
- sogHandler.value < 0.3;
841
+ const nearZeroGroundSpeed = isCogOverrideActive(rawGroundSpeed, minSpeed);
661
842
  if (rawHeading.ready && (rawGroundSpeed.ready || nearZeroGroundSpeed)) {
662
843
  boatSpeedRefGround.copyFrom(correctedBoatSpeed);
663
844
  boatSpeedRefGround.rotate(rawHeading.value);
@@ -695,18 +876,43 @@ module.exports = function (app) {
695
876
  */
696
877
  function updateTable() {
697
878
  lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
698
- if (!smoothedAttitude.ready || !smoothedBoatSpeed.ready || !smoothedHeading.ready || !smoothedGroundSpeed.ready || (options.assumeCurrent && !smoothedCurrent.ready)) {
699
- table.lastUpdateResult = 'invalid';
700
- return;
879
+ const learningMode = evaluateLearningMode({
880
+ options,
881
+ navigationState: buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState),
882
+ stabilizingUntil: learningStabilizingUntil,
883
+ stabilizingReason: learningStabilizingReason,
884
+ now: Date.now()
885
+ });
886
+ if (learningMode.state === 'active' && isCogOverrideActive(rawGroundSpeed, minSpeed)) {
887
+ learningMode.state = 'suspended';
888
+ learningMode.reason = 'cog_override';
701
889
  }
702
-
703
- // update correction table
704
- if (smoothedBoatSpeed.value > minSpeed) {
705
- table.update(smoothedBoatSpeed.value, smoothedAttitude.value?.roll, smoothedGroundSpeed, options.assumeCurrent ? smoothedCurrent : noCurrent, lrnBoatSpeed, smoothedHeading.value);
706
- } else {
707
- table.lastUpdateResult = 'waiting';
890
+ const inputsReady = smoothedAttitude.ready && smoothedBoatSpeed.ready && smoothedHeading.ready && smoothedGroundSpeed.ready;
891
+ const currentReady = !options.assumeCurrent || smoothedCurrent.ready;
892
+ const observationGate = evaluateObservationGate({
893
+ learningMode,
894
+ inputsReady,
895
+ assumeCurrent: options.assumeCurrent,
896
+ currentReady,
897
+ stw: smoothedBoatSpeed.value,
898
+ sog: smoothedGroundSpeed.magnitude,
899
+ speedThreshold: minSpeed
900
+ });
901
+
902
+ if (observationGate.state !== 'pending') {
903
+ if (observationGate.state === 'invalid') {
904
+ resetLearningStabilization('observation_reset', getShortStabilizingMs(options));
905
+ }
906
+ return;
708
907
  }
709
908
 
909
+ table.update(smoothedBoatSpeed.value, smoothedAttitude.value?.roll, smoothedGroundSpeed, options.assumeCurrent ? smoothedCurrent : noCurrent, lrnBoatSpeed, smoothedHeading.value);
910
+ if (table.lastUpdateResult === 'accepted') {
911
+ setObservationStatus('accepted', 'accepted');
912
+ } else if (table.lastUpdateResult === 'rejected') {
913
+ setObservationStatus('rejected', 'estimator_outlier');
914
+ resetLearningStabilization('observation_reset', getShortStabilizingMs(options));
915
+ }
710
916
  }
711
917
 
712
918
  /**
@@ -812,7 +1018,6 @@ module.exports = function (app) {
812
1018
  }
813
1019
  }
814
1020
  }
815
-
816
1021
  // All other keys (sogFallback, estimateBoatSpeed, assumeCurrent,
817
1022
  // stability, smootherClass, etc.) are read
818
1023
  // directly from options.* so no extra action needed.
@@ -834,10 +1039,21 @@ module.exports = function (app) {
834
1039
  }
835
1040
  if (smoothedAttitude) { smoothedAttitude.setSmootherClass(SC); smoothedAttitude.setSmootherOptions(so); }
836
1041
  }
837
-
838
- hasPendingChanges = false;
839
1042
  saveOptions();
840
1043
  }
841
1044
 
842
1045
  return plugin;
843
1046
  };
1047
+
1048
+ module.exports._test = {
1049
+ normalizeNavigationState,
1050
+ getShortStabilizingMs,
1051
+ evaluateLearningMode,
1052
+ isCogOverrideActive,
1053
+ evaluateObservationGate,
1054
+ getDerivedObservationStatus,
1055
+ buildNavigationStateStatus,
1056
+ ALWAYS_BLOCKING_NAVIGATION_STATES,
1057
+ OPTIONAL_BLOCKING_NAVIGATION_STATES,
1058
+ LONG_STABILIZING_MS,
1059
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speedandcurrent",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "A plugin that automatically corrects boat speed and calculates current and leeway using sensor fusion.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -34,6 +34,9 @@
34
34
  "./docs/correctionTable.png",
35
35
  "./docs/STW_estimation.png",
36
36
  "./docs/learning.png"
37
+ ],
38
+ "recommends": [
39
+ "@meri-imperiumi/signalk-autostate"
37
40
  ]
38
41
  },
39
42
  "dependencies": {
package/public/app.js CHANGED
@@ -275,11 +275,8 @@ function _refreshMessage() {
275
275
  else el.textContent = _explicitMsg;
276
276
  return;
277
277
  }
278
- const isRunningOk = (_pluginStatus === 'Running' || _pluginStatus === '');
278
+ const isRunningOk = (_pluginStatus === 'Running' || _pluginStatus === 'Stabilizing' || _pluginStatus === '');
279
279
  if (!isRunningOk) {
280
- el.style.color =
281
- _pluginStatus === 'Stabilizing' ? '#084298' :
282
- _pluginStatus === 'Falling back to Speed Over Ground' ? '#664d03' : '#842029';
283
280
  el.textContent = _pluginStatus;
284
281
  } else {
285
282
  el.style.color = '';
@@ -292,6 +289,7 @@ function _refreshMessage() {
292
289
  const paramMeta = {
293
290
  estimateBoatSpeed: { label: 'Estimate boat speed', type: 'boolean' },
294
291
  updateCorrectionTable: { label: 'Update correction table', type: 'boolean' },
292
+ suspendLearningOnNavigationState: { label: 'Suspend on navigation.state = motoring', type: 'boolean', description: 'Suspend learning when navigation.state is motoring. Anchored and moored always suspend learning when the path is available.' },
295
293
  assumeCurrent: { label: 'Assume current during update', type: 'boolean', description: 'Experimental, works best when currents are relatively stable.' },
296
294
  sogFallback: { label: 'Groundspeed fallback', type: 'boolean', description: 'Output Groundspeed as Boatspeed when the paddlewheel sensor is malfunctioning or stalled.' },
297
295
  stability: { label: 'Stability (1–20)', type: 'number', min: 1, max: 20, step: 1, default: 7, description: 'How quickly the correction table adapts to new observations. Higher values mean slower, more stable changes.' },
@@ -323,14 +321,8 @@ const paramMeta = {
323
321
  };
324
322
 
325
323
  // Settings groups for each UI section
326
- const INPUTS_SETTING_KEYS = [
327
- 'smootherClass',
328
- { key: 'smootherTau', showIf: cfg => cfg.smootherClass === 'ExponentialSmoother' },
329
- { key: 'smootherTimeSpan', showIf: cfg => (cfg.smootherClass || 'MovingAverageSmoother') === 'MovingAverageSmoother' },
330
- { key: 'smootherSteadyState', showIf: cfg => cfg.smootherClass === 'KalmanSmoother' },
331
- ];
332
324
  const ESTIMATION_SETTING_KEYS = ['sogFallback'];
333
- const LEARNING_SETTING_KEYS = ['stability','assumeCurrent','showStatistics'];
325
+ const LEARNING_SETTING_KEYS = ['stability', 'suspendLearningOnNavigationState', 'assumeCurrent', 'showStatistics'];
334
326
  const SMOOTHER_SETTING_KEYS = [
335
327
  'smootherClass',
336
328
  { key: 'smootherTau', showIf: cfg => cfg.smootherClass === 'ExponentialSmoother' },
@@ -445,7 +437,6 @@ function renderSectionToggles() {
445
437
 
446
438
  function renderSettingsPanel() {
447
439
  if (!config) return;
448
- renderSettingsRows('inputs-settings-table', INPUTS_SETTING_KEYS);
449
440
  renderSettingsRows('estimation-settings-table', ESTIMATION_SETTING_KEYS);
450
441
  renderSettingsRows('smoother-settings-table', SMOOTHER_SETTING_KEYS);
451
442
  renderSettingsRows('learning-settings-table', LEARNING_SETTING_KEYS);
@@ -535,6 +526,7 @@ function renderGroupInto(elId, polars, deltas, attitudes) {
535
526
  }
536
527
 
537
528
  function renderLiveSections() {
529
+ const learningState = state.learningState || null;
538
530
  const inputPolars = filterById(state.polarsAll, ['groundSpeed']);
539
531
  const inputDeltas = filterById(state.deltasAll, ['heading.angle', 'boatSpeed']);
540
532
  const inputAttitudes = filterById(state.attitudesAll, ['attitude']);
@@ -583,31 +575,60 @@ function renderLiveSections() {
583
575
  );
584
576
  // Learning — warnings
585
577
  const learningWarnings = document.getElementById('learning-warnings');
586
- if (learningWarnings) learningWarnings.innerHTML = '';
578
+ if (learningWarnings) {
579
+ learningWarnings.innerHTML = '';
580
+ const navState = learningState?.navigationState;
581
+ if (navState?.enabled && navState.pathKnown === false) {
582
+ const ul = document.createElement('ul');
583
+ ul.className = 'list-unstyled small ps-3';
584
+ const li = document.createElement('li');
585
+ li.textContent = 'navigation.state is not available; navigation-state learning gate is inactive.';
586
+ ul.appendChild(li);
587
+ learningWarnings.appendChild(ul);
588
+ }
589
+ }
587
590
  // Learning status section
588
591
  const statusTbody = document.querySelector('#learning-status-table tbody');
589
592
  if (statusTbody) {
590
- const tableData = Object.values(state.tablesById)[0];
591
- const result = tableData?.lastUpdateResult ?? null;
592
- const learningText = result === null ? 'Off'
593
- : result === 'stabilizing' ? 'Stabilising'
594
- : 'Active';
595
- const learningClass = result === null ? 'text-muted'
596
- : result === 'stabilizing' ? 'text-warning'
597
- : 'text-success';
598
- const obsText = result === 'accepted' ? 'Accepted'
599
- : result === 'rejected' ? 'Rejected'
600
- : result === 'invalid' ? 'Invalid'
601
- : result === 'waiting' ? 'Below threshold'
602
- : '\u2014';
603
- const obsClass = result === 'accepted' ? 'text-success'
604
- : result === 'rejected' ? 'text-danger'
605
- : result === 'invalid' ? 'text-warning'
606
- : result === 'waiting' ? 'text-muted'
607
- : 'text-muted';
593
+ const learningTextMap = {
594
+ off: 'Off',
595
+ stabilizing: 'Stabilising',
596
+ active: 'Active',
597
+ suspended: 'Suspended',
598
+ };
599
+ const observationTextMap = {
600
+ accepted: 'Accepted',
601
+ rejected: 'Rejected',
602
+ invalid: 'Invalid',
603
+ skipped: 'Skipped',
604
+ };
605
+ const reasonTextMap = {
606
+ manual: 'Manual toggle off',
607
+ startup: 'Startup stabilising window',
608
+ observation_reset: 'Recent rejected or invalid observation',
609
+ nav_state_change: 'navigation.state changed',
610
+ nav_state: 'Blocked by navigation.state',
611
+ cog_override: 'Blocked by COG override',
612
+ accepted: 'Observation recorded',
613
+ estimator_outlier: 'Estimator rejected observation',
614
+ missing_input: 'Required learning input unavailable',
615
+ missing_current_when_required: 'Current estimate unavailable',
616
+ learning_off: 'Learning is off',
617
+ stabilizing: 'Stabilising window active',
618
+ nav_state_blocked: 'Learning blocked by navigation.state',
619
+ cog_override_active: 'Learning blocked by COG override',
620
+ stw_below_threshold: 'Below minimum STW for learning',
621
+ sog_below_threshold: 'Below minimum SOG for learning',
622
+ };
623
+ const learningText = learningTextMap[learningState?.state] || '\u2014';
624
+ const obsText = observationTextMap[learningState?.observationState] || '\u2014';
625
+ const reason = reasonTextMap[learningState?.observationReason]
626
+ || reasonTextMap[learningState?.reason]
627
+ || '\u2014';
608
628
  statusTbody.innerHTML =
609
- `<tr><td class="text-muted small">Learning</td><td class="small fw-bold ${learningClass}">${learningText}</td></tr>` +
610
- `<tr><td class="text-muted small">Observation</td><td class="small fw-bold ${obsClass}">${obsText}</td></tr>`;
629
+ `<tr><td class="text-muted small">Learning</td><td class="small">${learningText}</td></tr>` +
630
+ `<tr><td class="text-muted small">Observation</td><td class="small">${obsText}</td></tr>` +
631
+ `<tr><td class="text-muted small">Reason</td><td class="small">${reason}</td></tr>`;
611
632
  }
612
633
  // Correction table
613
634
  const tableEl = document.getElementById('table-container');
@@ -642,9 +663,11 @@ async function tick() {
642
663
  }
643
664
  lastTickOk = true;
644
665
  normaliseState(data);
666
+ state.learningState = data.learningState || null;
645
667
  renderLiveSections();
646
668
  } else {
647
669
  lastTickOk = false;
670
+ state.learningState = null;
648
671
  }
649
672
  // Always poll status separately so the message reflects plugin state
650
673
  // even when /api/report fails (plugin stopped/restarting).
package/public/index.html CHANGED
@@ -13,22 +13,31 @@
13
13
  el.href = href;
14
14
  document.head.appendChild(el);
15
15
  };
16
+ let manifestFailure = 'manifest contained no CSS assets';
16
17
  try {
17
18
  const res = await fetch('/admin/.vite/manifest.json');
18
19
  if (res.ok) {
19
20
  const manifest = await res.json();
20
- let found = false;
21
+ const stylesheets = new Set();
21
22
  for (const entry of Object.values(manifest)) {
22
- if (!entry.isEntry) continue;
23
- for (const css of entry.css ?? []) {
24
- appendStylesheet('/admin/' + css);
25
- found = true;
23
+ if (entry.file?.endsWith('.css')) {
24
+ stylesheets.add(entry.file);
26
25
  }
26
+ if (entry.isEntry) {
27
+ for (const css of entry.css ?? []) {
28
+ stylesheets.add(css);
29
+ }
30
+ }
31
+ }
32
+ if (stylesheets.size > 0) {
33
+ for (const css of stylesheets) appendStylesheet('/admin/' + css);
34
+ return;
27
35
  }
28
- if (found) return;
36
+ } else {
37
+ manifestFailure = `manifest request returned ${res.status}`;
29
38
  }
30
39
  } catch (e) {
31
- // fall through to legacy scrape
40
+ manifestFailure = `manifest request failed: ${e.message}`;
32
41
  }
33
42
  try {
34
43
  const res = await fetch('/');
@@ -37,9 +46,11 @@
37
46
  const link = doc.querySelector('link[rel="stylesheet"]');
38
47
  if (link) {
39
48
  appendStylesheet(new URL(link.getAttribute('href'), res.url).href);
49
+ return;
40
50
  }
51
+ console.warn(`[webapp] Could not load SignalK admin stylesheet: ${manifestFailure}; legacy admin page contained no stylesheet link.`);
41
52
  } catch (e) {
42
- console.warn('[webapp] Could not load SignalK admin stylesheet:', e);
53
+ console.warn(`[webapp] Could not load SignalK admin stylesheet: ${manifestFailure}; legacy fallback failed: ${e.message}`);
43
54
  }
44
55
  })();
45
56
  </script>
@@ -91,11 +102,7 @@
91
102
  <div class="card">
92
103
  <div class="card-header fw-bold text-uppercase">Inputs</div>
93
104
  <div class="card-body">
94
- <h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mb-1 small">Settings</h6>
95
- <table class="table table-sm table-borderless mb-0" id="inputs-settings-table">
96
- <tbody></tbody>
97
- </table>
98
- <h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">Live Values</h6>
105
+ <h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mb-1 small">Live Values</h6>
99
106
  <div id="inputs-values"></div>
100
107
  <div id="inputs-warnings"></div>
101
108
  </div>