speedandcurrent 1.7.10 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,20 @@ The plugin requires no further configuration in the Signal K admin UI. All setti
29
29
 
30
30
  ---
31
31
 
32
+ ## Configuring Signal K source priorities
33
+
34
+ Signal K Server manages source priorities natively. When multiple devices publish the same path, the server delivers the highest-priority source to all subscribers — including this plugin. No per-path source selection is needed inside the plugin itself.
35
+
36
+ When **Estimate Boat Speed** is enabled the plugin publishes corrected `navigation.speedThroughWater` alongside the raw paddle wheel value. Signal K will deliver whichever source has the higher priority. To ensure the rest of your instrument system sees the *corrected* value:
37
+
38
+ 1. Open the Signal K Server admin UI and navigate to **Server → Data Browser** (or the **Sources** page, depending on your server version).
39
+ 2. Locate `navigation.speedThroughWater` and find the source published by this plugin (it is identified as `SpeedAndCurrent`).
40
+ 3. Set that source to a higher priority than the raw paddle wheel source.
41
+
42
+ Once configured, all consumers on the Signal K bus — KIP, OpenCPN, Instrument displays — receive the corrected value automatically without any further configuration.
43
+
44
+ ---
45
+
32
46
  ## Required Signal K paths
33
47
 
34
48
  | Path | Role |
@@ -84,10 +98,6 @@ The **Inputs** section shows live readings from the raw sensors as they arrive f
84
98
  | Ground speed | `navigation.speedOverGround` + `navigation.courseOverGroundTrue` |
85
99
  | Attitude (heel) | `navigation.attitude` (roll) |
86
100
 
87
- ### Source selection
88
-
89
- For each path you can pin the plugin to a specific Signal K source. This is useful when multiple devices publish the same path — for example two GPS receivers, or a fluxgate and a satellite compass both publishing `navigation.headingTrue`. Leave a source blank or select **(any)** to accept values from all sources. This is independent of the Signal K server's global source priority settings.
90
-
91
101
  ### Settings
92
102
 
93
103
  | Setting | Default | Description |
@@ -98,7 +108,7 @@ For each path you can pin the plugin to a specific Signal K source. This is usef
98
108
 
99
109
  ## Boatspeed Estimation
100
110
 
101
- The **Boatspeed Estimation** section is enabled with the toggle in the panel header. When off, no corrected output is published; the raw paddle wheel value passes through to the SK bus unchanged (subject to the Prevent Speed Duplication setting).
111
+ The **Boatspeed Estimation** section is enabled with the toggle in the panel header. When off, no corrected output is published; the raw paddle wheel value passes through to the SK bus unchanged.
102
112
 
103
113
  ### What it shows
104
114
 
@@ -120,7 +130,6 @@ The panel is organised into three groups:
120
130
  | Setting | Default | Description |
121
131
  |---------|---------|-------------|
122
132
  | **Estimate Boat Speed** | Off | Master toggle. Apply the correction table and publish corrected STW, leeway, and current. Turn on once the table has reasonable coverage. |
123
- | **Prevent Speed Duplication** | On | Replace the raw sensor value on the SK bus with the corrected value. When off, both raw and corrected exist under `navigation.speedThroughWater` with different source attributes — useful for comparing before and after. |
124
133
  | **Groundspeed Fallback** | Off | When the paddle wheel reads zero and SOG is above a minimum threshold, publish SOG as boatspeed. Primarily intended for clogged or stuck paddle wheels where the instrument is physically present but not turning — giving the rest of the instrument system a usable boat speed until the wheel is cleared. |
125
134
 
126
135
  ---
@@ -227,40 +236,40 @@ When a new paddle wheel sample arrives the plugin:
227
236
 
228
237
  Rather than bi-linear interpolation the plugin uses **inverse-distance weighting** over the five nearest cells in speed/heel space:
229
238
 
230
- $$w_i = \frac{1}{d_i + \epsilon}$$
239
+ > wᵢ = 1 / (dᵢ + ε)
231
240
 
232
- where $d_i$ is the Euclidean distance from the query point to cell centre $i$ in normalised speed/heel space. Only cells that have received at least one observation ($N > 0$) contribute.
241
+ where dᵢ is the Euclidean distance from the query point to cell centre i in normalised speed/heel space. Only cells that have received at least one observation (N > 0) contribute.
233
242
 
234
243
  The final correction is the normalised weighted average:
235
244
 
236
- $$\hat{x} = \frac{\sum_i w_i \cdot x_i}{\sum_i w_i}, \quad \hat{y} = \frac{\sum_i w_i \cdot y_i}{\sum_i w_i}$$
245
+ > x̂ = Σ(wᵢ · xᵢ) / Σwᵢ, ŷ = Σ(wᵢ · yᵢ) / Σwᵢ
237
246
 
238
247
  The variance of each axis is propagated through the same weighting, so cells with low confidence (high covariance) influence the result less. If only one or two cells have data, the correction extrapolates gradually to nearby conditions. As more cells fill in, the correction becomes tighter and more local.
239
248
 
240
249
  ### How the correction table is populated
241
250
 
242
- Each cell is an independent **2-dimensional Kalman filter** tracking the correction vector $[x, y]$ for that speed/heel bin, with a 2×2 covariance matrix expressing confidence in each axis.
251
+ Each cell is an independent **2-dimensional Kalman filter** tracking the correction vector [x, y] for that speed/heel bin, with a 2×2 covariance matrix expressing confidence in each axis.
243
252
 
244
253
  Every time conditions are right — plugin running for >60 seconds, smoothers settled, speed above minimum threshold — the plugin computes an **observation** of what the correction should be:
245
254
 
246
- $$\text{observation} = R(\psi)^{-1} \cdot \vec{V}_{SOG} - R(\psi)^{-1} \cdot \vec{V}_{current} - \vec{V}_{STW}$$
255
+ > observation = R(ψ)⁻¹ · V_SOG − R(ψ)⁻¹ · V_current − V_STW
247
256
 
248
- where $R(\psi)$ rotates from ground frame to boat frame using true heading $\psi$. In plain terms: rotate GPS velocity into the boat frame, subtract the current estimate (also rotated), subtract the raw paddle wheel velocity. The residual is the implied sensor error for the current speed and heel.
257
+ where R(ψ) rotates from ground frame to boat frame using true heading ψ. In plain terms: rotate GPS velocity into the boat frame, subtract the current estimate (also rotated), subtract the raw paddle wheel velocity. The residual is the implied sensor error for the current speed and heel.
249
258
 
250
259
  The Kalman update combines this observation with the cell's existing belief:
251
260
 
252
- $$K = P \cdot (P + R_{obs})^{-1}$$
253
- $$x_{new} = x_{old} + K \cdot (\text{observation} - x_{old})$$
261
+ > K = P · (P + R_obs)⁻¹
262
+ > x_new = x_old + K · (observation − x_old)
254
263
 
255
- where $P$ is the cell's current covariance and $R_{obs}$ is the observation covariance derived from the measurement uncertainty of all contributing signals (SOG variance + current variance + STW variance, rotated appropriately). **Noisy observations produce a smaller gain and move the cell estimate less.**
264
+ where P is the cell's current covariance and R_obs is the observation covariance derived from the measurement uncertainty of all contributing signals (SOG variance + current variance + STW variance, rotated appropriately). **Noisy observations produce a smaller gain and move the cell estimate less.**
256
265
 
257
266
  ### The stability setting in detail
258
267
 
259
268
  Each cell has a small **process noise** that allows it to drift slowly over time, reflecting that a paddle wheel's error can change with fouling, recalibration, or crew weight distribution. Process noise is:
260
269
 
261
- $$Q = 10^{-\text{stability}}$$
270
+ > Q = 10^(−stability)
262
271
 
263
- With stability = 7 (default), $Q = 10^{-7}$, making the cell very resistant to change — it effectively averages hundreds of observations before settling. Stability 4–5 makes the table react faster to recent conditions; stability 10–12 is appropriate for a well-characterised boat that changes rarely.
272
+ With stability = 7 (default), Q = 10⁻⁷, making the cell very resistant to change — it effectively averages hundreds of observations before settling. Stability 4–5 makes the table react faster to recent conditions; stability 10–12 is appropriate for a well-characterised boat that changes rarely.
264
273
 
265
274
  In practical terms: high stability = trust the accumulated history; low stability = trust recent observations more.
266
275
 
@@ -268,11 +277,11 @@ In practical terms: high stability = trust the accumulated history; low stabilit
268
277
 
269
278
  Current is estimated as:
270
279
 
271
- $$\vec{V}_{current} = \vec{V}_{SOG} - R(\psi) \cdot \vec{V}_{STW, corrected}$$
280
+ > V_current = V_SOG − R(ψ) · V_STW_corrected
272
281
 
273
282
  The corrected STW vector is rotated into the ground frame using heading, then subtracted from the GPS velocity. The residual is the water velocity.
274
283
 
275
- This raw estimate is fed into a **Kalman smoother with very low process noise** (process variance $\approx 10^{-6}$), so it changes very slowly, integrating over many minutes rather than chasing individual GPS fluctuations. At startup the estimate is strongly initialised to zero.
284
+ This raw estimate is fed into a **Kalman smoother with very low process noise** (process variance ≈ 10⁻⁶), so it changes very slowly, integrating over many minutes rather than chasing individual GPS fluctuations. At startup the estimate is strongly initialised to zero.
276
285
 
277
286
  Current estimation requires an accurate boat speed, so it is gated by the 60-second stabilisation period and only runs when **Estimate Boat Speed** is enabled.
278
287
 
@@ -1,6 +1,21 @@
1
1
  const { Table2D} = require('signalkutilities');
2
2
  const { KalmanFilter, State } = require('kalman-filter');
3
3
 
4
+ // Module-level helpers — avoids creating new Function objects on every Kalman update call
5
+ function _rotateValue(cos, sin, vector) {
6
+ return [
7
+ cos * vector[0] + sin * vector[1],
8
+ -sin * vector[0] + cos * vector[1]
9
+ ];
10
+ }
11
+
12
+ function _rotateVariance(cos, sin, vector) {
13
+ return [
14
+ [vector[0] * cos ** 2 + vector[1] * sin ** 2, (vector[0] - vector[1]) * cos * sin],
15
+ [(vector[0] - vector[1]) * cos * sin, vector[0] * sin ** 2 + vector[1] * cos ** 2]
16
+ ];
17
+ }
18
+
4
19
  class CorrectionTable extends Table2D{
5
20
  /**
6
21
  * Represents a 2D correction table for heel and speed.
@@ -114,18 +129,21 @@ class CorrectionTable extends Table2D{
114
129
  totalWeight += weight;
115
130
  }
116
131
  }
117
- for (const neighbour of this.neighbours) {
118
- if (totalWeight > 0) neighbour.normWeight /= totalWeight;
132
+ if (totalWeight > 0) {
133
+ for (const neighbour of this.neighbours) {
134
+ neighbour.normWeight /= totalWeight;
135
+ }
119
136
  }
120
137
 
121
- const corrAndVar = { correction: { x, y }, variance: { x: varX, y: varY } };
122
- if (totalWeight === 0) return corrAndVar;
138
+ if (totalWeight === 0) return { correction: { x: 0, y: 0 }, variance: { x: 0, y: 0 } };
123
139
 
124
140
  // Compute the final correction and variance
141
+ // Variance of a weighted mean: Σ(w²·var_i) / (Σw)²
142
+ const tw2 = totalWeight * totalWeight;
125
143
  x /= totalWeight;
126
144
  y /= totalWeight;
127
- varX /= totalWeight;
128
- varY /= totalWeight;
145
+ varX /= tw2;
146
+ varY /= tw2;
129
147
  this.totalWeight = totalWeight;
130
148
 
131
149
  return { correction: { x, y }, variance: { x: varX, y: varY } };
@@ -136,11 +154,11 @@ class CorrectionTable extends Table2D{
136
154
  id: this.id,
137
155
  row: { min: this.min[0], max: this.max[0], step: this.step[0] },
138
156
  col: { min: this.min[1], max: this.max[1], step: this.step[1] },
139
- table: this.table.map(row =>
140
- row.map((correction, colIndex, rowArray) => {
157
+ table: this.table.map((row, rowIndex) =>
158
+ row.map((correction, colIndex) => {
141
159
  const cellReport = correction.report();
142
160
  // Derive the bin coordinates from indices
143
- const speedBin = this.min[0] + this.step[0] * this.table.indexOf(rowArray); // row axis represents speed
161
+ const speedBin = this.min[0] + this.step[0] * rowIndex; // row axis represents speed
144
162
  const heelBin = this.min[1] + this.step[1] * colIndex; // col axis represents heel
145
163
  // Compute forward speed after longitudinal correction
146
164
  const forward = speedBin + cellReport.x;
@@ -151,14 +169,10 @@ class CorrectionTable extends Table2D{
151
169
  // Trace ( cov_xx + cov_yy ) when covariance available and N>0
152
170
  let trace = null;
153
171
  if (cellReport.N > 0) {
154
- try {
155
- const cov = correction.covariance;
156
- if (cov && Array.isArray(cov) && cov[0] && cov[1] && Number.isFinite(cov[0][0]) && Number.isFinite(cov[1][1])) {
157
- const a = cov[0][0];
158
- const d = cov[1][1];
159
- trace = a + d;
160
- }
161
- } catch { /* silent */ }
172
+ const cov = correction.covariance;
173
+ if (cov && Array.isArray(cov) && cov[0] && cov[1] && Number.isFinite(cov[0][0]) && Number.isFinite(cov[1][1])) {
174
+ trace = cov[0][0] + cov[1][1];
175
+ }
162
176
  }
163
177
  cellReport.forward = forward;
164
178
  cellReport.factor = factor;
@@ -229,22 +243,8 @@ class CorrectionEstimator {
229
243
  const cosTheta = Math.cos(heading);
230
244
  const sinTheta = Math.sin(heading);
231
245
 
232
- const rotateValue = (vector) => ([
233
- cosTheta * vector[0] + sinTheta * vector[1],
234
- -sinTheta * vector[0] + cosTheta * vector[1]]
235
- );
236
-
237
- const rotateVariance = (vector) => (
238
- [
239
- [vector[0] * cosTheta ** 2 + vector[1] * sinTheta ** 2,
240
- (vector[0] - vector[1]) * cosTheta * sinTheta],
241
- [(vector[0] - vector[1]) * cosTheta * sinTheta,
242
- vector[0] * sinTheta ** 2 + vector[1] * cosTheta ** 2]
243
- ]
244
- );
245
-
246
- var groundVector = rotateValue(groundSpeed.vector);
247
- var currentVector = rotateValue(current.vector);
246
+ var groundVector = _rotateValue(cosTheta, sinTheta, groundSpeed.vector);
247
+ var currentVector = _rotateValue(cosTheta, sinTheta, current.vector);
248
248
  var boatVector = boatSpeed.vector;
249
249
 
250
250
  const observation = [
@@ -252,8 +252,8 @@ class CorrectionEstimator {
252
252
  -boatVector[1] + groundVector[1] - currentVector[1]
253
253
  ];
254
254
 
255
- var groundCov = rotateVariance(groundSpeed.variance);
256
- var currentCov = rotateVariance(current.variance);
255
+ var groundCov = _rotateVariance(cosTheta, sinTheta, groundSpeed.variance);
256
+ var currentCov = _rotateVariance(cosTheta, sinTheta, current.variance);
257
257
  var boatCov = [[boatSpeed.xVariance, 0], [0, boatSpeed.yVariance]];
258
258
 
259
259
  const observationCovariance = [[
Binary file
Binary file
Binary file
package/index.js CHANGED
@@ -25,18 +25,15 @@ module.exports = function (app) {
25
25
 
26
26
  let options = {};
27
27
  let changedOptions = {};
28
+ let hasPendingChanges = false;
28
29
  const defaultOptions = {
29
30
  sogFallback: true,
30
31
  estimateBoatSpeed: false,
31
32
  updateCorrectionTable: true,
32
33
  stability: 7,
33
34
  assumeCurrent: false,
34
- headingSource: ' ',
35
- boatSpeedSource: ' ',
36
- SOGSource: ' ',
37
- attitudeSource: ' ',
38
- preventDuplication: true,
39
35
  tableName: 'correctionTable',
36
+ configVersion: 2,
40
37
  smootherClass: 'MovingAverageSmoother',
41
38
  smootherTau: 3,
42
39
  smootherTimeSpan: 5,
@@ -66,9 +63,19 @@ module.exports = function (app) {
66
63
  saveOptions();
67
64
  }
68
65
 
69
- // only allow prevent duplication when correctedBoatSpeed is enabled, otherwise it would prevent any boat speed from being published at all
70
- const preventDuplication = () => {
71
- return options.preventDuplication && options.estimateBoatSpeed;
66
+ /**
67
+ * Strips obsolete source-selection fields from the persisted config and
68
+ * writes it back if anything changed. Called once on every start().
69
+ */
70
+ function migrateConfig() {
71
+ const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication'];
72
+ const hadObsolete = obsoleteKeys.some(k => k in options);
73
+ for (const k of obsoleteKeys) delete options[k];
74
+ if (hadObsolete || (options.configVersion || 0) < 2) {
75
+ options.configVersion = 2;
76
+ saveOptions();
77
+ app.debug('Config migrated to v2: removed obsolete source-selection fields');
78
+ }
72
79
  }
73
80
 
74
81
  /**
@@ -105,6 +112,7 @@ module.exports = function (app) {
105
112
  table = newTable;
106
113
  minSpeed = table.step[0] / 2;
107
114
  if (reportFull) reportFull.setTables([table]);
115
+ lastSave = Date.now(); // explicit table operations already save; defer next periodic save
108
116
  }
109
117
 
110
118
  let isRunning = false;
@@ -131,6 +139,7 @@ module.exports = function (app) {
131
139
  let rawGroundSpeed = null;
132
140
  let started = null;
133
141
  let minSpeed = 0;
142
+ let lastSave = 0;
134
143
 
135
144
  const plugin = {};
136
145
  plugin.id = "SpeedAndCurrent";
@@ -188,6 +197,7 @@ module.exports = function (app) {
188
197
  }
189
198
  }
190
199
  changedOptions = { ...changedOptions, ...body };
200
+ hasPendingChanges = true;
191
201
  res.json({ ...options, ...changedOptions });
192
202
  });
193
203
 
@@ -296,6 +306,7 @@ module.exports = function (app) {
296
306
  setStatus('Starting');
297
307
  app.debug("Starting");
298
308
  readOptions(); // pick up any saves since registerWithRouter ran
309
+ migrateConfig(); // strip obsolete fields from persisted config
299
310
  const tableName = options.tableName || 'correctionTable';
300
311
  const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
301
312
  table = loadTable(options, tableFilePath);
@@ -306,8 +317,6 @@ module.exports = function (app) {
306
317
 
307
318
  // heading
308
319
  smoothedHeading = new SmoothedAngle(app, plugin.id, 'heading', 'navigation.headingTrue', {
309
- source: options.headingSource,
310
- passOn: true,
311
320
  angleRange: '0to2pi',
312
321
  meta: { displayName: 'Heading', plane: 'Ground' },
313
322
  SmootherClass,
@@ -324,8 +333,6 @@ module.exports = function (app) {
324
333
  app, pluginId: plugin.id,
325
334
  id: 'attitude',
326
335
  path: 'navigation.attitude',
327
- source: options.attitudeSource,
328
- passOn: true,
329
336
  subscribe: true,
330
337
  SmootherClass,
331
338
  smootherOptions
@@ -379,8 +386,6 @@ module.exports = function (app) {
379
386
  app, pluginId: plugin.id,
380
387
  id: 'boatSpeed',
381
388
  path: 'navigation.speedThroughWater',
382
- source: options.boatSpeedSource,
383
- passOn: !preventDuplication(),
384
389
  subscribe: true,
385
390
  SmootherClass,
386
391
  smootherOptions
@@ -411,8 +416,6 @@ module.exports = function (app) {
411
416
  id: 'groundSpeed',
412
417
  pathMagnitude: 'navigation.speedOverGround',
413
418
  pathAngle: 'navigation.courseOverGroundTrue',
414
- source: options.SOGSource,
415
- passOn: true,
416
419
  angleRange: '0to2pi',
417
420
  meta: { displayName: 'Groundspeed', plane: 'Ground' },
418
421
  SmootherClass,
@@ -476,11 +479,11 @@ module.exports = function (app) {
476
479
  }
477
480
 
478
481
  isRunning = true;
479
- started = new Date();
482
+ started = Date.now();
483
+ lastSave = 0;
480
484
  setStatus('Running');
481
485
  app.debug("Running");
482
486
 
483
- let lastSave = 0;
484
487
  smoothedBoatSpeed.onChange = () => {
485
488
  // Drain any pending option changes before calculating
486
489
  if (Object.keys(changedOptions).length) applyOptionChanges();
@@ -492,10 +495,12 @@ module.exports = function (app) {
492
495
  if (options.updateCorrectionTable) {
493
496
  if (wellUnderway) {
494
497
  updateTable();
495
- // Save correction table periodically
496
- const now = new Date();
497
- if (now - lastSave > 60 * 1000) {
498
- saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
498
+ // Save correction table periodically (skip if table is empty to avoid overwriting good data on disk)
499
+ const now = Date.now();
500
+ if (now - lastSave > 60_000) {
501
+ if (!isTableEmpty(table)) {
502
+ saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
503
+ }
499
504
  lastSave = now;
500
505
  }
501
506
  } else {
@@ -510,6 +515,9 @@ module.exports = function (app) {
510
515
  plugin.stop = () => {
511
516
  return new Promise((resolve, reject) => {
512
517
  try {
518
+ if (table && !isTableEmpty(table)) {
519
+ saveTableSync(table, path.join(app.getDataDirPath(), table.id + '.json'));
520
+ }
513
521
  smoothedHeading = smoothedHeading?.terminate();
514
522
  smoothedAttitude = smoothedAttitude?.terminate();
515
523
  rawCurrent = rawCurrent?.terminate();
@@ -640,11 +648,25 @@ module.exports = function (app) {
640
648
  */
641
649
  function loadTable(options, filePath) {
642
650
  const stability = (options.stability !== undefined) ? options.stability : 6;
643
- const fileData = Table2D.readFromFile(filePath);
651
+ let fileData = Table2D.readFromFile(filePath);
644
652
  let table;
645
653
  if (fileData) {
646
654
  table = CorrectionTable.fromJSON(fileData, stability);
647
655
  app.debug("Correction table loaded: " + (fileData.id || filePath));
656
+ } else if (fs.existsSync(filePath)) {
657
+ // File is present but could not be read — transient lock or parse error. Retry once.
658
+ app.error(`Correction table file exists but could not be read, retrying: ${filePath}`);
659
+ fileData = Table2D.readFromFile(filePath);
660
+ if (fileData) {
661
+ table = CorrectionTable.fromJSON(fileData, stability);
662
+ app.debug("Correction table loaded on retry: " + (fileData.id || filePath));
663
+ } else {
664
+ app.error(`Correction table retry failed — starting with empty table. Disk file preserved: ${filePath}`);
665
+ const name = options.tableName || 'correctionTable';
666
+ const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
667
+ const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
668
+ table = new CorrectionTable(name, row, col, stability);
669
+ }
648
670
  } else {
649
671
  const name = options.tableName || 'correctionTable';
650
672
  const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
@@ -657,7 +679,7 @@ module.exports = function (app) {
657
679
  }
658
680
 
659
681
  /**
660
- * Saves the correction table to disk as JSON.
682
+ * Saves the correction table to disk as JSON (async, via library).
661
683
  *
662
684
  * @param {CorrectionTable} correctionTable - The correction table instance to save.
663
685
  * @param {string} filePath - Absolute path of the target JSON file.
@@ -666,10 +688,31 @@ module.exports = function (app) {
666
688
  correctionTable.saveToFile(filePath);
667
689
  }
668
690
 
691
+ /**
692
+ * Saves the correction table synchronously. Used in stop() and when learning
693
+ * is toggled off, to ensure the write completes before the process can exit
694
+ * or the plugin be restarted.
695
+ */
696
+ function saveTableSync(correctionTable, filePath) {
697
+ try {
698
+ const data = JSON.stringify(correctionTable.toJSON(), null, 2);
699
+ fs.writeFileSync(filePath, data);
700
+ } catch (err) {
701
+ app.error(`Error saving correction table: ${err.message}`);
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Returns true if every cell in the table has N === 0 (no learned data).
707
+ * Used to guard against overwriting a good on-disk file with an empty table.
708
+ */
709
+ function isTableEmpty(correctionTable) {
710
+ return correctionTable.table.every(row => row.every(cell => cell.N === 0));
711
+ }
712
+
669
713
  /**
670
714
  * Drains changedOptions into options and hot-applies each change where possible.
671
- * Source changes are applied in-place on existing handlers; flag changes take
672
- * effect immediately since onChange reads from options.* directly.
715
+ * Flag changes take effect immediately since onChange reads from options.* directly.
673
716
  */
674
717
  function applyOptionChanges() {
675
718
  const changedKeys = Object.keys(changedOptions);
@@ -677,29 +720,35 @@ module.exports = function (app) {
677
720
  const value = changedOptions[key];
678
721
  options[key] = value;
679
722
 
680
- // Hot-apply source changes by mutating handler references in-place
681
- if (smoothedHeading && key === 'headingSource') {
682
- smoothedHeading.handler.source = value;
683
- } else if (smoothedAttitude && key === 'attitudeSource') {
684
- smoothedAttitude.handler.source = value;
685
- } else if (smoothedBoatSpeed && key === 'boatSpeedSource') {
686
- smoothedBoatSpeed.handler.source = value;
687
- } else if (smoothedGroundSpeed && key === 'SOGSource') {
688
- smoothedGroundSpeed.polar.magnitudeHandler.source = value;
689
- } else if (key === 'preventDuplication') {
690
- if (smoothedBoatSpeed) smoothedBoatSpeed.handler.passOn = !preventDuplication();
691
- } else if (key === 'estimateBoatSpeed' ) {
692
- if (smoothedBoatSpeed) smoothedBoatSpeed.handler.passOn = !preventDuplication();
693
- } else if (key === 'stalenessDetection') {
723
+ if (key === 'stalenessDetection') {
694
724
  const sdVal = Boolean(value);
695
725
  for (const s of [smoothedHeading, smoothedAttitude, smoothedBoatSpeed, smoothedGroundSpeed, smoothedCurrent, smoothedResidual]) {
696
726
  if (s) s.stalenessDetection = sdVal;
697
727
  }
698
728
  // noCurrent is always excluded from staleness detection
729
+ } else if (key === 'updateCorrectionTable') {
730
+ if (!value && table && !isTableEmpty(table)) {
731
+ // Learning switched off — persist current state before periodic saves stop
732
+ saveTableSync(table, path.join(app.getDataDirPath(), table.id + '.json'));
733
+ app.debug('Correction table saved: learning switched off');
734
+ } else if (value && table && isTableEmpty(table)) {
735
+ // Learning switched on and in-memory table is empty — reload from disk
736
+ // to recover from a failed load at startup
737
+ const filePath = path.join(app.getDataDirPath(), table.id + '.json');
738
+ const fileData = Table2D.readFromFile(filePath);
739
+ if (fileData) {
740
+ const reloaded = CorrectionTable.fromJSON(fileData, options.stability || 7);
741
+ reloaded.setDisplayAttributes({ label: reloaded.id });
742
+ if (!isTableEmpty(reloaded)) {
743
+ swapTable(reloaded);
744
+ app.debug('Correction table reloaded from disk: learning switched on (was empty in memory)');
745
+ }
746
+ }
747
+ }
699
748
  }
700
749
 
701
- // All other keys (sogFallback, estimateBoatSpeed, updateCorrectionTable,
702
- // assumeCurrent, stability, startWithNewTable, COGSource) are read
750
+ // All other keys (sogFallback, estimateBoatSpeed, assumeCurrent,
751
+ // stability, smootherClass, etc.) are read
703
752
  // directly from options.* so no extra action needed.
704
753
 
705
754
  delete changedOptions[key];
@@ -716,6 +765,7 @@ module.exports = function (app) {
716
765
  if (smoothedAttitude) { smoothedAttitude.setSmootherClass(SC); smoothedAttitude.setSmootherOptions(so); }
717
766
  }
718
767
 
768
+ hasPendingChanges = false;
719
769
  saveOptions();
720
770
  }
721
771
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "speedandcurrent",
3
- "version": "1.7.10",
3
+ "version": "2.0.2",
4
4
  "description": "A plugin that automatically corrects boat speed and calculates current and leeway using sensor fusion.",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
7
+ "test": "node --test test/plugin.test.js"
8
8
  },
9
9
  "author": "aswin.bouwmeester@gmail.com",
10
10
  "license": "MIT",
@@ -17,25 +17,33 @@
17
17
  },
18
18
  "homepage": "https://github.com/Asw1n/speedandcurrent#readme",
19
19
  "engines": {
20
- "node": ">=18.0.0"
20
+ "node": ">=22.0.0",
21
+ "signalk-server": ">=2.28.0"
21
22
  },
22
23
  "keywords": [
23
24
  "signalk-webapp",
24
25
  "signalk-node-server-plugin",
25
- "signalk-category-utility"
26
+ "signalk-category-utility",
27
+ "signalk-category-instruments"
26
28
  ],
27
29
  "signalk-plugin-enabled-by-default": false,
28
30
  "signalk": {
29
- "appIcon": "icon.png",
30
- "displayName": "Speed and current"
31
+ "appIcon": "./icon.png",
32
+ "displayName": "Speed and current",
33
+ "screenshots": [
34
+ "./docs/correctionTable.png",
35
+ "./docs/STW_estimation.png",
36
+ "./docs/learning.png"
37
+ ]
31
38
  },
32
39
  "dependencies": {
33
40
  "kalman-filter": "^2.3.0",
34
- "signalkutilities": "^1.12.0"
41
+ "signalkutilities": "^2.0.1"
35
42
  },
36
43
  "files": [
37
44
  "index.js",
38
45
  "correctionTable.js",
39
- "public/"
46
+ "public/",
47
+ "docs/"
40
48
  ]
41
49
  }