speedandcurrent 2.3.4 → 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
  }
@@ -396,6 +455,9 @@ module.exports = function (app) {
396
455
  const payload = reportFull.report();
397
456
  payload.lifecycleWarnings = lifecycleWarnings;
398
457
  payload.learningState = getLearningStatePayload();
458
+ payload.pollIntervalMs = calculationIntervalSmoother?.estimate
459
+ ? calculationIntervalSmoother.estimate * 1000
460
+ : 1000;
399
461
  res.json(payload);
400
462
  }
401
463
  });
@@ -416,7 +478,9 @@ module.exports = function (app) {
416
478
 
417
479
  // --- Settings API ---
418
480
  router.get('/api/settings', (req, res) => {
419
- res.json({ ...options, ...changedOptions });
481
+ const active = { ...options, ...changedOptions };
482
+ for (const key of OBSOLETE_SMOOTHER_KEYS) delete active[key];
483
+ res.json(active);
420
484
  });
421
485
 
422
486
  router.put('/api/settings', (req, res) => {
@@ -431,8 +495,22 @@ module.exports = function (app) {
431
495
  return res.status(400).json({ error: `Key '${k}' is managed via the table manager` });
432
496
  }
433
497
  }
434
- changedOptions = { ...changedOptions, ...body };
435
- 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);
436
514
  });
437
515
 
438
516
  // --- Correction Table Manager API ---
@@ -470,7 +548,7 @@ module.exports = function (app) {
470
548
  }
471
549
  const row = { min: 0, max: body.maxSpeed, step: body.speedStep };
472
550
  const col = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
473
- const newTable = new CorrectionTable(name, row, col, options.stability || 7);
551
+ const newTable = new CorrectionTable(name, row, col, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
474
552
  newTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
475
553
  saveTable(newTable, path.join(app.getDataDirPath(), name + '.json'));
476
554
  if (isRunning) swapTable(newTable);
@@ -486,12 +564,25 @@ module.exports = function (app) {
486
564
  return res.status(400).json({ error: 'Invalid table name' });
487
565
  const filePath = path.join(app.getDataDirPath(), name + '.json');
488
566
  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 });
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
+ }
495
586
  });
496
587
 
497
588
  // Copy active table under a new name and hot-swap to it
@@ -503,7 +594,7 @@ module.exports = function (app) {
503
594
  return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
504
595
  const data = table.toJSON();
505
596
  data.id = newName;
506
- const copiedTable = CorrectionTable.fromJSON(data, options.stability || 7);
597
+ const copiedTable = CorrectionTable.fromJSON(data, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
507
598
  copiedTable.setDisplayAttributes({ label: newName }); // Table2D API unchanged
508
599
  saveTable(copiedTable, path.join(app.getDataDirPath(), newName + '.json'));
509
600
  swapTable(copiedTable);
@@ -522,7 +613,7 @@ module.exports = function (app) {
522
613
  }
523
614
  const newRow = { min: 0, max: body.maxSpeed, step: body.speedStep };
524
615
  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);
616
+ const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate), 1e-4);
526
617
  resized.setDisplayAttributes({ label: resized.id }); // Table2D API unchanged
527
618
  saveTable(resized, path.join(app.getDataDirPath(), resized.id + '.json'));
528
619
  swapTable(resized);
@@ -547,6 +638,9 @@ module.exports = function (app) {
547
638
  const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
548
639
  table = loadTable(options, tableFilePath);
549
640
  minSpeed = table.step[0];
641
+ calculationIntervalSmoother = new ExponentialSmoother({ tau: 10 });
642
+ lastCalculationTime = null;
643
+ lastLearningAttemptTime = null;
550
644
 
551
645
  //#region Handler and Polar Initialization
552
646
  const { SmootherClass, smootherOptions } = resolveSmootherConfig();
@@ -583,6 +677,10 @@ module.exports = function (app) {
583
677
 
584
678
  navigationStateHandler = new MessageHandler(app, plugin.id, 'navigationState');
585
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;
586
684
  navigationStateHandler.onDelta = () => {
587
685
  handleNavigationStateDelta();
588
686
  };
@@ -619,7 +717,6 @@ module.exports = function (app) {
619
717
  });
620
718
  noCurrent.xSmoother.reset(0,0);
621
719
  noCurrent.ySmoother.reset(0,0);
622
- PolarSmoother.send(app, plugin.id, [noCurrent]);
623
720
 
624
721
  MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
625
722
  units: 'rad',
@@ -639,12 +736,23 @@ module.exports = function (app) {
639
736
  smootherOptions,
640
737
  onDelta: () => {
641
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;
642
745
  // Drain any pending option changes before calculating
643
746
  if (Object.keys(changedOptions).length) applyOptionChanges();
644
747
 
645
748
  const learningMode = evaluateLearningMode({
646
749
  options,
647
- navigationState: buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState),
750
+ vesselMoving: isVesselMoving({
751
+ sogMagnitude: smoothedGroundSpeed?.magnitude,
752
+ speedThreshold: minSpeed,
753
+ navigationStateHandler,
754
+ gateEnabled: options.suspendLearningOnNavigationState
755
+ }),
648
756
  stabilizingUntil: learningStabilizingUntil,
649
757
  stabilizingReason: learningStabilizingReason,
650
758
  now: Date.now()
@@ -752,11 +860,6 @@ module.exports = function (app) {
752
860
  reportFull.addAttitude(smoothedAttitude);
753
861
  reportFull.addDelta(smoothedBoatSpeed);
754
862
  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
863
  reportFull.addTable(table);
761
864
 
762
865
  //#endregion
@@ -848,7 +951,13 @@ module.exports = function (app) {
848
951
  clearLifecycleWarning('attitude.roll');
849
952
  if (correctedBoatSpeed.magnitude > 0) {
850
953
  const { correction, variance } = table.getCorrection(correctedBoatSpeed.magnitude, rawAttitude.value?.roll);
851
- 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);
852
961
  speedCorrection.setVectorValue(
853
962
  { x: correction.x, y: leewayValid ? correction.y : 0 },
854
963
  { x: variance.x, y: leewayValid ? variance.y : 0 }
@@ -886,21 +995,28 @@ module.exports = function (app) {
886
995
  setLifecycleWarning('attitude.roll', 'incomplete', 'navigation.attitude.roll');
887
996
  }
888
997
 
889
- PolarSmoother.send(app, plugin.id, [smoothedCurrent, smoothedResidual]);
998
+ PolarSmoother.send(app, plugin.id, [smoothedCurrent]);
890
999
 
891
1000
  Polar.send(app, plugin.id, [correctedBoatSpeed]);
892
1001
  }
893
1002
 
894
1003
  /**
895
1004
  * Updates the correction table from the current smoothed inputs.
896
- * Reads assumeCurrent and minSpeed from module-level options/state;
1005
+ * Reads minSpeed from module-level state;
897
1006
  * silently returns if any required input is not yet ready.
898
1007
  */
899
1008
  function updateTable() {
1009
+ const now = Date.now();
1010
+ if (lastLearningAttemptTime !== null && now - lastLearningAttemptTime < getLearningIntervalMs(options)) return;
900
1011
  lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
901
1012
  const learningMode = evaluateLearningMode({
902
1013
  options,
903
- navigationState: buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState),
1014
+ vesselMoving: isVesselMoving({
1015
+ sogMagnitude: smoothedGroundSpeed.magnitude,
1016
+ speedThreshold: minSpeed,
1017
+ navigationStateHandler,
1018
+ gateEnabled: options.suspendLearningOnNavigationState
1019
+ }),
904
1020
  stabilizingUntil: learningStabilizingUntil,
905
1021
  stabilizingReason: learningStabilizingReason,
906
1022
  now: Date.now()
@@ -911,12 +1027,9 @@ module.exports = function (app) {
911
1027
  }
912
1028
  const inputsReady = smoothedAttitude.ready && Number.isFinite(smoothedAttitude.value?.roll)
913
1029
  && smoothedBoatSpeed.ready && smoothedHeading.ready && smoothedGroundSpeed.ready;
914
- const currentReady = !options.assumeCurrent || smoothedCurrent.ready;
915
1030
  const observationGate = evaluateObservationGate({
916
1031
  learningMode,
917
1032
  inputsReady,
918
- assumeCurrent: options.assumeCurrent,
919
- currentReady,
920
1033
  stw: smoothedBoatSpeed.value,
921
1034
  sog: smoothedGroundSpeed.magnitude,
922
1035
  speedThreshold: minSpeed
@@ -930,7 +1043,16 @@ module.exports = function (app) {
930
1043
  return;
931
1044
  }
932
1045
 
933
- 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
+ );
934
1056
  if (table.lastUpdateResult === 'accepted') {
935
1057
  setObservationStatus('accepted', 'accepted');
936
1058
  } else if (table.lastUpdateResult === 'rejected') {
@@ -944,39 +1066,55 @@ module.exports = function (app) {
944
1066
  * If the file exists it is deserialized; otherwise a new table is created
945
1067
  * with default dimensions and saved to disk.
946
1068
  *
947
- * @param {Object} options - Plugin options (stability, tableName, and dimension defaults).
1069
+ * @param {Object} options - Plugin options (correctionDriftRate, tableName, and dimension defaults).
948
1070
  * @param {string} filePath - Absolute path of the JSON file to read.
949
1071
  * @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
950
1072
  */
951
1073
  function loadTable(options, filePath) {
952
- const stability = (options.stability !== undefined) ? options.stability : 6;
1074
+ const processNoiseRate = correctionDriftRateToProcessNoiseRate(options.correctionDriftRate);
953
1075
  let fileData = Table2D.readFromFile(filePath);
954
- let table;
955
1076
  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);
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}`);
971
1089
  }
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
1090
  }
979
- 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);
980
1118
  return table;
981
1119
  }
982
1120
 
@@ -1004,6 +1142,18 @@ module.exports = function (app) {
1004
1142
  }
1005
1143
  }
1006
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
+
1007
1157
  /**
1008
1158
  * Returns true if every cell in the table has N === 0 (no learned data).
1009
1159
  * Used to guard against overwriting a good on-disk file with an empty table.
@@ -1033,7 +1183,7 @@ module.exports = function (app) {
1033
1183
  const filePath = path.join(app.getDataDirPath(), table.id + '.json');
1034
1184
  const fileData = Table2D.readFromFile(filePath);
1035
1185
  if (fileData) {
1036
- const reloaded = CorrectionTable.fromJSON(fileData, options.stability || 7);
1186
+ const reloaded = CorrectionTable.fromJSON(fileData, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
1037
1187
  reloaded.setDisplayAttributes({ label: reloaded.id });
1038
1188
  if (!isTableEmpty(reloaded)) {
1039
1189
  swapTable(reloaded);
@@ -1042,8 +1192,8 @@ module.exports = function (app) {
1042
1192
  }
1043
1193
  }
1044
1194
  }
1045
- // All other keys (sogFallback, estimateBoatSpeed, assumeCurrent,
1046
- // stability, smootherClass, etc.) are read
1195
+ // All other keys (sogFallback, estimateBoatSpeed, correctionDriftRate,
1196
+ // and smootherTimeSpan) are read
1047
1197
  // directly from options.* so no extra action needed.
1048
1198
  if (key === 'estimateBoatSpeed' && !value && correctedBoatSpeed) {
1049
1199
  Polar.clear(app, plugin.id, [correctedBoatSpeed]);
@@ -1053,10 +1203,13 @@ module.exports = function (app) {
1053
1203
  delete changedOptions[key];
1054
1204
  }
1055
1205
 
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))) {
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);
1060
1213
  const { SmootherClass: SC, smootherOptions: so } = resolveSmootherConfig();
1061
1214
  for (const s of [smoothedHeading, smoothedBoatSpeed, smoothedGroundSpeed]) {
1062
1215
  if (s) { s.setSmootherClass(SC); s.setSmootherOptions(so); }
@@ -1072,13 +1225,18 @@ module.exports = function (app) {
1072
1225
  module.exports._test = {
1073
1226
  normalizeNavigationState,
1074
1227
  getShortStabilizingMs,
1228
+ getSmoothingWindowSeconds,
1229
+ getLearningIntervalMs,
1230
+ correctionDriftRateToProcessNoiseRate,
1231
+ legacyStabilityToCorrectionDriftRate,
1232
+ migrateCorrectionTableData,
1233
+ isVesselMoving,
1075
1234
  evaluateLearningMode,
1076
1235
  isCogOverrideActive,
1077
1236
  isLeewayValid,
1078
1237
  evaluateObservationGate,
1079
1238
  getDerivedObservationStatus,
1080
1239
  buildNavigationStateStatus,
1081
- ALWAYS_BLOCKING_NAVIGATION_STATES,
1082
- OPTIONAL_BLOCKING_NAVIGATION_STATES,
1240
+ NOT_MOVING_NAVIGATION_STATES,
1083
1241
  LONG_STABILIZING_MS,
1084
1242
  };