speedandcurrent 2.3.3 → 2.4.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/index.js CHANGED
@@ -17,18 +17,79 @@ const {
17
17
  Table2D
18
18
  } = require('signalkutilities');
19
19
 
20
- const { CorrectionTable } = require('./correctionTable.js');
20
+ const {
21
+ CorrectionTable,
22
+ DEFAULT_CORRECTION_DRIFT_RATE,
23
+ DEFAULT_PROCESS_NOISE_RATE,
24
+ SECONDS_PER_MONTH,
25
+ KNOT_TO_MPS
26
+ } = require('./correctionTable.js');
21
27
 
22
28
  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']);
29
+ const MIN_SMOOTHING_WINDOW_SECONDS = 5;
30
+ const LEARNING_GAP_SECONDS = 1;
31
+ // navigation.state values that indicate the vessel is not moving. Only applied as an
32
+ // override (see isVesselMoving) when options.suspendLearningOnNavigationState is enabled.
33
+ const NOT_MOVING_NAVIGATION_STATES = new Set(['anchored', 'moored', 'motoring']);
34
+ const OBSOLETE_SMOOTHER_KEYS = new Set(['smootherClass', 'smootherTau', 'smootherSteadyState']);
35
+ const MAX_CORRECTION_DRIFT_RATE = 3;
36
+
37
+ function correctionDriftRateToProcessNoiseRate(driftRate) {
38
+ const rate = Number.isFinite(Number(driftRate))
39
+ ? Math.min(MAX_CORRECTION_DRIFT_RATE, Math.max(0, Number(driftRate)))
40
+ : DEFAULT_CORRECTION_DRIFT_RATE;
41
+ return (rate * KNOT_TO_MPS) ** 2 / SECONDS_PER_MONTH;
42
+ }
43
+
44
+ function legacyStabilityToCorrectionDriftRate(stability) {
45
+ if (!Number.isFinite(Number(stability))) return DEFAULT_CORRECTION_DRIFT_RATE;
46
+ return Math.sqrt((10 ** -Number(stability)) * SECONDS_PER_MONTH) / KNOT_TO_MPS;
47
+ }
25
48
 
26
49
  function normalizeNavigationState(value) {
27
50
  return typeof value === 'string' ? value.trim().toLowerCase() : null;
28
51
  }
29
52
 
30
53
  function getShortStabilizingMs(options = {}) {
31
- return Math.max(0, Number(options.smootherTimeSpan) || 5) * 1000;
54
+ return Math.max(MIN_SMOOTHING_WINDOW_SECONDS, Number(options.smootherTimeSpan) || MIN_SMOOTHING_WINDOW_SECONDS) * 1000;
55
+ }
56
+
57
+ function getSmoothingWindowSeconds(options = {}) {
58
+ return Math.max(MIN_SMOOTHING_WINDOW_SECONDS, Number(options.smootherTimeSpan) || MIN_SMOOTHING_WINDOW_SECONDS);
59
+ }
60
+
61
+ function getLearningIntervalMs(options = {}) {
62
+ return (getSmoothingWindowSeconds(options) + LEARNING_GAP_SECONDS) * 1000;
63
+ }
64
+
65
+ function migrateCorrectionTableData(data, { filePath, statSync = fs.statSync, now = Date.now(), debug = () => {} } = {}) {
66
+ if (!data || (data.schemaVersion !== undefined && data.schemaVersion !== 1) || data.schemaVersion === 2) {
67
+ return { data, migrated: false };
68
+ }
69
+
70
+ let lastAcceptedAt;
71
+ try {
72
+ const stat = statSync(filePath);
73
+ lastAcceptedAt = Number.isFinite(stat?.mtimeMs) ? stat.mtimeMs : null;
74
+ } catch (err) {
75
+ lastAcceptedAt = null;
76
+ }
77
+ if (!Number.isFinite(lastAcceptedAt)) {
78
+ lastAcceptedAt = now;
79
+ debug(`Correction table migration used load time for ${filePath}: file modification time unavailable`);
80
+ }
81
+
82
+ return {
83
+ migrated: true,
84
+ data: {
85
+ ...data,
86
+ schemaVersion: 2,
87
+ table: data.table.map(row => row.map(cell => ({
88
+ ...cell,
89
+ lastAcceptedAt: cell?.state ? lastAcceptedAt : null
90
+ })))
91
+ }
92
+ };
32
93
  }
33
94
 
34
95
  function isCogOverrideActive(groundSpeedPolar, speedThreshold) {
@@ -39,45 +100,50 @@ function isCogOverrideActive(groundSpeedPolar, speedThreshold) {
39
100
  && sogHandler.value < speedThreshold;
40
101
  }
41
102
 
103
+ /**
104
+ * The vessel's own "moving" state, derived from SOG. When navigation.state is known
105
+ * (has ever delivered a value) and the suspend-on-navigation-state setting is enabled,
106
+ * it can additionally force "not moving" (anchored/moored/motoring) — but it can never
107
+ * force "moving" when SOG says otherwise, since navigation.state can go stale or wrong
108
+ * without a way to verify it independently.
109
+ */
110
+ function isVesselMoving({ sogMagnitude, speedThreshold, navigationStateHandler, gateEnabled }) {
111
+ const ownMoving = Number.isFinite(sogMagnitude) && sogMagnitude >= speedThreshold;
112
+ if (!ownMoving || !gateEnabled) return ownMoving;
113
+ const state = navigationStateHandler?.state;
114
+ const navigationKnown = state?.pathKnown === true && state?.hasDelta === true;
115
+ if (!navigationKnown) return true;
116
+ return !NOT_MOVING_NAVIGATION_STATES.has(normalizeNavigationState(navigationStateHandler.value));
117
+ }
118
+
42
119
  /**
43
120
  * Leeway is the angle of the corrected boatspeed vector, so a small lateral
44
121
  * correction divided by a near-zero magnitude yields a large, meaningless angle.
45
122
  */
46
- function isLeewayValid(speed, speedThreshold, navigationStateHandler) {
123
+ function isLeewayValid(speed, speedThreshold, vesselMoving) {
47
124
  if (!(speed >= speedThreshold)) return false;
48
- const state = navigationStateHandler?.state;
49
- if (state?.ready !== true) return true;
50
- return !ALWAYS_BLOCKING_NAVIGATION_STATES.has(normalizeNavigationState(navigationStateHandler.value));
125
+ return vesselMoving;
51
126
  }
52
127
 
53
- function evaluateLearningMode({ options = {}, navigationState, stabilizingUntil = 0, stabilizingReason = null, now = Date.now() }) {
54
- const normalizedNavigationState = normalizeNavigationState(navigationState?.value);
55
- const navigationGateReady = navigationState?.ready === true;
56
- const navigationGateBlocking = navigationGateReady && (
57
- ALWAYS_BLOCKING_NAVIGATION_STATES.has(normalizedNavigationState)
58
- || (!!options.suspendLearningOnNavigationState && OPTIONAL_BLOCKING_NAVIGATION_STATES.has(normalizedNavigationState))
59
- );
60
-
128
+ function evaluateLearningMode({ options = {}, vesselMoving = true, stabilizingUntil = 0, stabilizingReason = null, now = Date.now() }) {
61
129
  if (!options.updateCorrectionTable) {
62
- return { state: 'off', reason: 'manual', navigationGateBlocking, normalizedNavigationState };
130
+ return { state: 'off', reason: 'manual' };
63
131
  }
64
132
 
65
- if (navigationGateBlocking) {
66
- return { state: 'suspended', reason: 'nav_state', navigationGateBlocking, normalizedNavigationState };
133
+ if (!vesselMoving) {
134
+ return { state: 'suspended', reason: 'not_moving' };
67
135
  }
68
136
 
69
137
  if (now < stabilizingUntil) {
70
- return { state: 'stabilizing', reason: stabilizingReason || 'startup', navigationGateBlocking, normalizedNavigationState };
138
+ return { state: 'stabilizing', reason: stabilizingReason || 'startup' };
71
139
  }
72
140
 
73
- return { state: 'active', reason: null, navigationGateBlocking, normalizedNavigationState };
141
+ return { state: 'active', reason: null };
74
142
  }
75
143
 
76
144
  function evaluateObservationGate({
77
145
  learningMode,
78
146
  inputsReady,
79
- assumeCurrent,
80
- currentReady,
81
147
  stw,
82
148
  sog,
83
149
  speedThreshold,
@@ -86,13 +152,13 @@ function evaluateObservationGate({
86
152
  return { state: 'skipped', reason: 'learning_off' };
87
153
  }
88
154
  if (learningMode.state === 'suspended') {
89
- return { state: 'skipped', reason: learningMode.reason === 'cog_override' ? 'cog_override_active' : 'nav_state_blocked' };
155
+ return { state: 'skipped', reason: learningMode.reason === 'cog_override' ? 'cog_override_active' : 'not_moving_blocked' };
90
156
  }
91
157
  if (learningMode.state === 'stabilizing') {
92
158
  return { state: 'skipped', reason: 'stabilizing' };
93
159
  }
94
160
  if (!inputsReady) {
95
- return { state: 'invalid', reason: assumeCurrent && !currentReady ? 'missing_current_when_required' : 'missing_input' };
161
+ return { state: 'invalid', reason: 'missing_input' };
96
162
  }
97
163
  if (!(stw > speedThreshold)) {
98
164
  return { state: 'skipped', reason: 'stw_below_threshold' };
@@ -118,6 +184,7 @@ function buildNavigationStateStatus(handler, gateEnabled) {
118
184
  const state = handler.state;
119
185
  const value = handler.value ?? null;
120
186
  const normalized = normalizeNavigationState(value);
187
+ const known = state.pathKnown === true && state.hasDelta === true;
121
188
  return {
122
189
  enabled: !!gateEnabled,
123
190
  path: handler.path,
@@ -125,10 +192,7 @@ function buildNavigationStateStatus(handler, gateEnabled) {
125
192
  ready: state.ready,
126
193
  isStale: state.isStale,
127
194
  value,
128
- blocking: state.ready && (
129
- ALWAYS_BLOCKING_NAVIGATION_STATES.has(normalized)
130
- || (!!gateEnabled && OPTIONAL_BLOCKING_NAVIGATION_STATES.has(normalized))
131
- )
195
+ blocking: !!gateEnabled && known && NOT_MOVING_NAVIGATION_STATES.has(normalized)
132
196
  };
133
197
  }
134
198
 
@@ -140,14 +204,14 @@ function getDerivedObservationStatus(learningMode, lastState, lastReason) {
140
204
  return { state: 'skipped', reason: 'stabilizing' };
141
205
  }
142
206
  if (learningMode.state === 'suspended') {
143
- return { state: 'skipped', reason: learningMode.reason === 'cog_override' ? 'cog_override_active' : 'nav_state_blocked' };
207
+ return { state: 'skipped', reason: learningMode.reason === 'cog_override' ? 'cog_override_active' : 'not_moving_blocked' };
144
208
  }
145
209
  return { state: lastState, reason: lastReason };
146
210
  }
147
211
 
148
212
  module.exports = function (app) {
149
213
 
150
- const DEFAULT_DIMS = { maxSpeed: 9, speedStep: 1, maxHeel: 32, heelStep: 8 };
214
+ const DEFAULT_DIMS = { maxSpeed: 10, speedStep: 1, maxHeel: 24, heelStep: 6 };
151
215
 
152
216
  let options = {};
153
217
  let changedOptions = {};
@@ -155,15 +219,11 @@ module.exports = function (app) {
155
219
  sogFallback: true,
156
220
  estimateBoatSpeed: false,
157
221
  updateCorrectionTable: true,
158
- stability: 7,
159
- assumeCurrent: false,
222
+ correctionDriftRate: DEFAULT_CORRECTION_DRIFT_RATE,
160
223
  suspendLearningOnNavigationState: false,
161
224
  tableName: 'correctionTable',
162
- configVersion: 2,
163
- smootherClass: 'MovingAverageSmoother',
164
- smootherTau: 3,
225
+ configVersion: 3,
165
226
  smootherTimeSpan: 5,
166
- smootherSteadyState: 0.2,
167
227
  showStatistics: false
168
228
  };
169
229
 
@@ -175,6 +235,10 @@ module.exports = function (app) {
175
235
  // Strip embedded table — stored separately on disk
176
236
  const { correctionTable: _drop, ...rest } = raw;
177
237
  options = { ...defaultOptions, ...rest };
238
+ if (!('correctionDriftRate' in rest) && Number.isFinite(Number(rest.stability))) {
239
+ options.correctionDriftRate = legacyStabilityToCorrectionDriftRate(rest.stability);
240
+ }
241
+ delete options.stability;
178
242
  }
179
243
 
180
244
  function saveOptions() {
@@ -193,43 +257,28 @@ module.exports = function (app) {
193
257
  * writes it back if anything changed. Called once on every start().
194
258
  */
195
259
  function migrateConfig() {
196
- const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication', 'minSogForLearning'];
260
+ const obsoleteKeys = [
261
+ 'headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource',
262
+ 'preventDuplication', 'minSogForLearning', 'stability', 'smootherClass',
263
+ 'smootherTau', 'smootherSteadyState', 'assumeCurrent'
264
+ ];
197
265
  const hadObsolete = obsoleteKeys.some(k => k in options);
198
266
  for (const k of obsoleteKeys) delete options[k];
199
- if (hadObsolete || (options.configVersion || 0) < 2) {
200
- options.configVersion = 2;
267
+ const previousWindow = options.smootherTimeSpan;
268
+ options.smootherTimeSpan = getSmoothingWindowSeconds(options);
269
+ const windowChanged = previousWindow !== options.smootherTimeSpan;
270
+ if (hadObsolete || windowChanged || (options.configVersion || 0) < 4) {
271
+ options.configVersion = 4;
201
272
  saveOptions();
202
- app.debug('Config migrated to v2: removed obsolete source-selection fields');
273
+ app.debug('Config migrated to v3: standardized correction-table learning smoothing');
203
274
  }
204
275
  }
205
276
 
206
- /**
207
- * Derives { SmootherClass, smootherOptions } from the current options.
208
- * Enforces minimums so the smoother always has at least two observations for
209
- * variance to be meaningful:
210
- * - MovingAverageSmoother: timeSpan >= 2 s (≥ 2 samples at typical 1 Hz)
211
- * - ExponentialSmoother: tau >= 1 s
212
- * - KalmanSmoother: steadyState in [0.01, 0.99]
213
- */
277
+ /** Returns the fixed smoother used for all learning inputs. */
214
278
  function resolveSmootherConfig() {
215
- const cls = options.smootherClass || 'MovingAverageSmoother';
216
- if (cls === 'ExponentialSmoother') {
217
- return {
218
- SmootherClass: ExponentialSmoother,
219
- smootherOptions: { timeConstant: Math.max(1, Number(options.smootherTau) || 3) }
220
- };
221
- }
222
- if (cls === 'KalmanSmoother') {
223
- const K = Math.min(0.99, Math.max(0.01, Number(options.smootherSteadyState) || 0.2));
224
- return {
225
- SmootherClass: KalmanSmoother,
226
- smootherOptions: { steadyState: K }
227
- };
228
- }
229
- // Default: MovingAverageSmoother
230
279
  return {
231
280
  SmootherClass: MovingAverageSmoother,
232
- smootherOptions: { timeSpan: Math.max(2, Number(options.smootherTimeSpan) || 5) }
281
+ smootherOptions: { timeSpan: getSmoothingWindowSeconds(options) }
233
282
  };
234
283
  }
235
284
 
@@ -256,6 +305,9 @@ module.exports = function (app) {
256
305
  let smoothedResidual = null;
257
306
  let reportFull = null;
258
307
  let table = null;
308
+ let calculationIntervalSmoother = null;
309
+ let lastCalculationTime = null;
310
+ let lastLearningAttemptTime = null;
259
311
 
260
312
  let rawHeading = null;
261
313
  let rawAttitude = null;
@@ -299,9 +351,15 @@ module.exports = function (app) {
299
351
 
300
352
  function getLearningStatePayload() {
301
353
  const navigationState = buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState);
354
+ const vesselMoving = isVesselMoving({
355
+ sogMagnitude: smoothedGroundSpeed?.magnitude,
356
+ speedThreshold: minSpeed,
357
+ navigationStateHandler,
358
+ gateEnabled: options.suspendLearningOnNavigationState
359
+ });
302
360
  const learningMode = evaluateLearningMode({
303
361
  options,
304
- navigationState,
362
+ vesselMoving,
305
363
  stabilizingUntil: learningStabilizingUntil,
306
364
  stabilizingReason: learningStabilizingReason,
307
365
  now: Date.now()
@@ -319,6 +377,7 @@ module.exports = function (app) {
319
377
  observationReason: observation.reason,
320
378
  minStwForLearning: minSpeed,
321
379
  minSogForLearning: minSpeed,
380
+ vesselMoving,
322
381
  navigationState
323
382
  };
324
383
  }
@@ -327,6 +386,8 @@ module.exports = function (app) {
327
386
  const safePath = path || 'unknown path';
328
387
  const message = status === 'idle'
329
388
  ? `Input ${id} is idle on ${safePath}; resubscribing`
389
+ : status === 'incomplete'
390
+ ? `Input ${id} is missing ${safePath}`
330
391
  : `Input ${id} is stale on ${safePath}`;
331
392
  lifecycleWarningMap.set(id, {
332
393
  id,
@@ -394,6 +455,9 @@ module.exports = function (app) {
394
455
  const payload = reportFull.report();
395
456
  payload.lifecycleWarnings = lifecycleWarnings;
396
457
  payload.learningState = getLearningStatePayload();
458
+ payload.pollIntervalMs = calculationIntervalSmoother?.estimate
459
+ ? calculationIntervalSmoother.estimate * 1000
460
+ : 1000;
397
461
  res.json(payload);
398
462
  }
399
463
  });
@@ -414,7 +478,9 @@ module.exports = function (app) {
414
478
 
415
479
  // --- Settings API ---
416
480
  router.get('/api/settings', (req, res) => {
417
- res.json({ ...options, ...changedOptions });
481
+ const active = { ...options, ...changedOptions };
482
+ for (const key of OBSOLETE_SMOOTHER_KEYS) delete active[key];
483
+ res.json(active);
418
484
  });
419
485
 
420
486
  router.put('/api/settings', (req, res) => {
@@ -429,8 +495,22 @@ module.exports = function (app) {
429
495
  return res.status(400).json({ error: `Key '${k}' is managed via the table manager` });
430
496
  }
431
497
  }
432
- changedOptions = { ...changedOptions, ...body };
433
- res.json({ ...options, ...changedOptions });
498
+ const accepted = Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'stability' && !OBSOLETE_SMOOTHER_KEYS.has(key)));
499
+ if ('correctionDriftRate' in accepted) {
500
+ const driftRate = Number(accepted.correctionDriftRate);
501
+ if (Number.isFinite(driftRate)) {
502
+ accepted.correctionDriftRate = Math.min(MAX_CORRECTION_DRIFT_RATE, Math.max(0, driftRate));
503
+ } else {
504
+ delete accepted.correctionDriftRate;
505
+ }
506
+ }
507
+ if ('smootherTimeSpan' in accepted) {
508
+ accepted.smootherTimeSpan = getSmoothingWindowSeconds(accepted);
509
+ }
510
+ changedOptions = { ...changedOptions, ...accepted };
511
+ const active = { ...options, ...changedOptions };
512
+ for (const key of OBSOLETE_SMOOTHER_KEYS) delete active[key];
513
+ res.json(active);
434
514
  });
435
515
 
436
516
  // --- Correction Table Manager API ---
@@ -468,7 +548,7 @@ module.exports = function (app) {
468
548
  }
469
549
  const row = { min: 0, max: body.maxSpeed, step: body.speedStep };
470
550
  const col = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
471
- const newTable = new CorrectionTable(name, row, col, options.stability || 7);
551
+ const newTable = new CorrectionTable(name, row, col, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
472
552
  newTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
473
553
  saveTable(newTable, path.join(app.getDataDirPath(), name + '.json'));
474
554
  if (isRunning) swapTable(newTable);
@@ -484,12 +564,25 @@ module.exports = function (app) {
484
564
  return res.status(400).json({ error: 'Invalid table name' });
485
565
  const filePath = path.join(app.getDataDirPath(), name + '.json');
486
566
  const fileData = Table2D.readFromFile(filePath);
487
- if (!fileData) return res.status(404).json({ error: `Table '${name}' not found` });
488
- const loadedTable = CorrectionTable.fromJSON(fileData, options.stability || 7);
489
- loadedTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
490
- if (isRunning) swapTable(loadedTable);
491
- saveTableName(name);
492
- res.json({ name });
567
+ if (!fileData) {
568
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: `Table '${name}' not found` });
569
+ const message = `Correction table load rejected for ${name}: file could not be read`;
570
+ app.error(message);
571
+ app.debug(message);
572
+ return res.status(422).json({ error: message });
573
+ }
574
+ try {
575
+ const loadedTable = CorrectionTable.fromJSON(fileData, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
576
+ loadedTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
577
+ if (isRunning) swapTable(loadedTable);
578
+ saveTableName(name);
579
+ res.json({ name });
580
+ } catch (err) {
581
+ const message = `Correction table load rejected for ${name}: ${err.message}`;
582
+ app.error(message);
583
+ app.debug(message);
584
+ res.status(422).json({ error: message });
585
+ }
493
586
  });
494
587
 
495
588
  // Copy active table under a new name and hot-swap to it
@@ -501,7 +594,7 @@ module.exports = function (app) {
501
594
  return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
502
595
  const data = table.toJSON();
503
596
  data.id = newName;
504
- const copiedTable = CorrectionTable.fromJSON(data, options.stability || 7);
597
+ const copiedTable = CorrectionTable.fromJSON(data, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
505
598
  copiedTable.setDisplayAttributes({ label: newName }); // Table2D API unchanged
506
599
  saveTable(copiedTable, path.join(app.getDataDirPath(), newName + '.json'));
507
600
  swapTable(copiedTable);
@@ -520,7 +613,7 @@ module.exports = function (app) {
520
613
  }
521
614
  const newRow = { min: 0, max: body.maxSpeed, step: body.speedStep };
522
615
  const newCol = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
523
- const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, options.stability || 7, 1e-4);
616
+ const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate), 1e-4);
524
617
  resized.setDisplayAttributes({ label: resized.id }); // Table2D API unchanged
525
618
  saveTable(resized, path.join(app.getDataDirPath(), resized.id + '.json'));
526
619
  swapTable(resized);
@@ -545,6 +638,9 @@ module.exports = function (app) {
545
638
  const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
546
639
  table = loadTable(options, tableFilePath);
547
640
  minSpeed = table.step[0];
641
+ calculationIntervalSmoother = new ExponentialSmoother({ tau: 10 });
642
+ lastCalculationTime = null;
643
+ lastLearningAttemptTime = null;
548
644
 
549
645
  //#region Handler and Polar Initialization
550
646
  const { SmootherClass, smootherOptions } = resolveSmootherConfig();
@@ -581,6 +677,10 @@ module.exports = function (app) {
581
677
 
582
678
  navigationStateHandler = new MessageHandler(app, plugin.id, 'navigationState');
583
679
  navigationStateHandler.configure('navigation.state');
680
+ // navigation.state is an event path — it is only sent on transition, not on a regular
681
+ // period — so the generic telemetry staleness timer (which would otherwise mark it
682
+ // stale, and therefore not `ready`, within a few seconds of silence) does not apply.
683
+ navigationStateHandler.stalePeriod = 0;
584
684
  navigationStateHandler.onDelta = () => {
585
685
  handleNavigationStateDelta();
586
686
  };
@@ -617,7 +717,6 @@ module.exports = function (app) {
617
717
  });
618
718
  noCurrent.xSmoother.reset(0,0);
619
719
  noCurrent.ySmoother.reset(0,0);
620
- PolarSmoother.send(app, plugin.id, [noCurrent]);
621
720
 
622
721
  MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
623
722
  units: 'rad',
@@ -632,23 +731,34 @@ module.exports = function (app) {
632
731
  app, pluginId: plugin.id,
633
732
  id: 'boatSpeed',
634
733
  path: 'navigation.speedThroughWater',
635
- subscribe: true,
734
+ subscribe: false,
636
735
  SmootherClass,
637
736
  smootherOptions,
638
737
  onDelta: () => {
639
738
  clearLifecycleWarning('boatSpeed.smoothed');
739
+ const now = Date.now();
740
+ if (lastCalculationTime !== null) {
741
+ const interval = (now - lastCalculationTime) / 1000;
742
+ if (interval > 0) calculationIntervalSmoother.add(interval);
743
+ }
744
+ lastCalculationTime = now;
640
745
  // Drain any pending option changes before calculating
641
746
  if (Object.keys(changedOptions).length) applyOptionChanges();
642
747
 
643
748
  const learningMode = evaluateLearningMode({
644
749
  options,
645
- navigationState: buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState),
750
+ vesselMoving: isVesselMoving({
751
+ sogMagnitude: smoothedGroundSpeed?.magnitude,
752
+ speedThreshold: minSpeed,
753
+ navigationStateHandler,
754
+ gateEnabled: options.suspendLearningOnNavigationState
755
+ }),
646
756
  stabilizingUntil: learningStabilizingUntil,
647
757
  stabilizingReason: learningStabilizingReason,
648
758
  now: Date.now()
649
759
  });
650
760
 
651
- const wellUnderway = learningMode.state !== 'stabilizing';
761
+ const wellUnderway = Date.now() >= learningStabilizingUntil;
652
762
  setStatus(learningMode.state === 'stabilizing' ? 'Stabilizing' : 'Running');
653
763
  if (options.estimateBoatSpeed) correct(wellUnderway);
654
764
  updateTable();
@@ -750,11 +860,6 @@ module.exports = function (app) {
750
860
  reportFull.addAttitude(smoothedAttitude);
751
861
  reportFull.addDelta(smoothedBoatSpeed);
752
862
  reportFull.addPolar(smoothedGroundSpeed);
753
- // smoothedCurrent is already added by the estimateBoatSpeed block when that is on;
754
- // only add it here when estimateBoatSpeed is off, to avoid a duplicate entry.
755
- if (options.assumeCurrent && !options.estimateBoatSpeed) {
756
- reportFull.addPolar(smoothedCurrent);
757
- }
758
863
  reportFull.addTable(table);
759
864
 
760
865
  //#endregion
@@ -766,6 +871,7 @@ module.exports = function (app) {
766
871
  lastNavigationStateValue = normalizeNavigationState(navigationStateHandler?.value);
767
872
  resetLearningStabilization('startup', LONG_STABILIZING_MS);
768
873
  setStatus('Running');
874
+ smoothedBoatSpeed.subscribe();
769
875
  app.debug("Running");
770
876
 
771
877
  }
@@ -841,10 +947,17 @@ module.exports = function (app) {
841
947
  if (options.sogFallback && rawGroundSpeed.ready && rawBoatSpeed.ready && rawBoatSpeed.value === 0 && rawGroundSpeed.magnitude >= minSpeed) {
842
948
  correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
843
949
  }
844
- else if (rawAttitude.ready) {
950
+ else if (rawAttitude.ready && Number.isFinite(rawAttitude.value?.roll)) {
951
+ clearLifecycleWarning('attitude.roll');
845
952
  if (correctedBoatSpeed.magnitude > 0) {
846
953
  const { correction, variance } = table.getCorrection(correctedBoatSpeed.magnitude, rawAttitude.value?.roll);
847
- const leewayValid = isLeewayValid(correctedBoatSpeed.magnitude, minSpeed, navigationStateHandler);
954
+ const vesselMoving = isVesselMoving({
955
+ sogMagnitude: rawGroundSpeed?.magnitude,
956
+ speedThreshold: minSpeed,
957
+ navigationStateHandler,
958
+ gateEnabled: options.suspendLearningOnNavigationState
959
+ });
960
+ const leewayValid = isLeewayValid(correctedBoatSpeed.magnitude, minSpeed, vesselMoving);
848
961
  speedCorrection.setVectorValue(
849
962
  { x: correction.x, y: leewayValid ? correction.y : 0 },
850
963
  { x: variance.x, y: leewayValid ? variance.y : 0 }
@@ -877,23 +990,33 @@ module.exports = function (app) {
877
990
  }
878
991
  }
879
992
  }
880
- // Implicit fallback: attitude not ready — correctedBoatSpeed = raw STW, no correction
993
+ // Implicit fallback: attitude not ready or missing roll — correctedBoatSpeed = raw STW, no correction
994
+ else if (rawAttitude.ready) {
995
+ setLifecycleWarning('attitude.roll', 'incomplete', 'navigation.attitude.roll');
996
+ }
881
997
 
882
- PolarSmoother.send(app, plugin.id, [smoothedCurrent, smoothedResidual]);
998
+ PolarSmoother.send(app, plugin.id, [smoothedCurrent]);
883
999
 
884
1000
  Polar.send(app, plugin.id, [correctedBoatSpeed]);
885
1001
  }
886
1002
 
887
1003
  /**
888
1004
  * Updates the correction table from the current smoothed inputs.
889
- * Reads assumeCurrent and minSpeed from module-level options/state;
1005
+ * Reads minSpeed from module-level state;
890
1006
  * silently returns if any required input is not yet ready.
891
1007
  */
892
1008
  function updateTable() {
1009
+ const now = Date.now();
1010
+ if (lastLearningAttemptTime !== null && now - lastLearningAttemptTime < getLearningIntervalMs(options)) return;
893
1011
  lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
894
1012
  const learningMode = evaluateLearningMode({
895
1013
  options,
896
- navigationState: buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState),
1014
+ vesselMoving: isVesselMoving({
1015
+ sogMagnitude: smoothedGroundSpeed.magnitude,
1016
+ speedThreshold: minSpeed,
1017
+ navigationStateHandler,
1018
+ gateEnabled: options.suspendLearningOnNavigationState
1019
+ }),
897
1020
  stabilizingUntil: learningStabilizingUntil,
898
1021
  stabilizingReason: learningStabilizingReason,
899
1022
  now: Date.now()
@@ -902,13 +1025,11 @@ module.exports = function (app) {
902
1025
  learningMode.state = 'suspended';
903
1026
  learningMode.reason = 'cog_override';
904
1027
  }
905
- const inputsReady = smoothedAttitude.ready && smoothedBoatSpeed.ready && smoothedHeading.ready && smoothedGroundSpeed.ready;
906
- const currentReady = !options.assumeCurrent || smoothedCurrent.ready;
1028
+ const inputsReady = smoothedAttitude.ready && Number.isFinite(smoothedAttitude.value?.roll)
1029
+ && smoothedBoatSpeed.ready && smoothedHeading.ready && smoothedGroundSpeed.ready;
907
1030
  const observationGate = evaluateObservationGate({
908
1031
  learningMode,
909
1032
  inputsReady,
910
- assumeCurrent: options.assumeCurrent,
911
- currentReady,
912
1033
  stw: smoothedBoatSpeed.value,
913
1034
  sog: smoothedGroundSpeed.magnitude,
914
1035
  speedThreshold: minSpeed
@@ -916,12 +1037,22 @@ module.exports = function (app) {
916
1037
 
917
1038
  if (observationGate.state !== 'pending') {
918
1039
  if (observationGate.state === 'invalid') {
1040
+ setObservationStatus('rejected', observationGate.reason);
919
1041
  resetLearningStabilization('observation_reset', getShortStabilizingMs(options));
920
1042
  }
921
1043
  return;
922
1044
  }
923
1045
 
924
- table.update(smoothedBoatSpeed.value, smoothedAttitude.value?.roll, smoothedGroundSpeed, options.assumeCurrent ? smoothedCurrent : noCurrent, lrnBoatSpeed, smoothedHeading.value);
1046
+ lastLearningAttemptTime = now;
1047
+ table.update(
1048
+ smoothedBoatSpeed.value,
1049
+ smoothedAttitude.value?.roll,
1050
+ smoothedGroundSpeed,
1051
+ noCurrent,
1052
+ lrnBoatSpeed,
1053
+ smoothedHeading.value,
1054
+ smoothedHeading.variance
1055
+ );
925
1056
  if (table.lastUpdateResult === 'accepted') {
926
1057
  setObservationStatus('accepted', 'accepted');
927
1058
  } else if (table.lastUpdateResult === 'rejected') {
@@ -935,39 +1066,55 @@ module.exports = function (app) {
935
1066
  * If the file exists it is deserialized; otherwise a new table is created
936
1067
  * with default dimensions and saved to disk.
937
1068
  *
938
- * @param {Object} options - Plugin options (stability, tableName, and dimension defaults).
1069
+ * @param {Object} options - Plugin options (correctionDriftRate, tableName, and dimension defaults).
939
1070
  * @param {string} filePath - Absolute path of the JSON file to read.
940
1071
  * @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
941
1072
  */
942
1073
  function loadTable(options, filePath) {
943
- const stability = (options.stability !== undefined) ? options.stability : 6;
1074
+ const processNoiseRate = correctionDriftRateToProcessNoiseRate(options.correctionDriftRate);
944
1075
  let fileData = Table2D.readFromFile(filePath);
945
- let table;
946
1076
  if (fileData) {
947
- table = CorrectionTable.fromJSON(fileData, stability);
948
- app.debug("Correction table loaded: " + (fileData.id || filePath));
949
- } else if (fs.existsSync(filePath)) {
950
- // File is present but could not be read — transient lock or parse error. Retry once.
951
- app.error(`Correction table file exists but could not be read, retrying: ${filePath}`);
952
- fileData = Table2D.readFromFile(filePath);
953
- if (fileData) {
954
- table = CorrectionTable.fromJSON(fileData, stability);
955
- app.debug("Correction table loaded on retry: " + (fileData.id || filePath));
956
- } else {
957
- app.error(`Correction table retry failed — starting with empty table. Disk file preserved: ${filePath}`);
958
- const name = options.tableName || 'correctionTable';
959
- const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
960
- const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
961
- table = new CorrectionTable(name, row, col, stability);
1077
+ try {
1078
+ const migration = migrateCorrectionTableData(fileData, {
1079
+ filePath,
1080
+ debug: message => app.debug(message)
1081
+ });
1082
+ const table = CorrectionTable.fromJSON(migration.data, processNoiseRate);
1083
+ table.setDisplayAttributes({ label: table.id }); // Table2D API unchanged
1084
+ if (migration.migrated) saveMigratedTable(table, filePath);
1085
+ app.debug("Correction table loaded: " + (fileData.id || filePath));
1086
+ return table;
1087
+ } catch (err) {
1088
+ return recoverTable(`could not deserialize ${filePath}: ${err.message}`);
962
1089
  }
963
- } else {
964
- const name = options.tableName || 'correctionTable';
965
- const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
966
- const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
967
- table = new CorrectionTable(name, row, col, stability);
968
- app.debug("Correction table created: " + name);
969
1090
  }
970
- table.setDisplayAttributes({ label: table.id }); // Table2D API unchanged
1091
+
1092
+ if (fs.existsSync(filePath)) {
1093
+ return recoverTable(`could not read ${filePath}`);
1094
+ }
1095
+
1096
+ const table = createDefaultTable(options.tableName || 'correctionTable', processNoiseRate);
1097
+ app.debug("Correction table created: " + table.id);
1098
+ return table;
1099
+ }
1100
+
1101
+ function createDefaultTable(name, processNoiseRate = DEFAULT_PROCESS_NOISE_RATE) {
1102
+ const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
1103
+ const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
1104
+ const table = new CorrectionTable(name, row, col, processNoiseRate);
1105
+ table.setDisplayAttributes({ label: name }); // Table2D API unchanged
1106
+ return table;
1107
+ }
1108
+
1109
+ function recoverTable(reason) {
1110
+ const recoveryName = `correctionTable-${new Date().toISOString().slice(0, 10)}`;
1111
+ const recoveryPath = path.join(app.getDataDirPath(), recoveryName + '.json');
1112
+ const table = createDefaultTable(recoveryName, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
1113
+ const message = `Correction table recovery: ${reason}; loaded empty table ${recoveryName}`;
1114
+ app.error(message);
1115
+ app.debug(message);
1116
+ saveTableSync(table, recoveryPath);
1117
+ saveTableName(recoveryName);
971
1118
  return table;
972
1119
  }
973
1120
 
@@ -995,6 +1142,18 @@ module.exports = function (app) {
995
1142
  }
996
1143
  }
997
1144
 
1145
+ function saveMigratedTable(correctionTable, filePath) {
1146
+ const temporaryPath = `${filePath}.migration-${process.pid}-${Date.now()}.tmp`;
1147
+ try {
1148
+ fs.writeFileSync(temporaryPath, JSON.stringify(correctionTable.toJSON(), null, 2));
1149
+ fs.renameSync(temporaryPath, filePath);
1150
+ app.debug(`Correction table migrated to schema v2: ${filePath}`);
1151
+ } catch (err) {
1152
+ try { fs.unlinkSync(temporaryPath); } catch {}
1153
+ app.debug(`Correction table migration could not be persisted for ${filePath}: ${err.message}`);
1154
+ }
1155
+ }
1156
+
998
1157
  /**
999
1158
  * Returns true if every cell in the table has N === 0 (no learned data).
1000
1159
  * Used to guard against overwriting a good on-disk file with an empty table.
@@ -1024,7 +1183,7 @@ module.exports = function (app) {
1024
1183
  const filePath = path.join(app.getDataDirPath(), table.id + '.json');
1025
1184
  const fileData = Table2D.readFromFile(filePath);
1026
1185
  if (fileData) {
1027
- const reloaded = CorrectionTable.fromJSON(fileData, options.stability || 7);
1186
+ const reloaded = CorrectionTable.fromJSON(fileData, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
1028
1187
  reloaded.setDisplayAttributes({ label: reloaded.id });
1029
1188
  if (!isTableEmpty(reloaded)) {
1030
1189
  swapTable(reloaded);
@@ -1033,8 +1192,8 @@ module.exports = function (app) {
1033
1192
  }
1034
1193
  }
1035
1194
  }
1036
- // All other keys (sogFallback, estimateBoatSpeed, assumeCurrent,
1037
- // stability, smootherClass, etc.) are read
1195
+ // All other keys (sogFallback, estimateBoatSpeed, correctionDriftRate,
1196
+ // and smootherTimeSpan) are read
1038
1197
  // directly from options.* so no extra action needed.
1039
1198
  if (key === 'estimateBoatSpeed' && !value && correctedBoatSpeed) {
1040
1199
  Polar.clear(app, plugin.id, [correctedBoatSpeed]);
@@ -1044,10 +1203,13 @@ module.exports = function (app) {
1044
1203
  delete changedOptions[key];
1045
1204
  }
1046
1205
 
1047
- // Hot-apply smoother class / parameter changes to all user-tuned smoothers.
1048
- // (noCurrent and smoothedCurrent keep their own fixed Kalman settings.)
1049
- const SMOOTHER_KEYS = ['smootherClass', 'smootherTau', 'smootherTimeSpan', 'smootherSteadyState'];
1050
- if (changedKeys.some(k => SMOOTHER_KEYS.includes(k))) {
1206
+ if (changedKeys.includes('correctionDriftRate') && table) {
1207
+ table.setProcessNoiseRate(correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
1208
+ }
1209
+
1210
+ // Hot-apply the learning window to all user-tuned smoothers.
1211
+ if (changedKeys.includes('smootherTimeSpan')) {
1212
+ options.smootherTimeSpan = getSmoothingWindowSeconds(options);
1051
1213
  const { SmootherClass: SC, smootherOptions: so } = resolveSmootherConfig();
1052
1214
  for (const s of [smoothedHeading, smoothedBoatSpeed, smoothedGroundSpeed]) {
1053
1215
  if (s) { s.setSmootherClass(SC); s.setSmootherOptions(so); }
@@ -1063,13 +1225,18 @@ module.exports = function (app) {
1063
1225
  module.exports._test = {
1064
1226
  normalizeNavigationState,
1065
1227
  getShortStabilizingMs,
1228
+ getSmoothingWindowSeconds,
1229
+ getLearningIntervalMs,
1230
+ correctionDriftRateToProcessNoiseRate,
1231
+ legacyStabilityToCorrectionDriftRate,
1232
+ migrateCorrectionTableData,
1233
+ isVesselMoving,
1066
1234
  evaluateLearningMode,
1067
1235
  isCogOverrideActive,
1068
1236
  isLeewayValid,
1069
1237
  evaluateObservationGate,
1070
1238
  getDerivedObservationStatus,
1071
1239
  buildNavigationStateStatus,
1072
- ALWAYS_BLOCKING_NAVIGATION_STATES,
1073
- OPTIONAL_BLOCKING_NAVIGATION_STATES,
1240
+ NOT_MOVING_NAVIGATION_STATES,
1074
1241
  LONG_STABILIZING_MS,
1075
1242
  };