speedandcurrent 2.3.4 → 2.5.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
  }
@@ -326,7 +385,7 @@ module.exports = function (app) {
326
385
  function setLifecycleWarning(id, status, path) {
327
386
  const safePath = path || 'unknown path';
328
387
  const message = status === 'idle'
329
- ? `Input ${id} is idle on ${safePath}; resubscribing`
388
+ ? `Input ${id} is idle on ${safePath}`
330
389
  : status === 'incomplete'
331
390
  ? `Input ${id} is missing ${safePath}`
332
391
  : `Input ${id} is stale on ${safePath}`;
@@ -348,7 +407,7 @@ module.exports = function (app) {
348
407
  .sort((a, b) => b.updatedAt - a.updatedAt);
349
408
  }
350
409
 
351
- function buildLifecycleCallbacks(id, getPath, resubscribe) {
410
+ function buildLifecycleCallbacks(id, getPath) {
352
411
  return {
353
412
  onDelta: () => {
354
413
  clearLifecycleWarning(id);
@@ -362,13 +421,8 @@ module.exports = function (app) {
362
421
  onIdle: () => {
363
422
  if (!isRunning) return;
364
423
  const path = getPath();
365
- app.debug(`[${plugin.id}] idle input ${id} on ${path}; resubscribing`);
424
+ app.debug(`[${plugin.id}] idle input ${id} on ${path}`);
366
425
  setLifecycleWarning(id, 'idle', path);
367
- try {
368
- resubscribe();
369
- } catch (e) {
370
- app.debug(`[${plugin.id}] resubscribe failed for ${id}: ${e.message}`);
371
- }
372
426
  }
373
427
  };
374
428
  }
@@ -389,18 +443,21 @@ module.exports = function (app) {
389
443
  app.debug('registerWithRouter');
390
444
  readOptions(); // pre-load so /api/settings works before start()
391
445
 
392
- router.get('/api/report', (req, res) => {
446
+ router.access('readonly').get('/api/report', (req, res) => {
393
447
  if (!isRunning) {
394
448
  res.status(503).json({ error: "Plugin is not running" });
395
449
  } else {
396
450
  const payload = reportFull.report();
397
451
  payload.lifecycleWarnings = lifecycleWarnings;
398
452
  payload.learningState = getLearningStatePayload();
453
+ payload.pollIntervalMs = calculationIntervalSmoother?.estimate
454
+ ? calculationIntervalSmoother.estimate * 1000
455
+ : 1000;
399
456
  res.json(payload);
400
457
  }
401
458
  });
402
459
 
403
- router.get('/api/meta', (req, res) => {
460
+ router.access('readonly').get('/api/meta', (req, res) => {
404
461
  if (!isRunning) {
405
462
  res.status(503).json({ error: "Plugin is not running" });
406
463
  } else {
@@ -410,13 +467,15 @@ module.exports = function (app) {
410
467
 
411
468
 
412
469
 
413
- router.get('/api/status', (req, res) => {
470
+ router.access('readonly').get('/api/status', (req, res) => {
414
471
  res.json({ status: pluginStatus, isRunning, lifecycleWarnings, learningState: getLearningStatePayload() });
415
472
  });
416
473
 
417
474
  // --- Settings API ---
418
475
  router.get('/api/settings', (req, res) => {
419
- res.json({ ...options, ...changedOptions });
476
+ const active = { ...options, ...changedOptions };
477
+ for (const key of OBSOLETE_SMOOTHER_KEYS) delete active[key];
478
+ res.json(active);
420
479
  });
421
480
 
422
481
  router.put('/api/settings', (req, res) => {
@@ -431,8 +490,22 @@ module.exports = function (app) {
431
490
  return res.status(400).json({ error: `Key '${k}' is managed via the table manager` });
432
491
  }
433
492
  }
434
- changedOptions = { ...changedOptions, ...body };
435
- res.json({ ...options, ...changedOptions });
493
+ const accepted = Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'stability' && !OBSOLETE_SMOOTHER_KEYS.has(key)));
494
+ if ('correctionDriftRate' in accepted) {
495
+ const driftRate = Number(accepted.correctionDriftRate);
496
+ if (Number.isFinite(driftRate)) {
497
+ accepted.correctionDriftRate = Math.min(MAX_CORRECTION_DRIFT_RATE, Math.max(0, driftRate));
498
+ } else {
499
+ delete accepted.correctionDriftRate;
500
+ }
501
+ }
502
+ if ('smootherTimeSpan' in accepted) {
503
+ accepted.smootherTimeSpan = getSmoothingWindowSeconds(accepted);
504
+ }
505
+ changedOptions = { ...changedOptions, ...accepted };
506
+ const active = { ...options, ...changedOptions };
507
+ for (const key of OBSOLETE_SMOOTHER_KEYS) delete active[key];
508
+ res.json(active);
436
509
  });
437
510
 
438
511
  // --- Correction Table Manager API ---
@@ -470,7 +543,7 @@ module.exports = function (app) {
470
543
  }
471
544
  const row = { min: 0, max: body.maxSpeed, step: body.speedStep };
472
545
  const col = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
473
- const newTable = new CorrectionTable(name, row, col, options.stability || 7);
546
+ const newTable = new CorrectionTable(name, row, col, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
474
547
  newTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
475
548
  saveTable(newTable, path.join(app.getDataDirPath(), name + '.json'));
476
549
  if (isRunning) swapTable(newTable);
@@ -486,12 +559,25 @@ module.exports = function (app) {
486
559
  return res.status(400).json({ error: 'Invalid table name' });
487
560
  const filePath = path.join(app.getDataDirPath(), name + '.json');
488
561
  const fileData = Table2D.readFromFile(filePath);
489
- if (!fileData) return res.status(404).json({ error: `Table '${name}' not found` });
490
- const loadedTable = CorrectionTable.fromJSON(fileData, options.stability || 7);
491
- loadedTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
492
- if (isRunning) swapTable(loadedTable);
493
- saveTableName(name);
494
- res.json({ name });
562
+ if (!fileData) {
563
+ if (!fs.existsSync(filePath)) return res.status(404).json({ error: `Table '${name}' not found` });
564
+ const message = `Correction table load rejected for ${name}: file could not be read`;
565
+ app.error(message);
566
+ app.debug(message);
567
+ return res.status(422).json({ error: message });
568
+ }
569
+ try {
570
+ const loadedTable = CorrectionTable.fromJSON(fileData, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
571
+ loadedTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
572
+ if (isRunning) swapTable(loadedTable);
573
+ saveTableName(name);
574
+ res.json({ name });
575
+ } catch (err) {
576
+ const message = `Correction table load rejected for ${name}: ${err.message}`;
577
+ app.error(message);
578
+ app.debug(message);
579
+ res.status(422).json({ error: message });
580
+ }
495
581
  });
496
582
 
497
583
  // Copy active table under a new name and hot-swap to it
@@ -503,7 +589,7 @@ module.exports = function (app) {
503
589
  return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
504
590
  const data = table.toJSON();
505
591
  data.id = newName;
506
- const copiedTable = CorrectionTable.fromJSON(data, options.stability || 7);
592
+ const copiedTable = CorrectionTable.fromJSON(data, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
507
593
  copiedTable.setDisplayAttributes({ label: newName }); // Table2D API unchanged
508
594
  saveTable(copiedTable, path.join(app.getDataDirPath(), newName + '.json'));
509
595
  swapTable(copiedTable);
@@ -522,7 +608,7 @@ module.exports = function (app) {
522
608
  }
523
609
  const newRow = { min: 0, max: body.maxSpeed, step: body.speedStep };
524
610
  const newCol = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
525
- const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, options.stability || 7, 1e-4);
611
+ const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate), 1e-4);
526
612
  resized.setDisplayAttributes({ label: resized.id }); // Table2D API unchanged
527
613
  saveTable(resized, path.join(app.getDataDirPath(), resized.id + '.json'));
528
614
  swapTable(resized);
@@ -547,6 +633,9 @@ module.exports = function (app) {
547
633
  const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
548
634
  table = loadTable(options, tableFilePath);
549
635
  minSpeed = table.step[0];
636
+ calculationIntervalSmoother = new ExponentialSmoother({ tau: 10 });
637
+ lastCalculationTime = null;
638
+ lastLearningAttemptTime = null;
550
639
 
551
640
  //#region Handler and Polar Initialization
552
641
  const { SmootherClass, smootherOptions } = resolveSmootherConfig();
@@ -559,8 +648,7 @@ module.exports = function (app) {
559
648
  smootherOptions,
560
649
  ...buildLifecycleCallbacks(
561
650
  'heading.angle',
562
- () => smoothedHeading?.handler?.path || 'navigation.headingTrue',
563
- () => { smoothedHeading?.unsubscribe(); smoothedHeading?.subscribe(false, true); }
651
+ () => smoothedHeading?.handler?.path || 'navigation.headingTrue'
564
652
  )
565
653
  });
566
654
  rawHeading = smoothedHeading.handler;
@@ -575,14 +663,17 @@ module.exports = function (app) {
575
663
  smootherOptions,
576
664
  ...buildLifecycleCallbacks(
577
665
  'attitude.smoothed',
578
- () => smoothedAttitude?.handler?.path || 'navigation.attitude',
579
- () => { smoothedAttitude?.unsubscribe(); smoothedAttitude?.subscribe(); }
666
+ () => smoothedAttitude?.handler?.path || 'navigation.attitude'
580
667
  )
581
668
  });
582
669
  rawAttitude = smoothedAttitude.handler;
583
670
 
584
671
  navigationStateHandler = new MessageHandler(app, plugin.id, 'navigationState');
585
672
  navigationStateHandler.configure('navigation.state');
673
+ // navigation.state is an event path — it is only sent on transition, not on a regular
674
+ // period — so the generic telemetry staleness timer (which would otherwise mark it
675
+ // stale, and therefore not `ready`, within a few seconds of silence) does not apply.
676
+ navigationStateHandler.stalePeriod = 0;
586
677
  navigationStateHandler.onDelta = () => {
587
678
  handleNavigationStateDelta();
588
679
  };
@@ -619,7 +710,6 @@ module.exports = function (app) {
619
710
  });
620
711
  noCurrent.xSmoother.reset(0,0);
621
712
  noCurrent.ySmoother.reset(0,0);
622
- PolarSmoother.send(app, plugin.id, [noCurrent]);
623
713
 
624
714
  MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
625
715
  units: 'rad',
@@ -639,12 +729,23 @@ module.exports = function (app) {
639
729
  smootherOptions,
640
730
  onDelta: () => {
641
731
  clearLifecycleWarning('boatSpeed.smoothed');
732
+ const now = Date.now();
733
+ if (lastCalculationTime !== null) {
734
+ const interval = (now - lastCalculationTime) / 1000;
735
+ if (interval > 0) calculationIntervalSmoother.add(interval);
736
+ }
737
+ lastCalculationTime = now;
642
738
  // Drain any pending option changes before calculating
643
739
  if (Object.keys(changedOptions).length) applyOptionChanges();
644
740
 
645
741
  const learningMode = evaluateLearningMode({
646
742
  options,
647
- navigationState: buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState),
743
+ vesselMoving: isVesselMoving({
744
+ sogMagnitude: smoothedGroundSpeed?.magnitude,
745
+ speedThreshold: minSpeed,
746
+ navigationStateHandler,
747
+ gateEnabled: options.suspendLearningOnNavigationState
748
+ }),
648
749
  stabilizingUntil: learningStabilizingUntil,
649
750
  stabilizingReason: learningStabilizingReason,
650
751
  now: Date.now()
@@ -668,10 +769,8 @@ module.exports = function (app) {
668
769
  onIdle: () => {
669
770
  if (!isRunning) return;
670
771
  const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
671
- app.debug(`[${plugin.id}] idle input boatSpeed.smoothed on ${path}; resubscribing`);
772
+ app.debug(`[${plugin.id}] idle input boatSpeed.smoothed on ${path}`);
672
773
  setLifecycleWarning('boatSpeed.smoothed', 'idle', path);
673
- smoothedBoatSpeed?.unsubscribe();
674
- smoothedBoatSpeed?.subscribe();
675
774
  },
676
775
  onStale: () => {
677
776
  if (!isRunning) return;
@@ -712,8 +811,7 @@ module.exports = function (app) {
712
811
  smootherOptions,
713
812
  ...buildLifecycleCallbacks(
714
813
  'groundSpeed.smoothed',
715
- () => `${smoothedGroundSpeed?.polar?.pathMagnitude || 'navigation.speedOverGround'}, ${smoothedGroundSpeed?.polar?.pathAngle || 'navigation.courseOverGroundTrue'}`,
716
- () => { smoothedGroundSpeed?.unsubscribe(); smoothedGroundSpeed?.subscribe(true, true); }
814
+ () => `${smoothedGroundSpeed?.polar?.pathMagnitude || 'navigation.speedOverGround'}, ${smoothedGroundSpeed?.polar?.pathAngle || 'navigation.courseOverGroundTrue'}`
717
815
  )
718
816
  });
719
817
  rawGroundSpeed = smoothedGroundSpeed.polar;
@@ -752,11 +850,6 @@ module.exports = function (app) {
752
850
  reportFull.addAttitude(smoothedAttitude);
753
851
  reportFull.addDelta(smoothedBoatSpeed);
754
852
  reportFull.addPolar(smoothedGroundSpeed);
755
- // smoothedCurrent is already added by the estimateBoatSpeed block when that is on;
756
- // only add it here when estimateBoatSpeed is off, to avoid a duplicate entry.
757
- if (options.assumeCurrent && !options.estimateBoatSpeed) {
758
- reportFull.addPolar(smoothedCurrent);
759
- }
760
853
  reportFull.addTable(table);
761
854
 
762
855
  //#endregion
@@ -848,7 +941,13 @@ module.exports = function (app) {
848
941
  clearLifecycleWarning('attitude.roll');
849
942
  if (correctedBoatSpeed.magnitude > 0) {
850
943
  const { correction, variance } = table.getCorrection(correctedBoatSpeed.magnitude, rawAttitude.value?.roll);
851
- const leewayValid = isLeewayValid(correctedBoatSpeed.magnitude, minSpeed, navigationStateHandler);
944
+ const vesselMoving = isVesselMoving({
945
+ sogMagnitude: rawGroundSpeed?.magnitude,
946
+ speedThreshold: minSpeed,
947
+ navigationStateHandler,
948
+ gateEnabled: options.suspendLearningOnNavigationState
949
+ });
950
+ const leewayValid = isLeewayValid(correctedBoatSpeed.magnitude, minSpeed, vesselMoving);
852
951
  speedCorrection.setVectorValue(
853
952
  { x: correction.x, y: leewayValid ? correction.y : 0 },
854
953
  { x: variance.x, y: leewayValid ? variance.y : 0 }
@@ -886,21 +985,28 @@ module.exports = function (app) {
886
985
  setLifecycleWarning('attitude.roll', 'incomplete', 'navigation.attitude.roll');
887
986
  }
888
987
 
889
- PolarSmoother.send(app, plugin.id, [smoothedCurrent, smoothedResidual]);
988
+ PolarSmoother.send(app, plugin.id, [smoothedCurrent]);
890
989
 
891
990
  Polar.send(app, plugin.id, [correctedBoatSpeed]);
892
991
  }
893
992
 
894
993
  /**
895
994
  * Updates the correction table from the current smoothed inputs.
896
- * Reads assumeCurrent and minSpeed from module-level options/state;
995
+ * Reads minSpeed from module-level state;
897
996
  * silently returns if any required input is not yet ready.
898
997
  */
899
998
  function updateTable() {
999
+ const now = Date.now();
1000
+ if (lastLearningAttemptTime !== null && now - lastLearningAttemptTime < getLearningIntervalMs(options)) return;
900
1001
  lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
901
1002
  const learningMode = evaluateLearningMode({
902
1003
  options,
903
- navigationState: buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState),
1004
+ vesselMoving: isVesselMoving({
1005
+ sogMagnitude: smoothedGroundSpeed.magnitude,
1006
+ speedThreshold: minSpeed,
1007
+ navigationStateHandler,
1008
+ gateEnabled: options.suspendLearningOnNavigationState
1009
+ }),
904
1010
  stabilizingUntil: learningStabilizingUntil,
905
1011
  stabilizingReason: learningStabilizingReason,
906
1012
  now: Date.now()
@@ -911,12 +1017,9 @@ module.exports = function (app) {
911
1017
  }
912
1018
  const inputsReady = smoothedAttitude.ready && Number.isFinite(smoothedAttitude.value?.roll)
913
1019
  && smoothedBoatSpeed.ready && smoothedHeading.ready && smoothedGroundSpeed.ready;
914
- const currentReady = !options.assumeCurrent || smoothedCurrent.ready;
915
1020
  const observationGate = evaluateObservationGate({
916
1021
  learningMode,
917
1022
  inputsReady,
918
- assumeCurrent: options.assumeCurrent,
919
- currentReady,
920
1023
  stw: smoothedBoatSpeed.value,
921
1024
  sog: smoothedGroundSpeed.magnitude,
922
1025
  speedThreshold: minSpeed
@@ -930,7 +1033,16 @@ module.exports = function (app) {
930
1033
  return;
931
1034
  }
932
1035
 
933
- table.update(smoothedBoatSpeed.value, smoothedAttitude.value?.roll, smoothedGroundSpeed, options.assumeCurrent ? smoothedCurrent : noCurrent, lrnBoatSpeed, smoothedHeading.value);
1036
+ lastLearningAttemptTime = now;
1037
+ table.update(
1038
+ smoothedBoatSpeed.value,
1039
+ smoothedAttitude.value?.roll,
1040
+ smoothedGroundSpeed,
1041
+ noCurrent,
1042
+ lrnBoatSpeed,
1043
+ smoothedHeading.value,
1044
+ smoothedHeading.variance
1045
+ );
934
1046
  if (table.lastUpdateResult === 'accepted') {
935
1047
  setObservationStatus('accepted', 'accepted');
936
1048
  } else if (table.lastUpdateResult === 'rejected') {
@@ -944,39 +1056,55 @@ module.exports = function (app) {
944
1056
  * If the file exists it is deserialized; otherwise a new table is created
945
1057
  * with default dimensions and saved to disk.
946
1058
  *
947
- * @param {Object} options - Plugin options (stability, tableName, and dimension defaults).
1059
+ * @param {Object} options - Plugin options (correctionDriftRate, tableName, and dimension defaults).
948
1060
  * @param {string} filePath - Absolute path of the JSON file to read.
949
1061
  * @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
950
1062
  */
951
1063
  function loadTable(options, filePath) {
952
- const stability = (options.stability !== undefined) ? options.stability : 6;
1064
+ const processNoiseRate = correctionDriftRateToProcessNoiseRate(options.correctionDriftRate);
953
1065
  let fileData = Table2D.readFromFile(filePath);
954
- let table;
955
1066
  if (fileData) {
956
- table = CorrectionTable.fromJSON(fileData, stability);
957
- app.debug("Correction table loaded: " + (fileData.id || filePath));
958
- } else if (fs.existsSync(filePath)) {
959
- // File is present but could not be read — transient lock or parse error. Retry once.
960
- app.error(`Correction table file exists but could not be read, retrying: ${filePath}`);
961
- fileData = Table2D.readFromFile(filePath);
962
- if (fileData) {
963
- table = CorrectionTable.fromJSON(fileData, stability);
964
- app.debug("Correction table loaded on retry: " + (fileData.id || filePath));
965
- } else {
966
- app.error(`Correction table retry failed — starting with empty table. Disk file preserved: ${filePath}`);
967
- const name = options.tableName || 'correctionTable';
968
- const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
969
- const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
970
- table = new CorrectionTable(name, row, col, stability);
1067
+ try {
1068
+ const migration = migrateCorrectionTableData(fileData, {
1069
+ filePath,
1070
+ debug: message => app.debug(message)
1071
+ });
1072
+ const table = CorrectionTable.fromJSON(migration.data, processNoiseRate);
1073
+ table.setDisplayAttributes({ label: table.id }); // Table2D API unchanged
1074
+ if (migration.migrated) saveMigratedTable(table, filePath);
1075
+ app.debug("Correction table loaded: " + (fileData.id || filePath));
1076
+ return table;
1077
+ } catch (err) {
1078
+ return recoverTable(`could not deserialize ${filePath}: ${err.message}`);
971
1079
  }
972
- } else {
973
- const name = options.tableName || 'correctionTable';
974
- const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
975
- const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
976
- table = new CorrectionTable(name, row, col, stability);
977
- app.debug("Correction table created: " + name);
978
1080
  }
979
- table.setDisplayAttributes({ label: table.id }); // Table2D API unchanged
1081
+
1082
+ if (fs.existsSync(filePath)) {
1083
+ return recoverTable(`could not read ${filePath}`);
1084
+ }
1085
+
1086
+ const table = createDefaultTable(options.tableName || 'correctionTable', processNoiseRate);
1087
+ app.debug("Correction table created: " + table.id);
1088
+ return table;
1089
+ }
1090
+
1091
+ function createDefaultTable(name, processNoiseRate = DEFAULT_PROCESS_NOISE_RATE) {
1092
+ const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
1093
+ const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
1094
+ const table = new CorrectionTable(name, row, col, processNoiseRate);
1095
+ table.setDisplayAttributes({ label: name }); // Table2D API unchanged
1096
+ return table;
1097
+ }
1098
+
1099
+ function recoverTable(reason) {
1100
+ const recoveryName = `correctionTable-${new Date().toISOString().slice(0, 10)}`;
1101
+ const recoveryPath = path.join(app.getDataDirPath(), recoveryName + '.json');
1102
+ const table = createDefaultTable(recoveryName, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
1103
+ const message = `Correction table recovery: ${reason}; loaded empty table ${recoveryName}`;
1104
+ app.error(message);
1105
+ app.debug(message);
1106
+ saveTableSync(table, recoveryPath);
1107
+ saveTableName(recoveryName);
980
1108
  return table;
981
1109
  }
982
1110
 
@@ -1004,6 +1132,18 @@ module.exports = function (app) {
1004
1132
  }
1005
1133
  }
1006
1134
 
1135
+ function saveMigratedTable(correctionTable, filePath) {
1136
+ const temporaryPath = `${filePath}.migration-${process.pid}-${Date.now()}.tmp`;
1137
+ try {
1138
+ fs.writeFileSync(temporaryPath, JSON.stringify(correctionTable.toJSON(), null, 2));
1139
+ fs.renameSync(temporaryPath, filePath);
1140
+ app.debug(`Correction table migrated to schema v2: ${filePath}`);
1141
+ } catch (err) {
1142
+ try { fs.unlinkSync(temporaryPath); } catch {}
1143
+ app.debug(`Correction table migration could not be persisted for ${filePath}: ${err.message}`);
1144
+ }
1145
+ }
1146
+
1007
1147
  /**
1008
1148
  * Returns true if every cell in the table has N === 0 (no learned data).
1009
1149
  * Used to guard against overwriting a good on-disk file with an empty table.
@@ -1033,7 +1173,7 @@ module.exports = function (app) {
1033
1173
  const filePath = path.join(app.getDataDirPath(), table.id + '.json');
1034
1174
  const fileData = Table2D.readFromFile(filePath);
1035
1175
  if (fileData) {
1036
- const reloaded = CorrectionTable.fromJSON(fileData, options.stability || 7);
1176
+ const reloaded = CorrectionTable.fromJSON(fileData, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
1037
1177
  reloaded.setDisplayAttributes({ label: reloaded.id });
1038
1178
  if (!isTableEmpty(reloaded)) {
1039
1179
  swapTable(reloaded);
@@ -1042,8 +1182,8 @@ module.exports = function (app) {
1042
1182
  }
1043
1183
  }
1044
1184
  }
1045
- // All other keys (sogFallback, estimateBoatSpeed, assumeCurrent,
1046
- // stability, smootherClass, etc.) are read
1185
+ // All other keys (sogFallback, estimateBoatSpeed, correctionDriftRate,
1186
+ // and smootherTimeSpan) are read
1047
1187
  // directly from options.* so no extra action needed.
1048
1188
  if (key === 'estimateBoatSpeed' && !value && correctedBoatSpeed) {
1049
1189
  Polar.clear(app, plugin.id, [correctedBoatSpeed]);
@@ -1053,10 +1193,13 @@ module.exports = function (app) {
1053
1193
  delete changedOptions[key];
1054
1194
  }
1055
1195
 
1056
- // Hot-apply smoother class / parameter changes to all user-tuned smoothers.
1057
- // (noCurrent and smoothedCurrent keep their own fixed Kalman settings.)
1058
- const SMOOTHER_KEYS = ['smootherClass', 'smootherTau', 'smootherTimeSpan', 'smootherSteadyState'];
1059
- if (changedKeys.some(k => SMOOTHER_KEYS.includes(k))) {
1196
+ if (changedKeys.includes('correctionDriftRate') && table) {
1197
+ table.setProcessNoiseRate(correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
1198
+ }
1199
+
1200
+ // Hot-apply the learning window to all user-tuned smoothers.
1201
+ if (changedKeys.includes('smootherTimeSpan')) {
1202
+ options.smootherTimeSpan = getSmoothingWindowSeconds(options);
1060
1203
  const { SmootherClass: SC, smootherOptions: so } = resolveSmootherConfig();
1061
1204
  for (const s of [smoothedHeading, smoothedBoatSpeed, smoothedGroundSpeed]) {
1062
1205
  if (s) { s.setSmootherClass(SC); s.setSmootherOptions(so); }
@@ -1072,13 +1215,18 @@ module.exports = function (app) {
1072
1215
  module.exports._test = {
1073
1216
  normalizeNavigationState,
1074
1217
  getShortStabilizingMs,
1218
+ getSmoothingWindowSeconds,
1219
+ getLearningIntervalMs,
1220
+ correctionDriftRateToProcessNoiseRate,
1221
+ legacyStabilityToCorrectionDriftRate,
1222
+ migrateCorrectionTableData,
1223
+ isVesselMoving,
1075
1224
  evaluateLearningMode,
1076
1225
  isCogOverrideActive,
1077
1226
  isLeewayValid,
1078
1227
  evaluateObservationGate,
1079
1228
  getDerivedObservationStatus,
1080
1229
  buildNavigationStateStatus,
1081
- ALWAYS_BLOCKING_NAVIGATION_STATES,
1082
- OPTIONAL_BLOCKING_NAVIGATION_STATES,
1230
+ NOT_MOVING_NAVIGATION_STATES,
1083
1231
  LONG_STABILIZING_MS,
1084
1232
  };