speedandcurrent 1.7.3 → 1.7.6
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 +11 -12
- package/index.js +117 -103
- package/package.json +2 -2
- package/public/app.js +49 -16
- package/public/index.html +2 -2
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ After the initial ~60 second stabilization period the plugin starts sending the
|
|
|
32
32
|
| Water current set (direction) | self.environment.current.setTrue | rad | True direction (set) the current flows toward. |
|
|
33
33
|
|
|
34
34
|
Boat speed overwrite / duplication behavior:
|
|
35
|
-
- When the setting "Prevent duplication
|
|
35
|
+
- When the setting "Prevent speed duplication" = true (default): plugin overwrites navigation.speedThroughWater with corrected value.
|
|
36
36
|
- When false: both raw and corrected updates exist under navigation.speedThroughWater with distinct source attributes (viewable in deltas / data browser) allowing comparison.
|
|
37
37
|
|
|
38
38
|
Current vector representation: Published as polar (drift + set). Set angle is the direction the water moves toward (oceanographic convention). Verify tooling expectations if it assumes FROM direction.
|
|
@@ -60,7 +60,7 @@ All settings are managed from the plugin webapp. The Signal K admin UI is not us
|
|
|
60
60
|
| Show Statistics | Off | Displays σ (standard deviation) next to smoothed values. | Enable for debugging or to inspect signal quality. |
|
|
61
61
|
|
|
62
62
|
### Smoother
|
|
63
|
-
Controls how raw sensor signals are smoothed before being fed into the
|
|
63
|
+
Controls how raw sensor signals are smoothed before being fed into the learning calculations. The smoother type and its parameter apply to heading, boat speed, ground speed, and attitude.
|
|
64
64
|
|
|
65
65
|
| Setting | Default | Description |
|
|
66
66
|
|---------|---------|-------------|
|
|
@@ -93,8 +93,8 @@ Leave a source blank or set to *(any)* to accept values from all sources. If mul
|
|
|
93
93
|
### Output Behavior
|
|
94
94
|
| Setting | Default | Description | Notes |
|
|
95
95
|
|---------|---------|-------------|-------|
|
|
96
|
-
| Prevent duplication
|
|
97
|
-
|
|
|
96
|
+
| Prevent speed duplication | On | Overwrites navigation.speedThroughWater with corrected value. | Disable to compare raw vs corrected via distinct source attributes under the same path. |
|
|
97
|
+
| Groundspeed fallback | Off | Copy navigation.speedOverGround (GPS) to navigation.speedThroughWater (Paddle wheel) if paddle wheel malfunction is detected. | Activates when navigation.speedThroughWater = 0 and navigation.speedOverGround > threshold. |
|
|
98
98
|
|
|
99
99
|
### Tips
|
|
100
100
|
1. Be patient with the correction table. Its corrections will improve over time.
|
|
@@ -116,7 +116,7 @@ The "Assume Current" option is experimental. It works best on a stable correctio
|
|
|
116
116
|
## WebApp Usage Guide
|
|
117
117
|
Open via Signal K Server: Apps → Speed and current.
|
|
118
118
|
|
|
119
|
-
The webapp has a collapsible sidebar with four sections, and a status message in the top-right of the nav bar (blue = stabilizing, amber = SOG fallback active, red = error or stopped).
|
|
119
|
+
The webapp has a collapsible sidebar with four sections, and a status message in the top-right of the nav bar (blue = stabilizing, amber = SOG fallback active, red = error or stopped). All settings changes take effect immediately — no plugin restart required.
|
|
120
120
|
|
|
121
121
|
### Inputs
|
|
122
122
|
The **Inputs** section contains two sub-sections:
|
|
@@ -125,17 +125,16 @@ The **Inputs** section contains two sub-sections:
|
|
|
125
125
|
|
|
126
126
|
### Boatspeed Estimation
|
|
127
127
|
The **Boatspeed Estimation** section has an On/Off toggle at the top of the card and contains:
|
|
128
|
-
- **Settings**: `
|
|
128
|
+
- **Settings**: `Groundspeed fallback` and `Prevent speed duplication`.
|
|
129
129
|
- **Inputs**: The raw signal values used for correction (heading, raw STW, SOG, attitude).
|
|
130
|
-
- **Intermediates**: Internally derived quantities
|
|
131
|
-
- **Outputs**: Corrected
|
|
130
|
+
- **Intermediates**: Internally derived quantities including the speed correction vector, boat speed over ground, and residual.
|
|
131
|
+
- **Outputs**: Corrected boatspeed & leeway, and estimated current.
|
|
132
132
|
|
|
133
133
|
### Correction Table Learning
|
|
134
134
|
The **Correction Table Learning** section has an On/Off toggle and contains:
|
|
135
135
|
- **Settings**: Stability, Assume Current, and Show Statistics (σ display toggle).
|
|
136
136
|
- **Smoother**: Type selector (Moving average / Exponential / Kalman) and its single tuning parameter. Only the parameter relevant to the selected smoother is shown.
|
|
137
137
|
- **Inputs**: Smoothed values fed into the learning algorithm. When Show Statistics is on, σ values are shown.
|
|
138
|
-
- **Intermediates**: Derived observations used to update correction table cells.
|
|
139
138
|
|
|
140
139
|
### Correction Table
|
|
141
140
|
The **Correction Table** section shows the current correction table grid. Table management buttons appear top-right:
|
|
@@ -146,11 +145,11 @@ The **Correction Table** section shows the current correction table grid. Table
|
|
|
146
145
|
|
|
147
146
|
Each populated cell shows:
|
|
148
147
|
- **Factor** (±%): how much the paddle wheel over- or under-reads at this speed and heel (green = reads slow, orange = reads fast).
|
|
149
|
-
- **Leeway** (°): the estimated sideways drift angle
|
|
148
|
+
- **Leeway** (°): the estimated sideways drift angle; also encoded visually as the stripe direction in the cell background.
|
|
150
149
|
|
|
151
150
|
Cell highlighting:
|
|
152
|
-
- **
|
|
153
|
-
- **
|
|
151
|
+
- **Blue outline**: the most recently updated table cell.
|
|
152
|
+
- **Dashed outline**: cells contributing to the current interpolation.
|
|
154
153
|
- **No content**: not yet populated by learning.
|
|
155
154
|
|
|
156
155
|
### Correction Table Health Checklist
|
package/index.js
CHANGED
|
@@ -33,7 +33,6 @@ module.exports = function (app) {
|
|
|
33
33
|
assumeCurrent: false,
|
|
34
34
|
headingSource: ' ',
|
|
35
35
|
boatSpeedSource: ' ',
|
|
36
|
-
COGSource: ' ',
|
|
37
36
|
SOGSource: ' ',
|
|
38
37
|
attitudeSource: ' ',
|
|
39
38
|
preventDuplication: true,
|
|
@@ -45,8 +44,7 @@ module.exports = function (app) {
|
|
|
45
44
|
showStatistics: false
|
|
46
45
|
};
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
|
|
50
48
|
|
|
51
49
|
function readOptions() {
|
|
52
50
|
const stored = app.readPluginOptions();
|
|
@@ -67,6 +65,11 @@ module.exports = function (app) {
|
|
|
67
65
|
saveOptions();
|
|
68
66
|
}
|
|
69
67
|
|
|
68
|
+
// only allow prevent duplication when correctedBoatSpeed is enabled, otherwise it would prevent any boat speed from being published at all
|
|
69
|
+
const preventDuplication = () => {
|
|
70
|
+
return options.preventDuplication && options.estimateBoatSpeed;
|
|
71
|
+
}
|
|
72
|
+
|
|
70
73
|
/**
|
|
71
74
|
* Derives { SmootherClass, smootherOptions } from the current options.
|
|
72
75
|
* Enforces minimums so the smoother always has at least two observations for
|
|
@@ -99,6 +102,7 @@ module.exports = function (app) {
|
|
|
99
102
|
|
|
100
103
|
function swapTable(newTable) {
|
|
101
104
|
table = newTable;
|
|
105
|
+
minSpeed = table.step[0] / 2;
|
|
102
106
|
if (reportFull) reportFull.setTables([table]);
|
|
103
107
|
}
|
|
104
108
|
|
|
@@ -111,12 +115,13 @@ module.exports = function (app) {
|
|
|
111
115
|
let smoothedCurrent = null;
|
|
112
116
|
let smoothedBoatSpeed = null;
|
|
113
117
|
let correctedBoatSpeed = null;
|
|
118
|
+
let lrnBoatSpeed = null;
|
|
114
119
|
let boatSpeedRefGround = null;
|
|
115
120
|
let smoothedGroundSpeed = null;
|
|
116
121
|
let speedCorrection = null;
|
|
117
122
|
let residual = null;
|
|
123
|
+
let smoothedResidual = null;
|
|
118
124
|
let reportFull = null;
|
|
119
|
-
let reportVector = null;
|
|
120
125
|
let table = null;
|
|
121
126
|
|
|
122
127
|
// add missing declarations
|
|
@@ -126,6 +131,7 @@ module.exports = function (app) {
|
|
|
126
131
|
let rawBoatSpeed = null;
|
|
127
132
|
let rawGroundSpeed = null;
|
|
128
133
|
let started = null;
|
|
134
|
+
let minSpeed = 0;
|
|
129
135
|
|
|
130
136
|
const plugin = {};
|
|
131
137
|
plugin.id = "SpeedAndCurrent";
|
|
@@ -159,13 +165,7 @@ module.exports = function (app) {
|
|
|
159
165
|
}
|
|
160
166
|
});
|
|
161
167
|
|
|
162
|
-
|
|
163
|
-
if (!isRunning) {
|
|
164
|
-
res.status(503).json({ error: "Plugin is not running" });
|
|
165
|
-
} else {
|
|
166
|
-
res.json(reportVector.report());
|
|
167
|
-
}
|
|
168
|
-
});
|
|
168
|
+
|
|
169
169
|
|
|
170
170
|
router.get('/api/status', (req, res) => {
|
|
171
171
|
res.json({ status: pluginStatus, isRunning });
|
|
@@ -300,6 +300,7 @@ module.exports = function (app) {
|
|
|
300
300
|
const tableName = options.tableName || 'correctionTable';
|
|
301
301
|
const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
|
|
302
302
|
table = loadTable(options, tableFilePath);
|
|
303
|
+
minSpeed = table.step[0] / 2;
|
|
303
304
|
|
|
304
305
|
//#region Handler and Polar Initialization
|
|
305
306
|
const { SmootherClass, smootherOptions } = resolveSmootherConfig();
|
|
@@ -369,21 +370,34 @@ module.exports = function (app) {
|
|
|
369
370
|
}
|
|
370
371
|
});
|
|
371
372
|
|
|
373
|
+
// boatspeed
|
|
374
|
+
smoothedBoatSpeed = createSmoothedHandler({
|
|
375
|
+
app, pluginId: plugin.id,
|
|
376
|
+
id: 'boatSpeed',
|
|
377
|
+
path: 'navigation.speedThroughWater',
|
|
378
|
+
source: options.boatSpeedSource,
|
|
379
|
+
passOn: !preventDuplication(),
|
|
380
|
+
subscribe: true,
|
|
381
|
+
SmootherClass,
|
|
382
|
+
smootherOptions
|
|
383
|
+
});
|
|
384
|
+
rawBoatSpeed = smoothedBoatSpeed.handler;
|
|
385
|
+
|
|
386
|
+
// Learning polar — used for updating the correction table
|
|
387
|
+
lrnBoatSpeed = new Polar(app, plugin.id, "lrnBoatSpeed");
|
|
388
|
+
lrnBoatSpeed.configureMagnitude("navigation.speedThroughWater");
|
|
389
|
+
lrnBoatSpeed.configureAngle("navigation.leewayAngle");
|
|
390
|
+
lrnBoatSpeed.setMeta({ displayName: "Learning boat speed", plane: "Boat" });
|
|
391
|
+
lrnBoatSpeed.setAngleRange('-piToPi');
|
|
372
392
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
rawBoatSpeedPolar.configureMagnitude('navigation.speedThroughWater', options.boatSpeedSource, !options.preventDuplication);
|
|
376
|
-
rawBoatSpeedPolar.configureAngle('navigation.leewayAngle', options.boatSpeedSource, false);
|
|
377
|
-
rawBoatSpeedPolar.setMeta({ displayName: 'Boat speed', plane: 'Boat' });
|
|
378
|
-
rawBoatSpeedPolar.setAngleRange('-piToPi');
|
|
379
|
-
rawBoatSpeedPolar.subscribe(true, false);
|
|
380
|
-
smoothedBoatSpeed = new PolarSmoother(rawBoatSpeedPolar, SmootherClass, smootherOptions);
|
|
381
|
-
rawBoatSpeedPolar.onChange = () => { smoothedBoatSpeed.sample(); };
|
|
382
|
-
rawBoatSpeed = rawBoatSpeedPolar;
|
|
393
|
+
|
|
394
|
+
// corrected boatspeed holds both corrected boatspeed and estimated leeway
|
|
383
395
|
correctedBoatSpeed = new Polar(app, plugin.id, "correctedBoatSpeed");
|
|
384
396
|
correctedBoatSpeed.configureMagnitude("navigation.speedThroughWater");
|
|
385
397
|
correctedBoatSpeed.configureAngle("navigation.leewayAngle");
|
|
386
398
|
correctedBoatSpeed.setMeta({ displayName: "Corrected boatspeed / Leeway", plane: "Boat" });
|
|
399
|
+
|
|
400
|
+
// boatspeed vector in ground frame, used for current estimation and residual calculation
|
|
387
401
|
boatSpeedRefGround = new Polar(app, plugin.id, "boatSpeedRefGround");
|
|
388
402
|
boatSpeedRefGround.setMeta({ displayName: "Boat speed over ground", plane: "Ground" });
|
|
389
403
|
|
|
@@ -409,6 +423,10 @@ module.exports = function (app) {
|
|
|
409
423
|
// residual
|
|
410
424
|
residual = new Polar(app, plugin.id, "residual");
|
|
411
425
|
residual.setMeta({ displayName: "Residual", plane: "Ground" });
|
|
426
|
+
smoothedResidual = new PolarSmoother(residual, ExponentialSmoother, { tau: 30, timeSpan: 30 }); // id auto-derived: 'residual.smoothed'
|
|
427
|
+
smoothedResidual.setAngleRange('0to2pi');
|
|
428
|
+
// smoothedResidual.xSmoother.reset(0, 0.00000001);
|
|
429
|
+
// smoothedResidual.ySmoother.reset(0, 0.00000001);
|
|
412
430
|
|
|
413
431
|
//#endregion
|
|
414
432
|
|
|
@@ -418,34 +436,26 @@ module.exports = function (app) {
|
|
|
418
436
|
if (options.estimateBoatSpeed) {
|
|
419
437
|
reportFull.addDelta(rawHeading);
|
|
420
438
|
reportFull.addAttitude(rawAttitude);
|
|
421
|
-
reportFull.
|
|
439
|
+
reportFull.addDelta(rawBoatSpeed);
|
|
422
440
|
reportFull.addPolar(speedCorrection);
|
|
423
441
|
reportFull.addPolar(boatSpeedRefGround);
|
|
424
442
|
reportFull.addPolar(correctedBoatSpeed);
|
|
425
443
|
reportFull.addPolar(rawGroundSpeed);
|
|
426
444
|
reportFull.addPolar(smoothedCurrent);
|
|
445
|
+
reportFull.addPolar(residual);
|
|
446
|
+
reportFull.addPolar(smoothedResidual);
|
|
427
447
|
}
|
|
428
448
|
if (options.updateCorrectionTable) {
|
|
429
449
|
reportFull.addDelta(smoothedHeading);
|
|
430
450
|
reportFull.addAttitude(smoothedAttitude);
|
|
431
|
-
reportFull.
|
|
451
|
+
reportFull.addDelta(smoothedBoatSpeed);
|
|
432
452
|
reportFull.addPolar(smoothedGroundSpeed);
|
|
433
453
|
if (options.assumeCurrent) {
|
|
434
454
|
reportFull.addPolar(smoothedCurrent);
|
|
435
455
|
}
|
|
436
|
-
reportFull.addPolar(residual);
|
|
437
456
|
}
|
|
438
457
|
reportFull.addTable(table);
|
|
439
458
|
|
|
440
|
-
// Make reporting object for webApp
|
|
441
|
-
reportVector = new Reporter();
|
|
442
|
-
reportVector.addDelta(rawHeading);
|
|
443
|
-
reportVector.addPolar(residual);
|
|
444
|
-
reportVector.addPolar(speedCorrection);
|
|
445
|
-
if (options.assumeCurrent) reportVector.addPolar(smoothedCurrent);
|
|
446
|
-
reportVector.addPolar(correctedBoatSpeed);
|
|
447
|
-
reportVector.addPolar(rawBoatSpeed);
|
|
448
|
-
reportVector.addPolar(rawGroundSpeed);
|
|
449
459
|
//#endregion
|
|
450
460
|
|
|
451
461
|
isRunning = true;
|
|
@@ -459,24 +469,14 @@ module.exports = function (app) {
|
|
|
459
469
|
if (Object.keys(changedOptions).length) applyOptionChanges();
|
|
460
470
|
|
|
461
471
|
const wellUnderway = started < new Date() - 60 * 1000;
|
|
462
|
-
const minSpeed = table.step[0] / 2;
|
|
463
472
|
|
|
464
|
-
|
|
465
|
-
setStatus('Falling back to Speed Over Ground');
|
|
466
|
-
app.debug("Falling back");
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
if (!wellUnderway) {
|
|
470
|
-
setStatus('Stabilizing');
|
|
471
|
-
} else {
|
|
472
|
-
setStatus('Running');
|
|
473
|
-
}
|
|
473
|
+
setStatus(wellUnderway ? 'Running' : 'Stabilizing');
|
|
474
474
|
if (options.estimateBoatSpeed) correct(wellUnderway);
|
|
475
475
|
if (options.updateCorrectionTable && wellUnderway) {
|
|
476
|
-
updateTable(
|
|
476
|
+
updateTable();
|
|
477
477
|
// Save correction table periodically
|
|
478
478
|
const now = new Date();
|
|
479
|
-
if (now - lastSave >
|
|
479
|
+
if (now - lastSave > 60 * 1000) {
|
|
480
480
|
saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
|
|
481
481
|
lastSave = now;
|
|
482
482
|
}
|
|
@@ -494,12 +494,13 @@ module.exports = function (app) {
|
|
|
494
494
|
smoothedCurrent = smoothedCurrent?.terminate?.();
|
|
495
495
|
smoothedBoatSpeed = smoothedBoatSpeed?.terminate();
|
|
496
496
|
correctedBoatSpeed = correctedBoatSpeed?.terminate();
|
|
497
|
+
lrnBoatSpeed = lrnBoatSpeed?.terminate();
|
|
497
498
|
boatSpeedRefGround = boatSpeedRefGround?.terminate();
|
|
498
499
|
smoothedGroundSpeed = smoothedGroundSpeed?.terminate();
|
|
499
500
|
speedCorrection = speedCorrection?.terminate();
|
|
500
501
|
residual = residual?.terminate();
|
|
502
|
+
smoothedResidual = smoothedResidual?.terminate?.();
|
|
501
503
|
reportFull = null;
|
|
502
|
-
reportVector = null;
|
|
503
504
|
table = null;
|
|
504
505
|
rawHeading = null;
|
|
505
506
|
rawAttitude = null;
|
|
@@ -519,50 +520,70 @@ module.exports = function (app) {
|
|
|
519
520
|
});
|
|
520
521
|
};
|
|
521
522
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
|
|
527
|
-
Polar.send(app, plugin.id, [correctedBoatSpeed]);
|
|
528
|
-
return true;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* Estimates and applies corrections to boat speed, leeway, and current.
|
|
523
|
+
/**
|
|
524
|
+
* Corrects and publishes boat speed. Handles SOG fallback and missing-input
|
|
525
|
+
* silent fallback so that navigation.speedThroughWater is always written
|
|
526
|
+
* when estimateBoatSpeed is on, as long as any usable speed source exists.
|
|
533
527
|
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
528
|
+
* Priority order:
|
|
529
|
+
* 1. STW zero + sogFallback enabled + SOG available → publish SOG magnitude
|
|
530
|
+
* 2. attitude/heading not ready → publish raw STW unchanged (silent fallback)
|
|
531
|
+
* 3. All inputs ready → apply table correction; estimate current if wellUnderway
|
|
538
532
|
*
|
|
539
|
-
* @param {boolean} wellUnderway -
|
|
533
|
+
* @param {boolean} wellUnderway - Gates current estimation (requires 60 s settling).
|
|
540
534
|
*/
|
|
541
535
|
function correct(wellUnderway) {
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
const heel = rawAttitude.value?.roll;
|
|
545
|
-
const speed = rawBoatSpeed.magnitude;
|
|
546
|
-
const theta = rawHeading.value;
|
|
547
|
-
//app.debug(`Heel: ${SI.toDegrees(heel).toFixed(1)}°, Speed: ${SI.toKnots(speed).toFixed(2)} kn, Heading: ${SI.toDegrees(theta).toFixed(1)}°`);
|
|
536
|
+
// copy the boatspeed delta to polar
|
|
537
|
+
correctedBoatSpeed.setVectorValue({ x: rawBoatSpeed.value, y: 0 });
|
|
548
538
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
539
|
+
speedCorrection.setVectorValue({ x: 0, y: 0 });
|
|
540
|
+
|
|
541
|
+
// Priority 1: SOG fallback when STW is stuck at zero
|
|
542
|
+
// (onChange only fires when STW is ready, so a missing STW cannot reach here)
|
|
543
|
+
if (options.sogFallback && rawGroundSpeed.ready && correctedBoatSpeed.magnitude === 0 && rawGroundSpeed.magnitude >= minSpeed) {
|
|
544
|
+
correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
|
|
555
545
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
546
|
+
else if (rawAttitude.ready) {
|
|
547
|
+
if (correctedBoatSpeed.magnitude > 0) {
|
|
548
|
+
const { correction, variance } = table.getCorrection(correctedBoatSpeed.magnitude, rawAttitude.value?.roll);
|
|
549
|
+
speedCorrection.setVectorValue(correction, variance);
|
|
550
|
+
correctedBoatSpeed.add(speedCorrection);
|
|
551
|
+
}
|
|
552
|
+
// Current estimation and residual also require heading (to rotate into ground frame).
|
|
553
|
+
// Also handle near-zero SOG where COG is unavailable: treat groundspeed as zero vector.
|
|
554
|
+
const sogHandler = rawGroundSpeed.magnitudeHandler;
|
|
555
|
+
const nearZeroGroundSpeed = !rawGroundSpeed.ready &&
|
|
556
|
+
sogHandler.ready &&
|
|
557
|
+
Number.isFinite(sogHandler.value) &&
|
|
558
|
+
sogHandler.value < 0.3;
|
|
559
|
+
if (rawHeading.ready && (rawGroundSpeed.ready || nearZeroGroundSpeed)) {
|
|
560
|
+
boatSpeedRefGround.copyFrom(correctedBoatSpeed);
|
|
561
|
+
boatSpeedRefGround.rotate(rawHeading.value);
|
|
562
|
+
// Current estimation gated by wellUnderway (smoothers need to settle first)
|
|
563
|
+
if (wellUnderway) {
|
|
564
|
+
if (rawGroundSpeed.ready) {
|
|
565
|
+
rawCurrent.copyFrom(rawGroundSpeed);
|
|
566
|
+
} else {
|
|
567
|
+
// COG unavailable but SOG is near-zero: boat is stationary, groundspeed treated as zero
|
|
568
|
+
rawCurrent.setVectorValue({ x: 0, y: 0 });
|
|
569
|
+
}
|
|
570
|
+
rawCurrent.substract(boatSpeedRefGround);
|
|
571
|
+
smoothedCurrent.sample();
|
|
572
|
+
}
|
|
573
|
+
// Residual requires full groundspeed (both SOG and COG)
|
|
574
|
+
if (rawGroundSpeed.ready) {
|
|
575
|
+
residual.copyFrom(rawGroundSpeed);
|
|
576
|
+
residual.substract(boatSpeedRefGround);
|
|
577
|
+
residual.substract(smoothedCurrent);
|
|
578
|
+
smoothedResidual.sample();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
564
581
|
}
|
|
565
|
-
|
|
582
|
+
// Implicit fallback: attitude not ready — correctedBoatSpeed = raw STW, no correction
|
|
583
|
+
|
|
584
|
+
PolarSmoother.send(app, plugin.id, [smoothedCurrent, smoothedResidual]);
|
|
585
|
+
|
|
586
|
+
Polar.send(app, plugin.id, [correctedBoatSpeed]);
|
|
566
587
|
}
|
|
567
588
|
|
|
568
589
|
/**
|
|
@@ -575,25 +596,15 @@ module.exports = function (app) {
|
|
|
575
596
|
* @param {boolean} [assumeCurrent=false] - If true, uses the smoothed current for correction; otherwise, assumes no current.
|
|
576
597
|
* @param {number} [minSpeed=0] - The minimum speed threshold (in SI units) required to update the correction table.
|
|
577
598
|
*/
|
|
578
|
-
function updateTable(
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
// update correction table
|
|
586
|
-
if (speed > minSpeed ) {
|
|
587
|
-
table.update(speed, heel, smoothedGroundSpeed, assumeCurrent ? smoothedCurrent : noCurrent, smoothedBoatSpeed, theta);
|
|
599
|
+
function updateTable() {
|
|
600
|
+
lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 });
|
|
601
|
+
if (!smoothedAttitude.ready || !smoothedBoatSpeed.ready || !smoothedHeading.ready || !smoothedGroundSpeed.ready || (options.assumeCurrent ? !smoothedCurrent.ready : !noCurrent.ready)) return;
|
|
602
|
+
|
|
603
|
+
// update correction table
|
|
604
|
+
if (smoothedBoatSpeed.value > minSpeed) {
|
|
605
|
+
table.update(smoothedBoatSpeed.value, smoothedAttitude.value?.roll, smoothedGroundSpeed, options.assumeCurrent ? smoothedCurrent : noCurrent, lrnBoatSpeed, smoothedHeading.value);
|
|
588
606
|
}
|
|
589
607
|
|
|
590
|
-
// calculate residual
|
|
591
|
-
residual.copyFrom(smoothedGroundSpeed);
|
|
592
|
-
boatSpeedRefGround.copyFrom(correctedBoatSpeed);
|
|
593
|
-
boatSpeedRefGround.rotate(theta);
|
|
594
|
-
residual.substract(boatSpeedRefGround);
|
|
595
|
-
residual.substract(smoothedCurrent);
|
|
596
|
-
|
|
597
608
|
}
|
|
598
609
|
|
|
599
610
|
/**
|
|
@@ -680,12 +691,15 @@ module.exports = function (app) {
|
|
|
680
691
|
} else if (smoothedAttitude && key === 'attitudeSource') {
|
|
681
692
|
smoothedAttitude.handler.source = value;
|
|
682
693
|
} else if (smoothedBoatSpeed && key === 'boatSpeedSource') {
|
|
683
|
-
smoothedBoatSpeed.
|
|
694
|
+
smoothedBoatSpeed.handler.source = value;
|
|
684
695
|
} else if (smoothedGroundSpeed && key === 'SOGSource') {
|
|
685
696
|
smoothedGroundSpeed.polar.magnitudeHandler.source = value;
|
|
686
697
|
} else if (key === 'preventDuplication') {
|
|
687
|
-
if (smoothedBoatSpeed) smoothedBoatSpeed.
|
|
698
|
+
if (smoothedBoatSpeed) smoothedBoatSpeed.handler.passOn = !preventDuplication();
|
|
699
|
+
} else if (key === 'estimateBoatSpeed' ) {
|
|
700
|
+
if (smoothedBoatSpeed) smoothedBoatSpeed.handler.passOn = !preventDuplication();
|
|
688
701
|
}
|
|
702
|
+
|
|
689
703
|
// All other keys (sogFallback, estimateBoatSpeed, updateCorrectionTable,
|
|
690
704
|
// assumeCurrent, stability, startWithNewTable, COGSource) are read
|
|
691
705
|
// directly from options.* so no extra action needed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speedandcurrent",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.6",
|
|
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": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"kalman-filter": "^2.3.0",
|
|
34
|
-
"signalkutilities": "^1.
|
|
34
|
+
"signalkutilities": "^1.11.0"
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"index.js",
|
package/public/app.js
CHANGED
|
@@ -121,6 +121,36 @@ function isStale(item) {
|
|
|
121
121
|
return item.state?.stale === true;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
function isNotReady(item) {
|
|
125
|
+
if (!item) return true;
|
|
126
|
+
return item.state?.ready !== true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getAnyItem(id) {
|
|
130
|
+
return state.polarsById[id] || state.deltasById[id] || state.attitudesById[id] || null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function renderWarnings(elId, ids) {
|
|
134
|
+
const el = document.getElementById(elId);
|
|
135
|
+
if (!el) return;
|
|
136
|
+
el.innerHTML = '';
|
|
137
|
+
const notReady = ids.filter(id => isNotReady(getAnyItem(id)));
|
|
138
|
+
if (notReady.length === 0) return;
|
|
139
|
+
const h = document.createElement('h6');
|
|
140
|
+
h.className = 'text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small';
|
|
141
|
+
h.textContent = 'Warnings';
|
|
142
|
+
el.appendChild(h);
|
|
143
|
+
const ul = document.createElement('ul');
|
|
144
|
+
ul.className = 'list-unstyled text-danger small ps-3';
|
|
145
|
+
notReady.forEach(id => {
|
|
146
|
+
const label = metaById[id]?.displayName ?? id;
|
|
147
|
+
const li = document.createElement('li');
|
|
148
|
+
li.textContent = `"${label}" — not available`;
|
|
149
|
+
ul.appendChild(li);
|
|
150
|
+
});
|
|
151
|
+
el.appendChild(ul);
|
|
152
|
+
}
|
|
153
|
+
|
|
124
154
|
// ─── Config (live settings mirror) ───────────────────────────────────────────
|
|
125
155
|
let config = null;
|
|
126
156
|
|
|
@@ -216,7 +246,7 @@ const paramMeta = {
|
|
|
216
246
|
stability: { label: 'Stability (1–20)', type: 'number', min: 1, max: 20, step: 1, default: 7, description: 'How quickly the correction table adapts to new observations. Higher values mean slower, more stable changes.' },
|
|
217
247
|
showStatistics: { label: 'Show statistics (σ)', type: 'boolean', description: 'Display standard deviation alongside smoothed values for debugging.' },
|
|
218
248
|
headingSource: { label: 'Heading source', type: 'source', sourceOf: { type: 'delta', id: 'heading.smoothed' } },
|
|
219
|
-
boatSpeedSource:{ label: 'Boat speed source', type: 'source', sourceOf: { type: '
|
|
249
|
+
boatSpeedSource:{ label: 'Boat speed source', type: 'source', sourceOf: { type: 'delta', id: 'boatSpeed.smoothed' } },
|
|
220
250
|
SOGSource: { label: 'Groundspeed source', type: 'source', sourceOf: { type: 'polar', id: 'groundSpeed.smoothed' } },
|
|
221
251
|
attitudeSource: { label: 'Attitude source', type: 'source', sourceOf: { type: 'attitude',id: 'attitude.smoothed' } },
|
|
222
252
|
smootherClass: {
|
|
@@ -492,9 +522,9 @@ function renderGroupInto(elId, polars, deltas, attitudes) {
|
|
|
492
522
|
if (!el) return;
|
|
493
523
|
el.innerHTML = '';
|
|
494
524
|
const rows = [
|
|
495
|
-
...polars .map(p => ({ label: itemLabel(p), value: formatPolarValue(p), stale:
|
|
496
|
-
...deltas .map(d => ({ label: itemLabel(d), value: formatDeltaValue(d), stale:
|
|
497
|
-
...attitudes.map(a => ({ label: itemLabel(a), value: formatAttitudeValue(a), stale:
|
|
525
|
+
...polars .map(p => ({ label: itemLabel(p), value: formatPolarValue(p), stale: isNotReady(p) })),
|
|
526
|
+
...deltas .map(d => ({ label: itemLabel(d), value: formatDeltaValue(d), stale: isNotReady(d) })),
|
|
527
|
+
...attitudes.map(a => ({ label: itemLabel(a), value: formatAttitudeValue(a), stale: isNotReady(a) }))
|
|
498
528
|
];
|
|
499
529
|
if (rows.length) el.appendChild(buildDataTable(rows));
|
|
500
530
|
}
|
|
@@ -502,20 +532,20 @@ function renderGroupInto(elId, polars, deltas, attitudes) {
|
|
|
502
532
|
function renderLiveSections() {
|
|
503
533
|
// Inputs section — raw sensor readings only (smoothing is internal to the plugin)
|
|
504
534
|
renderGroupInto('inputs-values',
|
|
505
|
-
filterById(state.polarsAll, ['
|
|
506
|
-
filterById(state.deltasAll, ['heading.angle']),
|
|
535
|
+
filterById(state.polarsAll, ['groundSpeed']),
|
|
536
|
+
filterById(state.deltasAll, ['heading.angle', 'boatSpeed']),
|
|
507
537
|
filterById(state.attitudesAll, ['attitude'])
|
|
508
538
|
);
|
|
509
539
|
|
|
510
540
|
// Estimation — inputs (raw sensor data used for boat speed estimation)
|
|
511
541
|
renderGroupInto('estimation-inputs',
|
|
512
|
-
filterById(state.polarsAll, ['
|
|
513
|
-
filterById(state.deltasAll, ['heading.angle']),
|
|
542
|
+
filterById(state.polarsAll, ['groundSpeed']),
|
|
543
|
+
filterById(state.deltasAll, ['heading.angle', 'boatSpeed']),
|
|
514
544
|
filterById(state.attitudesAll, ['attitude'])
|
|
515
545
|
);
|
|
516
546
|
// Estimation — intermediates
|
|
517
547
|
renderGroupInto('estimation-intermediates',
|
|
518
|
-
filterById(state.polarsAll, ['boatSpeedRefGround', 'speedCorrection']),
|
|
548
|
+
filterById(state.polarsAll, ['boatSpeedRefGround', 'speedCorrection', 'residual', 'residual.smoothed']),
|
|
519
549
|
[], []
|
|
520
550
|
);
|
|
521
551
|
// Estimation — outputs
|
|
@@ -523,21 +553,24 @@ function renderLiveSections() {
|
|
|
523
553
|
filterById(state.polarsAll, ['correctedBoatSpeed', 'current.smoothed']),
|
|
524
554
|
[], []
|
|
525
555
|
);
|
|
556
|
+
// Estimation — warnings
|
|
557
|
+
renderWarnings('estimation-warnings',
|
|
558
|
+
['boatSpeed', 'attitude', 'heading.angle', 'groundSpeed']
|
|
559
|
+
);
|
|
526
560
|
|
|
527
561
|
// Learning — inputs: smoothed sensors + current if assumeCurrent
|
|
528
562
|
const learningCurrentPolars = (config && config.assumeCurrent)
|
|
529
563
|
? filterById(state.polarsAll, ['current.smoothed'])
|
|
530
564
|
: [];
|
|
531
565
|
renderGroupInto('learning-inputs',
|
|
532
|
-
[...filterById(state.polarsAll, ['
|
|
533
|
-
filterById(state.deltasAll, ['heading.smoothed']),
|
|
566
|
+
[...filterById(state.polarsAll, ['groundSpeed.smoothed']), ...learningCurrentPolars],
|
|
567
|
+
filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']),
|
|
534
568
|
filterById(state.attitudesAll, ['attitude.smoothed'])
|
|
535
569
|
);
|
|
536
|
-
// Learning —
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
);
|
|
570
|
+
// Learning — warnings
|
|
571
|
+
const learningWarningIds = ['boatSpeed.smoothed', 'groundSpeed.smoothed', 'heading.smoothed', 'attitude.smoothed'];
|
|
572
|
+
if (config && config.assumeCurrent) learningWarningIds.push('current.smoothed');
|
|
573
|
+
renderWarnings('learning-warnings', learningWarningIds);
|
|
541
574
|
// Correction table
|
|
542
575
|
const tableEl = document.getElementById('table-container');
|
|
543
576
|
if (tableEl) {
|
package/public/index.html
CHANGED
|
@@ -104,6 +104,7 @@
|
|
|
104
104
|
<div id="estimation-intermediates"></div>
|
|
105
105
|
<h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">Outputs</h6>
|
|
106
106
|
<div id="estimation-outputs"></div>
|
|
107
|
+
<div id="estimation-warnings"></div>
|
|
107
108
|
</div>
|
|
108
109
|
</div>
|
|
109
110
|
</div>
|
|
@@ -130,8 +131,7 @@
|
|
|
130
131
|
</table>
|
|
131
132
|
<h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">Inputs</h6>
|
|
132
133
|
<div id="learning-inputs"></div>
|
|
133
|
-
<
|
|
134
|
-
<div id="learning-intermediates"></div>
|
|
134
|
+
<div id="learning-warnings"></div>
|
|
135
135
|
</div>
|
|
136
136
|
</div>
|
|
137
137
|
</div>
|