speedandcurrent 2.1.2 → 2.3.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/README.md CHANGED
@@ -6,6 +6,8 @@ Most paddle wheel logs carry a systematic error that varies with boat speed and
6
6
 
7
7
  This plugin builds and maintains a **2-dimensional correction table** — indexed by speed and heel — that is populated automatically from real sailing observations. No calibration runs, no spreadsheets, no manual entry. Once the table has enough coverage the plugin applies per-point corrections to `navigation.speedThroughWater`, derives leeway from the lateral component of the correction, and estimates water current by subtracting the corrected boat speed vector from the GPS ground speed vector.
8
8
 
9
+ In the current implementation the plugin is also explicit about runtime state: if an input goes idle or stale, the webapp shows a warning and the handler resubscribes; when estimation is disabled or the plugin stops, the active output paths are cleared with `null` so downstream consumers do not keep stale values.
10
+
9
11
  In broad terms the plugin:
10
12
 
11
13
  - corrects paddle wheel speed using a learned, heel- and speed-aware correction table
@@ -64,7 +66,7 @@ Once configured, all consumers on the Signal K bus — KIP, OpenCPN, Instrument
64
66
  | `environment.current.drift` | m/s | Estimated current speed. |
65
67
  | `environment.current.setTrue` | rad | Estimated current direction (the direction the water moves *toward*). |
66
68
 
67
- 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.
68
70
 
69
71
  ---
70
72
 
@@ -130,7 +132,7 @@ The panel is organised into three groups:
130
132
  | Setting | Default | Description |
131
133
  |---------|---------|-------------|
132
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. |
133
- | **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. |
134
136
 
135
137
  ---
136
138
 
@@ -146,11 +148,14 @@ When **Assume Current** is enabled, the smoothed current estimate is also shown
146
148
 
147
149
  Warnings appear here if any smoothed input is unavailable.
148
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
+
149
153
  ### Settings
150
154
 
151
155
  | Setting | Default | Description |
152
156
  |---------|---------|-------------|
153
- | **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. |
154
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. |
155
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. |
156
161
  | **Show Statistics (σ)** | Off | Display standard deviation alongside each smoothed value. Useful for spotting noisy sensors. |
@@ -206,7 +211,7 @@ Multiple tables can coexist on disk; only the active one is used. This makes it
206
211
 
207
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.
208
213
 
209
- **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.
210
215
 
211
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.
212
217
 
package/index.js CHANGED
@@ -19,27 +19,141 @@ 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',
38
153
  smootherTau: 3,
39
154
  smootherTimeSpan: 5,
40
155
  smootherSteadyState: 0.2,
41
- showStatistics: false,
42
- stalenessDetection: true
156
+ showStatistics: false
43
157
  };
44
158
 
45
159
 
@@ -68,7 +182,7 @@ module.exports = function (app) {
68
182
  * writes it back if anything changed. Called once on every start().
69
183
  */
70
184
  function migrateConfig() {
71
- const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication'];
185
+ const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication', 'minSogForLearning'];
72
186
  const hadObsolete = obsoleteKeys.some(k => k in options);
73
187
  for (const k of obsoleteKeys) delete options[k];
74
188
  if (hadObsolete || (options.configVersion || 0) < 2) {
@@ -110,7 +224,7 @@ module.exports = function (app) {
110
224
 
111
225
  function swapTable(newTable) {
112
226
  table = newTable;
113
- minSpeed = table.step[0] / 2;
227
+ minSpeed = table.step[0];
114
228
  if (reportFull) reportFull.setTables([table]);
115
229
  lastSave = Date.now(); // explicit table operations already save; defer next periodic save
116
230
  }
@@ -137,9 +251,114 @@ module.exports = function (app) {
137
251
  let noCurrent = null;
138
252
  let rawBoatSpeed = null;
139
253
  let rawGroundSpeed = null;
140
- let started = null;
141
254
  let minSpeed = 0;
142
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;
262
+ let lifecycleWarningMap = new Map();
263
+ let lifecycleWarnings = [];
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
+
315
+ function setLifecycleWarning(id, status, path) {
316
+ const safePath = path || 'unknown path';
317
+ const message = status === 'idle'
318
+ ? `Input ${id} is idle on ${safePath}; resubscribing`
319
+ : `Input ${id} is stale on ${safePath}`;
320
+ lifecycleWarningMap.set(id, {
321
+ id,
322
+ status,
323
+ path: safePath,
324
+ message,
325
+ updatedAt: Date.now()
326
+ });
327
+ lifecycleWarnings = Array.from(lifecycleWarningMap.values())
328
+ .sort((a, b) => b.updatedAt - a.updatedAt);
329
+ }
330
+
331
+ function clearLifecycleWarning(id) {
332
+ if (!lifecycleWarningMap.has(id)) return;
333
+ lifecycleWarningMap.delete(id);
334
+ lifecycleWarnings = Array.from(lifecycleWarningMap.values())
335
+ .sort((a, b) => b.updatedAt - a.updatedAt);
336
+ }
337
+
338
+ function buildLifecycleCallbacks(id, getPath, resubscribe) {
339
+ return {
340
+ onDelta: () => {
341
+ clearLifecycleWarning(id);
342
+ },
343
+ onStale: () => {
344
+ if (!isRunning) return;
345
+ const path = getPath();
346
+ app.debug(`[${plugin.id}] stale input ${id} on ${path}`);
347
+ setLifecycleWarning(id, 'stale', path);
348
+ },
349
+ onIdle: () => {
350
+ if (!isRunning) return;
351
+ const path = getPath();
352
+ app.debug(`[${plugin.id}] idle input ${id} on ${path}; resubscribing`);
353
+ setLifecycleWarning(id, 'idle', path);
354
+ try {
355
+ resubscribe();
356
+ } catch (e) {
357
+ app.debug(`[${plugin.id}] resubscribe failed for ${id}: ${e.message}`);
358
+ }
359
+ }
360
+ };
361
+ }
143
362
 
144
363
  const plugin = {};
145
364
  plugin.id = "SpeedAndCurrent";
@@ -161,7 +380,10 @@ module.exports = function (app) {
161
380
  if (!isRunning) {
162
381
  res.status(503).json({ error: "Plugin is not running" });
163
382
  } else {
164
- res.json(reportFull.report());
383
+ const payload = reportFull.report();
384
+ payload.lifecycleWarnings = lifecycleWarnings;
385
+ payload.learningState = getLearningStatePayload();
386
+ res.json(payload);
165
387
  }
166
388
  });
167
389
 
@@ -176,7 +398,7 @@ module.exports = function (app) {
176
398
 
177
399
 
178
400
  router.get('/api/status', (req, res) => {
179
- res.json({ status: pluginStatus, isRunning });
401
+ res.json({ status: pluginStatus, isRunning, lifecycleWarnings, learningState: getLearningStatePayload() });
180
402
  });
181
403
 
182
404
  // --- Settings API ---
@@ -197,7 +419,6 @@ module.exports = function (app) {
197
419
  }
198
420
  }
199
421
  changedOptions = { ...changedOptions, ...body };
200
- hasPendingChanges = true;
201
422
  res.json({ ...options, ...changedOptions });
202
423
  });
203
424
 
@@ -305,12 +526,14 @@ module.exports = function (app) {
305
526
  plugin.start = (settings) => {
306
527
  setStatus('Starting');
307
528
  app.debug("Starting");
529
+ lifecycleWarningMap = new Map();
530
+ lifecycleWarnings = [];
308
531
  readOptions(); // pick up any saves since registerWithRouter ran
309
532
  migrateConfig(); // strip obsolete fields from persisted config
310
533
  const tableName = options.tableName || 'correctionTable';
311
534
  const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
312
535
  table = loadTable(options, tableFilePath);
313
- minSpeed = table.step[0] / 2;
536
+ minSpeed = table.step[0];
314
537
 
315
538
  //#region Handler and Polar Initialization
316
539
  const { SmootherClass, smootherOptions } = resolveSmootherConfig();
@@ -320,25 +543,38 @@ module.exports = function (app) {
320
543
  angleRange: '0to2pi',
321
544
  meta: { displayName: 'Heading', plane: 'Ground' },
322
545
  SmootherClass,
323
- smootherOptions
546
+ smootherOptions,
547
+ ...buildLifecycleCallbacks(
548
+ 'heading.angle',
549
+ () => smoothedHeading?.handler?.path || 'navigation.headingTrue',
550
+ () => { smoothedHeading?.unsubscribe(); smoothedHeading?.subscribe(false, true); }
551
+ )
324
552
  });
325
- // The magnitude handler has a fixed value of 1 and is never subscribed, so its timestamp
326
- // stays null forever. Staleness detection on it would always force _stale=true, blocking
327
- // processChanges() from setting polar._ready=true on every heading delta.
328
- smoothedHeading.polar.magnitudeHandler.stalenessDetection = false;
329
553
  rawHeading = smoothedHeading.handler;
330
554
 
331
- //attitude
555
+ // attitude
332
556
  smoothedAttitude = createSmoothedHandler({
333
557
  app, pluginId: plugin.id,
334
558
  id: 'attitude',
335
559
  path: 'navigation.attitude',
336
560
  subscribe: true,
337
561
  SmootherClass,
338
- smootherOptions
562
+ smootherOptions,
563
+ ...buildLifecycleCallbacks(
564
+ 'attitude.smoothed',
565
+ () => smoothedAttitude?.handler?.path || 'navigation.attitude',
566
+ () => { smoothedAttitude?.unsubscribe(); smoothedAttitude?.subscribe(); }
567
+ )
339
568
  });
340
569
  rawAttitude = smoothedAttitude.handler;
341
570
 
571
+ navigationStateHandler = new MessageHandler(app, plugin.id, 'navigationState');
572
+ navigationStateHandler.configure('navigation.state');
573
+ navigationStateHandler.onDelta = () => {
574
+ handleNavigationStateDelta();
575
+ };
576
+ navigationStateHandler.subscribe();
577
+
342
578
 
343
579
  // current
344
580
  // send metadata for current
@@ -370,7 +606,6 @@ module.exports = function (app) {
370
606
  });
371
607
  noCurrent.xSmoother.reset(0,0);
372
608
  noCurrent.ySmoother.reset(0,0);
373
- noCurrent.stalenessDetection = false; // noCurrent is a fixed zero-vector, never receives live data
374
609
  PolarSmoother.send(app, plugin.id, [noCurrent]);
375
610
 
376
611
  MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
@@ -388,7 +623,49 @@ module.exports = function (app) {
388
623
  path: 'navigation.speedThroughWater',
389
624
  subscribe: true,
390
625
  SmootherClass,
391
- smootherOptions
626
+ smootherOptions,
627
+ onDelta: () => {
628
+ clearLifecycleWarning('boatSpeed.smoothed');
629
+ // Drain any pending option changes before calculating
630
+ if (Object.keys(changedOptions).length) applyOptionChanges();
631
+
632
+ const learningMode = evaluateLearningMode({
633
+ options,
634
+ navigationState: buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState),
635
+ stabilizingUntil: learningStabilizingUntil,
636
+ stabilizingReason: learningStabilizingReason,
637
+ now: Date.now()
638
+ });
639
+
640
+ const wellUnderway = learningMode.state !== 'stabilizing';
641
+ setStatus(learningMode.state === 'stabilizing' ? 'Stabilizing' : 'Running');
642
+ if (options.estimateBoatSpeed) correct(wellUnderway);
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'));
649
+ }
650
+ lastSave = now;
651
+ }
652
+ }
653
+ }
654
+ ,
655
+ onIdle: () => {
656
+ if (!isRunning) return;
657
+ const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
658
+ app.debug(`[${plugin.id}] idle input boatSpeed.smoothed on ${path}; resubscribing`);
659
+ setLifecycleWarning('boatSpeed.smoothed', 'idle', path);
660
+ smoothedBoatSpeed?.unsubscribe();
661
+ smoothedBoatSpeed?.subscribe();
662
+ },
663
+ onStale: () => {
664
+ if (!isRunning) return;
665
+ const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
666
+ app.debug(`[${plugin.id}] stale input boatSpeed.smoothed on ${path}`);
667
+ setLifecycleWarning('boatSpeed.smoothed', 'stale', path);
668
+ }
392
669
  });
393
670
  rawBoatSpeed = smoothedBoatSpeed.handler;
394
671
 
@@ -419,7 +696,12 @@ module.exports = function (app) {
419
696
  angleRange: '0to2pi',
420
697
  meta: { displayName: 'Groundspeed', plane: 'Ground' },
421
698
  SmootherClass,
422
- smootherOptions
699
+ smootherOptions,
700
+ ...buildLifecycleCallbacks(
701
+ 'groundSpeed.smoothed',
702
+ () => `${smoothedGroundSpeed?.polar?.pathMagnitude || 'navigation.speedOverGround'}, ${smoothedGroundSpeed?.polar?.pathAngle || 'navigation.courseOverGroundTrue'}`,
703
+ () => { smoothedGroundSpeed?.unsubscribe(); smoothedGroundSpeed?.subscribe(true, true); }
704
+ )
423
705
  });
424
706
  rawGroundSpeed = smoothedGroundSpeed.polar;
425
707
 
@@ -466,50 +748,15 @@ module.exports = function (app) {
466
748
 
467
749
  //#endregion
468
750
 
469
- // Apply staleness detection to all subscribed smoother instances.
470
- // noCurrent is intentionally excluded — it is a fixed zero-vector and never receives live data.
471
- // Only apply when sd = false: all smoothers already default to stalenessDetection = true.
472
- // Explicitly calling the setter with true at startup forces _stale = true on handlers that have
473
- // never received data, causing the first sample to be dropped (library side-effect).
474
- const sd = options.stalenessDetection ?? true;
475
- if (!sd) {
476
- for (const s of [smoothedHeading, smoothedAttitude, smoothedBoatSpeed, smoothedGroundSpeed, smoothedCurrent, smoothedResidual]) {
477
- if (s) s.stalenessDetection = false;
478
- }
479
- }
480
-
481
751
  isRunning = true;
482
- started = Date.now();
483
752
  lastSave = 0;
753
+ lastObservationState = null;
754
+ lastObservationReason = null;
755
+ lastNavigationStateValue = normalizeNavigationState(navigationStateHandler?.value);
756
+ resetLearningStabilization('startup', LONG_STABILIZING_MS);
484
757
  setStatus('Running');
485
758
  app.debug("Running");
486
759
 
487
- smoothedBoatSpeed.onChange = () => {
488
- // Drain any pending option changes before calculating
489
- if (Object.keys(changedOptions).length) applyOptionChanges();
490
-
491
- const wellUnderway = started < new Date() - 60 * 1000;
492
-
493
- setStatus(wellUnderway ? 'Running' : 'Stabilizing');
494
- if (options.estimateBoatSpeed) correct(wellUnderway);
495
- if (options.updateCorrectionTable) {
496
- if (wellUnderway) {
497
- updateTable();
498
- // Save correction table periodically (skip if table is empty to avoid overwriting good data on disk)
499
- const now = Date.now();
500
- if (now - lastSave > 60_000) {
501
- if (!isTableEmpty(table)) {
502
- saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
503
- }
504
- lastSave = now;
505
- }
506
- } else {
507
- table.lastUpdateResult = 'stabilizing';
508
- }
509
- } else {
510
- table.lastUpdateResult = null;
511
- }
512
- };
513
760
  }
514
761
 
515
762
  plugin.stop = () => {
@@ -525,6 +772,7 @@ module.exports = function (app) {
525
772
  smoothedAttitude = smoothedAttitude?.terminate();
526
773
  rawCurrent = rawCurrent?.terminate();
527
774
  smoothedCurrent = smoothedCurrent?.terminate?.();
775
+ navigationStateHandler = navigationStateHandler?.terminate();
528
776
  smoothedBoatSpeed = smoothedBoatSpeed?.terminate();
529
777
  correctedBoatSpeed = correctedBoatSpeed?.terminate();
530
778
  lrnBoatSpeed = lrnBoatSpeed?.terminate();
@@ -540,12 +788,18 @@ module.exports = function (app) {
540
788
  noCurrent = null;
541
789
  rawBoatSpeed = null;
542
790
  rawGroundSpeed = null;
543
- started = null;
791
+ lastNavigationStateValue = null;
792
+ learningStabilizingUntil = 0;
793
+ learningStabilizingReason = 'startup';
794
+ lastObservationState = null;
795
+ lastObservationReason = null;
544
796
  app.setPluginStatus("Stopped");
545
797
  app.debug("Stopped");
546
798
 
547
799
  pluginStatus = 'Stopped';
548
800
  isRunning = false;
801
+ lifecycleWarningMap = new Map();
802
+ lifecycleWarnings = [];
549
803
  resolve();
550
804
  } catch (error) {
551
805
  reject(error);
@@ -573,7 +827,7 @@ module.exports = function (app) {
573
827
 
574
828
  // Priority 1: SOG fallback when STW is stuck at zero
575
829
  // (onChange only fires when STW is ready, so a missing STW cannot reach here)
576
- 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) {
577
831
  correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
578
832
  }
579
833
  else if (rawAttitude.ready) {
@@ -584,11 +838,7 @@ module.exports = function (app) {
584
838
  }
585
839
  // Current estimation and residual also require heading (to rotate into ground frame).
586
840
  // Also handle near-zero SOG where COG is unavailable: treat groundspeed as zero vector.
587
- const sogHandler = rawGroundSpeed.magnitudeHandler;
588
- const nearZeroGroundSpeed = !rawGroundSpeed.ready &&
589
- sogHandler.ready &&
590
- Number.isFinite(sogHandler.value) &&
591
- sogHandler.value < 0.3;
841
+ const nearZeroGroundSpeed = isCogOverrideActive(rawGroundSpeed, minSpeed);
592
842
  if (rawHeading.ready && (rawGroundSpeed.ready || nearZeroGroundSpeed)) {
593
843
  boatSpeedRefGround.copyFrom(correctedBoatSpeed);
594
844
  boatSpeedRefGround.rotate(rawHeading.value);
@@ -626,18 +876,43 @@ module.exports = function (app) {
626
876
  */
627
877
  function updateTable() {
628
878
  lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
629
- if (!smoothedAttitude.ready || !smoothedBoatSpeed.ready || !smoothedHeading.ready || !smoothedGroundSpeed.ready || (options.assumeCurrent && !smoothedCurrent.ready)) {
630
- table.lastUpdateResult = 'invalid';
631
- 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';
632
889
  }
633
-
634
- // update correction table
635
- if (smoothedBoatSpeed.value > minSpeed) {
636
- table.update(smoothedBoatSpeed.value, smoothedAttitude.value?.roll, smoothedGroundSpeed, options.assumeCurrent ? smoothedCurrent : noCurrent, lrnBoatSpeed, smoothedHeading.value);
637
- } else {
638
- 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;
639
907
  }
640
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
+ }
641
916
  }
642
917
 
643
918
  /**
@@ -723,13 +998,7 @@ module.exports = function (app) {
723
998
  const value = changedOptions[key];
724
999
  options[key] = value;
725
1000
 
726
- if (key === 'stalenessDetection') {
727
- const sdVal = Boolean(value);
728
- for (const s of [smoothedHeading, smoothedAttitude, smoothedBoatSpeed, smoothedGroundSpeed, smoothedCurrent, smoothedResidual]) {
729
- if (s) s.stalenessDetection = sdVal;
730
- }
731
- // noCurrent is always excluded from staleness detection
732
- } else if (key === 'updateCorrectionTable') {
1001
+ if (key === 'updateCorrectionTable') {
733
1002
  if (!value && table && !isTableEmpty(table)) {
734
1003
  // Learning switched off — persist current state before periodic saves stop
735
1004
  saveTableSync(table, path.join(app.getDataDirPath(), table.id + '.json'));
@@ -749,7 +1018,6 @@ module.exports = function (app) {
749
1018
  }
750
1019
  }
751
1020
  }
752
-
753
1021
  // All other keys (sogFallback, estimateBoatSpeed, assumeCurrent,
754
1022
  // stability, smootherClass, etc.) are read
755
1023
  // directly from options.* so no extra action needed.
@@ -771,10 +1039,21 @@ module.exports = function (app) {
771
1039
  }
772
1040
  if (smoothedAttitude) { smoothedAttitude.setSmootherClass(SC); smoothedAttitude.setSmootherOptions(so); }
773
1041
  }
774
-
775
- hasPendingChanges = false;
776
1042
  saveOptions();
777
1043
  }
778
1044
 
779
1045
  return plugin;
780
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.1.2",
3
+ "version": "2.3.0",
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": {
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "kalman-filter": "^2.3.0",
41
- "signalkutilities": "^3.0.1"
41
+ "signalkutilities": "^3.1.0"
42
42
  },
43
43
  "files": [
44
44
  "icon.png",
package/public/app.js CHANGED
@@ -97,6 +97,7 @@ function normaliseState(data) {
97
97
 
98
98
  // ─── Static meta (fetched once at startup from /api/meta) ─────────────────────
99
99
  let metaById = {}; // keyed by item id
100
+ let lifecycleWarnings = [];
100
101
 
101
102
  async function loadMeta() {
102
103
  const data = await apiGet('/api/meta');
@@ -182,6 +183,29 @@ function renderWarnings(elId, ids) {
182
183
  el.appendChild(ul);
183
184
  }
184
185
 
186
+ function renderInputWarnings(elId, ids) {
187
+ const el = document.getElementById(elId);
188
+ if (!el) return;
189
+ el.innerHTML = '';
190
+ const lines = [];
191
+ lifecycleWarnings.forEach(w => {
192
+ if (w && typeof w.message === 'string') lines.push(w.message);
193
+ });
194
+ if (lines.length === 0) return;
195
+ const h = document.createElement('h6');
196
+ h.className = 'text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small';
197
+ h.textContent = 'Warnings';
198
+ el.appendChild(h);
199
+ const ul = document.createElement('ul');
200
+ ul.className = 'list-unstyled text-danger small ps-3';
201
+ lines.forEach(line => {
202
+ const li = document.createElement('li');
203
+ li.textContent = line;
204
+ ul.appendChild(li);
205
+ });
206
+ el.appendChild(ul);
207
+ }
208
+
185
209
  // ─── Config (live settings mirror) ───────────────────────────────────────────
186
210
  let config = null;
187
211
 
@@ -251,11 +275,8 @@ function _refreshMessage() {
251
275
  else el.textContent = _explicitMsg;
252
276
  return;
253
277
  }
254
- const isRunningOk = (_pluginStatus === 'Running' || _pluginStatus === '');
278
+ const isRunningOk = (_pluginStatus === 'Running' || _pluginStatus === 'Stabilizing' || _pluginStatus === '');
255
279
  if (!isRunningOk) {
256
- el.style.color =
257
- _pluginStatus === 'Stabilizing' ? '#084298' :
258
- _pluginStatus === 'Falling back to Speed Over Ground' ? '#664d03' : '#842029';
259
280
  el.textContent = _pluginStatus;
260
281
  } else {
261
282
  el.style.color = '';
@@ -268,6 +289,7 @@ function _refreshMessage() {
268
289
  const paramMeta = {
269
290
  estimateBoatSpeed: { label: 'Estimate boat speed', type: 'boolean' },
270
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.' },
271
293
  assumeCurrent: { label: 'Assume current during update', type: 'boolean', description: 'Experimental, works best when currents are relatively stable.' },
272
294
  sogFallback: { label: 'Groundspeed fallback', type: 'boolean', description: 'Output Groundspeed as Boatspeed when the paddlewheel sensor is malfunctioning or stalled.' },
273
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.' },
@@ -296,17 +318,17 @@ const paramMeta = {
296
318
  min: 0.01, max: 0.99, step: 0.01, default: 0.2,
297
319
  description: 'Steady-state Kalman gain (0 ≈ slow/smooth, 1 ≈ fast/raw). Clamped to 0.01–0.99.'
298
320
  },
299
- stalenessDetection: {
300
- label: 'Staleness detection',
301
- type: 'boolean',
302
- description: 'Mark inputs as unavailable when no data has been received for a period. '
303
- },
304
321
  };
305
322
 
306
323
  // Settings groups for each UI section
307
- const INPUTS_SETTING_KEYS = ['stalenessDetection'];
324
+ const INPUTS_SETTING_KEYS = [
325
+ 'smootherClass',
326
+ { key: 'smootherTau', showIf: cfg => cfg.smootherClass === 'ExponentialSmoother' },
327
+ { key: 'smootherTimeSpan', showIf: cfg => (cfg.smootherClass || 'MovingAverageSmoother') === 'MovingAverageSmoother' },
328
+ { key: 'smootherSteadyState', showIf: cfg => cfg.smootherClass === 'KalmanSmoother' },
329
+ ];
308
330
  const ESTIMATION_SETTING_KEYS = ['sogFallback'];
309
- const LEARNING_SETTING_KEYS = ['stability','assumeCurrent','showStatistics'];
331
+ const LEARNING_SETTING_KEYS = ['stability', 'suspendLearningOnNavigationState', 'assumeCurrent', 'showStatistics'];
310
332
  const SMOOTHER_SETTING_KEYS = [
311
333
  'smootherClass',
312
334
  { key: 'smootherTau', showIf: cfg => cfg.smootherClass === 'ExponentialSmoother' },
@@ -511,12 +533,23 @@ function renderGroupInto(elId, polars, deltas, attitudes) {
511
533
  }
512
534
 
513
535
  function renderLiveSections() {
536
+ const learningState = state.learningState || null;
537
+ const inputPolars = filterById(state.polarsAll, ['groundSpeed']);
538
+ const inputDeltas = filterById(state.deltasAll, ['heading.angle', 'boatSpeed']);
539
+ const inputAttitudes = filterById(state.attitudesAll, ['attitude']);
540
+ const fallbackInputPolars = inputPolars.length ? inputPolars : filterById(state.polarsAll, ['groundSpeed.smoothed']);
541
+ const fallbackInputDeltas = inputDeltas.length ? inputDeltas : filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']);
542
+ const fallbackInputAttitudes = inputAttitudes.length ? inputAttitudes : filterById(state.attitudesAll, ['attitude.smoothed']);
543
+
514
544
  // Inputs section — raw sensor readings only (smoothing is internal to the plugin)
515
545
  renderGroupInto('inputs-values',
516
- filterById(state.polarsAll, ['groundSpeed']),
517
- filterById(state.deltasAll, ['heading.angle', 'boatSpeed']),
518
- filterById(state.attitudesAll, ['attitude'])
546
+ fallbackInputPolars,
547
+ fallbackInputDeltas,
548
+ fallbackInputAttitudes
519
549
  );
550
+ const inputWarningIds = ['boatSpeed', 'attitude', 'heading.angle', 'groundSpeed', 'boatSpeed.smoothed', 'groundSpeed.smoothed', 'heading.smoothed', 'attitude.smoothed'];
551
+ if (config && config.assumeCurrent) inputWarningIds.push('current.smoothed');
552
+ renderInputWarnings('inputs-warnings', inputWarningIds);
520
553
 
521
554
  // Estimation — inputs (raw sensor data used for boat speed estimation)
522
555
  renderGroupInto('estimation-inputs',
@@ -535,9 +568,8 @@ function renderLiveSections() {
535
568
  [], []
536
569
  );
537
570
  // Estimation — warnings
538
- renderWarnings('estimation-warnings',
539
- ['boatSpeed', 'attitude', 'heading.angle', 'groundSpeed']
540
- );
571
+ const estimationWarnings = document.getElementById('estimation-warnings');
572
+ if (estimationWarnings) estimationWarnings.innerHTML = '';
541
573
 
542
574
  // Learning — inputs: smoothed sensors + current if assumeCurrent
543
575
  const learningCurrentPolars = (config && config.assumeCurrent)
@@ -549,33 +581,61 @@ function renderLiveSections() {
549
581
  filterById(state.attitudesAll, ['attitude.smoothed'])
550
582
  );
551
583
  // Learning — warnings
552
- const learningWarningIds = ['boatSpeed.smoothed', 'groundSpeed.smoothed', 'heading.smoothed', 'attitude.smoothed'];
553
- if (config && config.assumeCurrent) learningWarningIds.push('current.smoothed');
554
- renderWarnings('learning-warnings', learningWarningIds);
584
+ const learningWarnings = document.getElementById('learning-warnings');
585
+ if (learningWarnings) {
586
+ learningWarnings.innerHTML = '';
587
+ const navState = learningState?.navigationState;
588
+ if (navState?.enabled && navState.pathKnown === false) {
589
+ const ul = document.createElement('ul');
590
+ ul.className = 'list-unstyled small ps-3';
591
+ const li = document.createElement('li');
592
+ li.textContent = 'navigation.state is not available; navigation-state learning gate is inactive.';
593
+ ul.appendChild(li);
594
+ learningWarnings.appendChild(ul);
595
+ }
596
+ }
555
597
  // Learning status section
556
598
  const statusTbody = document.querySelector('#learning-status-table tbody');
557
599
  if (statusTbody) {
558
- const tableData = Object.values(state.tablesById)[0];
559
- const result = tableData?.lastUpdateResult ?? null;
560
- const learningText = result === null ? 'Off'
561
- : result === 'stabilizing' ? 'Stabilising'
562
- : 'Active';
563
- const learningClass = result === null ? 'text-muted'
564
- : result === 'stabilizing' ? 'text-warning'
565
- : 'text-success';
566
- const obsText = result === 'accepted' ? 'Accepted'
567
- : result === 'rejected' ? 'Rejected'
568
- : result === 'invalid' ? 'Invalid'
569
- : result === 'waiting' ? 'Below threshold'
570
- : '\u2014';
571
- const obsClass = result === 'accepted' ? 'text-success'
572
- : result === 'rejected' ? 'text-danger'
573
- : result === 'invalid' ? 'text-warning'
574
- : result === 'waiting' ? 'text-muted'
575
- : 'text-muted';
600
+ const learningTextMap = {
601
+ off: 'Off',
602
+ stabilizing: 'Stabilising',
603
+ active: 'Active',
604
+ suspended: 'Suspended',
605
+ };
606
+ const observationTextMap = {
607
+ accepted: 'Accepted',
608
+ rejected: 'Rejected',
609
+ invalid: 'Invalid',
610
+ skipped: 'Skipped',
611
+ };
612
+ const reasonTextMap = {
613
+ manual: 'Manual toggle off',
614
+ startup: 'Startup stabilising window',
615
+ observation_reset: 'Recent rejected or invalid observation',
616
+ nav_state_change: 'navigation.state changed',
617
+ nav_state: 'Blocked by navigation.state',
618
+ cog_override: 'Blocked by COG override',
619
+ accepted: 'Observation recorded',
620
+ estimator_outlier: 'Estimator rejected observation',
621
+ missing_input: 'Required learning input unavailable',
622
+ missing_current_when_required: 'Current estimate unavailable',
623
+ learning_off: 'Learning is off',
624
+ stabilizing: 'Stabilising window active',
625
+ nav_state_blocked: 'Learning blocked by navigation.state',
626
+ cog_override_active: 'Learning blocked by COG override',
627
+ stw_below_threshold: 'Below minimum STW for learning',
628
+ sog_below_threshold: 'Below minimum SOG for learning',
629
+ };
630
+ const learningText = learningTextMap[learningState?.state] || '\u2014';
631
+ const obsText = observationTextMap[learningState?.observationState] || '\u2014';
632
+ const reason = reasonTextMap[learningState?.observationReason]
633
+ || reasonTextMap[learningState?.reason]
634
+ || '\u2014';
576
635
  statusTbody.innerHTML =
577
- `<tr><td class="text-muted small">Learning</td><td class="small fw-bold ${learningClass}">${learningText}</td></tr>` +
578
- `<tr><td class="text-muted small">Observation</td><td class="small fw-bold ${obsClass}">${obsText}</td></tr>`;
636
+ `<tr><td class="text-muted small">Learning</td><td class="small">${learningText}</td></tr>` +
637
+ `<tr><td class="text-muted small">Observation</td><td class="small">${obsText}</td></tr>` +
638
+ `<tr><td class="text-muted small">Reason</td><td class="small">${reason}</td></tr>`;
579
639
  }
580
640
  // Correction table
581
641
  const tableEl = document.getElementById('table-container');
@@ -610,9 +670,11 @@ async function tick() {
610
670
  }
611
671
  lastTickOk = true;
612
672
  normaliseState(data);
673
+ state.learningState = data.learningState || null;
613
674
  renderLiveSections();
614
675
  } else {
615
676
  lastTickOk = false;
677
+ state.learningState = null;
616
678
  }
617
679
  // Always poll status separately so the message reflects plugin state
618
680
  // even when /api/report fails (plugin stopped/restarting).
@@ -620,6 +682,7 @@ async function tick() {
620
682
  .then(r => r.ok ? r.json() : null)
621
683
  .catch(() => null);
622
684
  _pluginStatus = statusData?.status ?? '';
685
+ lifecycleWarnings = Array.isArray(statusData?.lifecycleWarnings) ? statusData.lifecycleWarnings : [];
623
686
  _refreshMessage();
624
687
  }
625
688
 
package/public/index.html CHANGED
@@ -97,6 +97,7 @@
97
97
  </table>
98
98
  <h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">Live Values</h6>
99
99
  <div id="inputs-values"></div>
100
+ <div id="inputs-warnings"></div>
100
101
  </div>
101
102
  </div>
102
103
  </div>