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 +28 -19
- package/correctionTable.js +35 -35
- package/docs/STW_estimation.png +0 -0
- package/docs/correctionTable.png +0 -0
- package/docs/learning.png +0 -0
- package/index.js +92 -42
- package/package.json +16 -8
- package/public/app.js +843 -900
- package/public/index.html +1 -6
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
|
|
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
|
-
|
|
239
|
+
> wᵢ = 1 / (dᵢ + ε)
|
|
231
240
|
|
|
232
|
-
where
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
255
|
+
> observation = R(ψ)⁻¹ · V_SOG − R(ψ)⁻¹ · V_current − V_STW
|
|
247
256
|
|
|
248
|
-
where
|
|
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
|
-
|
|
253
|
-
|
|
261
|
+
> K = P · (P + R_obs)⁻¹
|
|
262
|
+
> x_new = x_old + K · (observation − x_old)
|
|
254
263
|
|
|
255
|
-
where
|
|
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
|
-
|
|
270
|
+
> Q = 10^(−stability)
|
|
262
271
|
|
|
263
|
-
With stability = 7 (default),
|
|
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
|
-
|
|
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
|
|
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
|
|
package/correctionTable.js
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
132
|
+
if (totalWeight > 0) {
|
|
133
|
+
for (const neighbour of this.neighbours) {
|
|
134
|
+
neighbour.normWeight /= totalWeight;
|
|
135
|
+
}
|
|
119
136
|
}
|
|
120
137
|
|
|
121
|
-
|
|
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 /=
|
|
128
|
-
varY /=
|
|
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
|
|
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] *
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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 =
|
|
256
|
-
var currentCov =
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 =
|
|
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 =
|
|
497
|
-
if (now - lastSave >
|
|
498
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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,
|
|
702
|
-
//
|
|
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": "
|
|
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": "
|
|
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": ">=
|
|
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": "^
|
|
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
|
}
|