speedandcurrent 1.7.5 → 1.7.7
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 +1 -1
- package/correctionTable.js +1 -35
- package/index.js +63 -58
- package/package.json +2 -3
- package/public/app.js +41 -3
- package/public/index.html +28 -4
- package/leakyExtremes.js +0 -98
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ Leave a source blank or set to *(any)* to accept values from all sources. If mul
|
|
|
104
104
|
5. The plugin will always estimate currents when Estimate boat speed is enabled. Even when Assume currents is disabled, as this setting is only used in updating the correction table.
|
|
105
105
|
|
|
106
106
|
## Persistence & Data Storage
|
|
107
|
-
Each correction table is saved as a `.json` file in Signal K's plugin data directory (typically `~/.signalk/plugin-config-data/speedandcurrent/`). The active table name is stored in the plugin options and reloaded automatically on restart. While learning is active, the table is written to disk approximately every
|
|
107
|
+
Each correction table is saved as a `.json` file in Signal K's plugin data directory (typically `~/.signalk/plugin-config-data/speedandcurrent/`). The active table name is stored in the plugin options and reloaded automatically on restart. While learning is active, the table is written to disk approximately every 60 seconds.
|
|
108
108
|
|
|
109
109
|
Safety & backup:
|
|
110
110
|
- Tables can be copied to a new name with the **Copy** button before experimenting.
|
package/correctionTable.js
CHANGED
|
@@ -91,39 +91,6 @@ class CorrectionTable extends Table2D{
|
|
|
91
91
|
this.lastUpdatedCell = cell;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
getKalmanCorrection(speed, heel) {
|
|
95
|
-
/**
|
|
96
|
-
* Return a estimated correction based on interpolation and weighting of correction variance;
|
|
97
|
-
*/
|
|
98
|
-
if (speed == 0) return { x: 0, y: 0 };
|
|
99
|
-
const neighbours = this.findNeighbours(speed, heel);
|
|
100
|
-
let totalWeightX = 0;
|
|
101
|
-
let totalWeightY = 0;
|
|
102
|
-
let x = 0;
|
|
103
|
-
let y = 0;
|
|
104
|
-
for (const neighbour of neighbours) {
|
|
105
|
-
const { cell:correction, xdist, ydist } = neighbour;
|
|
106
|
-
if (correction.N > 0) {
|
|
107
|
-
const weightX = 1 / (xdist + 1e-6) ;
|
|
108
|
-
const weightY = 1 / (ydist + 1e-6) ;
|
|
109
|
-
const varianceX = correction.covariance[0][0];
|
|
110
|
-
const varianceY = correction.covariance[1][1];
|
|
111
|
-
const N = correction.N;
|
|
112
|
-
const varWeightX = weightX * N / (varianceX + 1e-6);
|
|
113
|
-
const varWeightY = weightY * N / (varianceY + 1e-6);
|
|
114
|
-
x += correction.x * varWeightX;
|
|
115
|
-
y += correction.y * varWeightY;
|
|
116
|
-
totalWeightX += varWeightX;
|
|
117
|
-
totalWeightY += varWeightY;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
if (totalWeightX == 0 || totalWeightY == 0) return { x: 0, y: 0 };
|
|
121
|
-
x = x / totalWeightX;
|
|
122
|
-
y = y / totalWeightY;
|
|
123
|
-
return { x:x, y:y };
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
94
|
getCorrection(speed, heel) {
|
|
128
95
|
this.neighbours = this.findClosest(speed, heel, 5);
|
|
129
96
|
if (this.neighbours.length == 0) return { correction: {x: 0, y: 0}, variance: null };
|
|
@@ -187,7 +154,6 @@ class CorrectionTable extends Table2D{
|
|
|
187
154
|
if (cov && Array.isArray(cov) && cov[0] && cov[1] && Number.isFinite(cov[0][0]) && Number.isFinite(cov[1][1])) {
|
|
188
155
|
const a = cov[0][0];
|
|
189
156
|
const d = cov[1][1];
|
|
190
|
-
//trace = Math.sqrt(a*a + d*d);
|
|
191
157
|
trace = a + d;
|
|
192
158
|
}
|
|
193
159
|
} catch { /* silent */ }
|
|
@@ -285,7 +251,7 @@ class CorrectionEstimator {
|
|
|
285
251
|
|
|
286
252
|
var groundCov = rotateVariance(groundSpeed.variance);
|
|
287
253
|
var currentCov = rotateVariance(current.variance);
|
|
288
|
-
var boatCov = [[boatSpeed.
|
|
254
|
+
var boatCov = [[boatSpeed.xVariance, 0], [0, boatSpeed.yVariance]];
|
|
289
255
|
|
|
290
256
|
const observationCovariance = [[
|
|
291
257
|
groundCov[0][0] + currentCov[0][0] + boatCov[0][0],
|
package/index.js
CHANGED
|
@@ -41,7 +41,8 @@ module.exports = function (app) {
|
|
|
41
41
|
smootherTau: 3,
|
|
42
42
|
smootherTimeSpan: 5,
|
|
43
43
|
smootherSteadyState: 0.2,
|
|
44
|
-
showStatistics: false
|
|
44
|
+
showStatistics: false,
|
|
45
|
+
stalenessDetection: true
|
|
45
46
|
};
|
|
46
47
|
|
|
47
48
|
|
|
@@ -109,7 +110,6 @@ module.exports = function (app) {
|
|
|
109
110
|
let isRunning = false;
|
|
110
111
|
let pluginStatus = 'Stopped';
|
|
111
112
|
let smoothedHeading = null;
|
|
112
|
-
let stability = null;
|
|
113
113
|
let smoothedAttitude = null;
|
|
114
114
|
let rawCurrent = null;
|
|
115
115
|
let smoothedCurrent = null;
|
|
@@ -120,10 +120,10 @@ module.exports = function (app) {
|
|
|
120
120
|
let smoothedGroundSpeed = null;
|
|
121
121
|
let speedCorrection = null;
|
|
122
122
|
let residual = null;
|
|
123
|
+
let smoothedResidual = null;
|
|
123
124
|
let reportFull = null;
|
|
124
125
|
let table = null;
|
|
125
126
|
|
|
126
|
-
// add missing declarations
|
|
127
127
|
let rawHeading = null;
|
|
128
128
|
let rawAttitude = null;
|
|
129
129
|
let noCurrent = null;
|
|
@@ -313,6 +313,10 @@ module.exports = function (app) {
|
|
|
313
313
|
SmootherClass,
|
|
314
314
|
smootherOptions
|
|
315
315
|
});
|
|
316
|
+
// The magnitude handler has a fixed value of 1 and is never subscribed, so its timestamp
|
|
317
|
+
// stays null forever. Staleness detection on it would always force _stale=true, blocking
|
|
318
|
+
// processChanges() from setting polar._ready=true on every heading delta.
|
|
319
|
+
smoothedHeading.polar.magnitudeHandler.stalenessDetection = false;
|
|
316
320
|
rawHeading = smoothedHeading.handler;
|
|
317
321
|
|
|
318
322
|
//attitude
|
|
@@ -359,6 +363,7 @@ module.exports = function (app) {
|
|
|
359
363
|
});
|
|
360
364
|
noCurrent.xSmoother.reset(0,0);
|
|
361
365
|
noCurrent.ySmoother.reset(0,0);
|
|
366
|
+
noCurrent.stalenessDetection = false; // noCurrent is a fixed zero-vector, never receives live data
|
|
362
367
|
PolarSmoother.send(app, plugin.id, [noCurrent]);
|
|
363
368
|
|
|
364
369
|
MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
|
|
@@ -422,6 +427,8 @@ module.exports = function (app) {
|
|
|
422
427
|
// residual
|
|
423
428
|
residual = new Polar(app, plugin.id, "residual");
|
|
424
429
|
residual.setMeta({ displayName: "Residual", plane: "Ground" });
|
|
430
|
+
smoothedResidual = new PolarSmoother(residual, ExponentialSmoother, { tau: 30, timeSpan: 30 }); // id auto-derived: 'residual.smoothed'
|
|
431
|
+
smoothedResidual.setAngleRange('0to2pi');
|
|
425
432
|
|
|
426
433
|
//#endregion
|
|
427
434
|
|
|
@@ -438,6 +445,7 @@ module.exports = function (app) {
|
|
|
438
445
|
reportFull.addPolar(rawGroundSpeed);
|
|
439
446
|
reportFull.addPolar(smoothedCurrent);
|
|
440
447
|
reportFull.addPolar(residual);
|
|
448
|
+
reportFull.addPolar(smoothedResidual);
|
|
441
449
|
}
|
|
442
450
|
if (options.updateCorrectionTable) {
|
|
443
451
|
reportFull.addDelta(smoothedHeading);
|
|
@@ -452,6 +460,18 @@ module.exports = function (app) {
|
|
|
452
460
|
|
|
453
461
|
//#endregion
|
|
454
462
|
|
|
463
|
+
// Apply staleness detection to all subscribed smoother instances.
|
|
464
|
+
// noCurrent is intentionally excluded — it is a fixed zero-vector and never receives live data.
|
|
465
|
+
// Only apply when sd = false: all smoothers already default to stalenessDetection = true.
|
|
466
|
+
// Explicitly calling the setter with true at startup forces _stale = true on handlers that have
|
|
467
|
+
// never received data, causing the first sample to be dropped (library side-effect).
|
|
468
|
+
const sd = options.stalenessDetection ?? true;
|
|
469
|
+
if (!sd) {
|
|
470
|
+
for (const s of [smoothedHeading, smoothedAttitude, smoothedBoatSpeed, smoothedGroundSpeed, smoothedCurrent, smoothedResidual]) {
|
|
471
|
+
if (s) s.stalenessDetection = false;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
455
475
|
isRunning = true;
|
|
456
476
|
started = new Date();
|
|
457
477
|
setStatus('Running');
|
|
@@ -482,7 +502,6 @@ module.exports = function (app) {
|
|
|
482
502
|
return new Promise((resolve, reject) => {
|
|
483
503
|
try {
|
|
484
504
|
smoothedHeading = smoothedHeading?.terminate();
|
|
485
|
-
stability = stability?.terminate?.();
|
|
486
505
|
smoothedAttitude = smoothedAttitude?.terminate();
|
|
487
506
|
rawCurrent = rawCurrent?.terminate();
|
|
488
507
|
smoothedCurrent = smoothedCurrent?.terminate?.();
|
|
@@ -493,6 +512,7 @@ module.exports = function (app) {
|
|
|
493
512
|
smoothedGroundSpeed = smoothedGroundSpeed?.terminate();
|
|
494
513
|
speedCorrection = speedCorrection?.terminate();
|
|
495
514
|
residual = residual?.terminate();
|
|
515
|
+
smoothedResidual = smoothedResidual?.terminate?.();
|
|
496
516
|
reportFull = null;
|
|
497
517
|
table = null;
|
|
498
518
|
rawHeading = null;
|
|
@@ -542,41 +562,50 @@ module.exports = function (app) {
|
|
|
542
562
|
speedCorrection.setVectorValue(correction, variance);
|
|
543
563
|
correctedBoatSpeed.add(speedCorrection);
|
|
544
564
|
}
|
|
545
|
-
// Current estimation and residual also require heading (to rotate into ground frame)
|
|
546
|
-
|
|
565
|
+
// Current estimation and residual also require heading (to rotate into ground frame).
|
|
566
|
+
// Also handle near-zero SOG where COG is unavailable: treat groundspeed as zero vector.
|
|
567
|
+
const sogHandler = rawGroundSpeed.magnitudeHandler;
|
|
568
|
+
const nearZeroGroundSpeed = !rawGroundSpeed.ready &&
|
|
569
|
+
sogHandler.ready &&
|
|
570
|
+
Number.isFinite(sogHandler.value) &&
|
|
571
|
+
sogHandler.value < 0.3;
|
|
572
|
+
if (rawHeading.ready && (rawGroundSpeed.ready || nearZeroGroundSpeed)) {
|
|
547
573
|
boatSpeedRefGround.copyFrom(correctedBoatSpeed);
|
|
548
574
|
boatSpeedRefGround.rotate(rawHeading.value);
|
|
549
575
|
// Current estimation gated by wellUnderway (smoothers need to settle first)
|
|
550
576
|
if (wellUnderway) {
|
|
551
|
-
|
|
577
|
+
if (rawGroundSpeed.ready) {
|
|
578
|
+
rawCurrent.copyFrom(rawGroundSpeed);
|
|
579
|
+
} else {
|
|
580
|
+
// COG unavailable but SOG is near-zero: boat is stationary, groundspeed treated as zero
|
|
581
|
+
rawCurrent.setVectorValue({ x: 0, y: 0 });
|
|
582
|
+
}
|
|
552
583
|
rawCurrent.substract(boatSpeedRefGround);
|
|
553
584
|
smoothedCurrent.sample();
|
|
554
585
|
}
|
|
555
|
-
// Residual
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
586
|
+
// Residual requires full groundspeed (both SOG and COG)
|
|
587
|
+
if (rawGroundSpeed.ready) {
|
|
588
|
+
residual.copyFrom(rawGroundSpeed);
|
|
589
|
+
residual.substract(boatSpeedRefGround);
|
|
590
|
+
residual.substract(smoothedCurrent);
|
|
591
|
+
smoothedResidual.sample();
|
|
592
|
+
}
|
|
559
593
|
}
|
|
560
594
|
}
|
|
561
595
|
// Implicit fallback: attitude not ready — correctedBoatSpeed = raw STW, no correction
|
|
562
596
|
|
|
563
|
-
PolarSmoother.send(app, plugin.id, [smoothedCurrent]);
|
|
597
|
+
PolarSmoother.send(app, plugin.id, [smoothedCurrent, smoothedResidual]);
|
|
564
598
|
|
|
565
599
|
Polar.send(app, plugin.id, [correctedBoatSpeed]);
|
|
566
600
|
}
|
|
567
601
|
|
|
568
602
|
/**
|
|
569
|
-
* Updates the correction table
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
* if the boat speed exceeds the specified minimum. It also calculates the residual between the measured ground speed
|
|
573
|
-
* and the expected ground speed (corrected boat speed plus current). The correction table is periodically saved.
|
|
574
|
-
*
|
|
575
|
-
* @param {boolean} [assumeCurrent=false] - If true, uses the smoothed current for correction; otherwise, assumes no current.
|
|
576
|
-
* @param {number} [minSpeed=0] - The minimum speed threshold (in SI units) required to update the correction table.
|
|
603
|
+
* Updates the correction table from the current smoothed inputs.
|
|
604
|
+
* Reads assumeCurrent and minSpeed from module-level options/state;
|
|
605
|
+
* silently returns if any required input is not yet ready.
|
|
577
606
|
*/
|
|
578
607
|
function updateTable() {
|
|
579
|
-
lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 });
|
|
608
|
+
lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
|
|
580
609
|
if (!smoothedAttitude.ready || !smoothedBoatSpeed.ready || !smoothedHeading.ready || !smoothedGroundSpeed.ready || (options.assumeCurrent ? !smoothedCurrent.ready : !noCurrent.ready)) return;
|
|
581
610
|
|
|
582
611
|
// update correction table
|
|
@@ -587,37 +616,12 @@ module.exports = function (app) {
|
|
|
587
616
|
}
|
|
588
617
|
|
|
589
618
|
/**
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
* in the current correction table against the provided row and col objects. Returns true
|
|
594
|
-
* if all values match, otherwise false. If the table is missing or not properly structured,
|
|
595
|
-
* returns false.
|
|
596
|
-
*
|
|
597
|
-
* @param {Object} row - The expected row configuration ({ min, max, step }).
|
|
598
|
-
* @param {Object} col - The expected column configuration ({ min, max, step }).
|
|
599
|
-
* @returns {boolean} True if the table matches the configuration, false otherwise.
|
|
600
|
-
*/
|
|
601
|
-
function enforceConsistancy(data, row, col) {
|
|
602
|
-
if (!data) return false;
|
|
603
|
-
if (!data.row) return false;
|
|
604
|
-
if (data.row.min != row.min) return false;
|
|
605
|
-
if (data.row.max != row.max) return false;
|
|
606
|
-
if (data.row.step != row.step) return false;
|
|
607
|
-
if (data.col.min != col.min) return false;
|
|
608
|
-
if (data.col.max != col.max) return false;
|
|
609
|
-
if (data.col.step != col.step) return false;
|
|
610
|
-
return true;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/**
|
|
614
|
-
* Loads or creates a correction table based on the provided options.
|
|
615
|
-
*
|
|
616
|
-
* This function checks if the correction table in the options matches the expected row and column configuration.
|
|
617
|
-
* If so, it loads the table from JSON; otherwise, it creates a new correction table with the specified parameters.
|
|
618
|
-
* The table is labeled and, if newly created, saved to the plugin options.
|
|
619
|
+
* Loads or creates a correction table from disk.
|
|
620
|
+
* If the file exists it is deserialized; otherwise a new table is created
|
|
621
|
+
* with default dimensions and saved to disk.
|
|
619
622
|
*
|
|
620
|
-
* @param {Object} options -
|
|
623
|
+
* @param {Object} options - Plugin options (stability, tableName, and dimension defaults).
|
|
624
|
+
* @param {string} filePath - Absolute path of the JSON file to read.
|
|
621
625
|
* @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
|
|
622
626
|
*/
|
|
623
627
|
function loadTable(options, filePath) {
|
|
@@ -639,15 +643,10 @@ module.exports = function (app) {
|
|
|
639
643
|
}
|
|
640
644
|
|
|
641
645
|
/**
|
|
642
|
-
* Saves the correction table to
|
|
646
|
+
* Saves the correction table to disk as JSON.
|
|
643
647
|
*
|
|
644
|
-
* This function serializes the provided correction table, updates the options object,
|
|
645
|
-
* and triggers the plugin's save mechanism. It returns the current date/time to indicate
|
|
646
|
-
* when the save occurred.
|
|
647
|
-
*
|
|
648
|
-
* @param {Object} options - The plugin options object to update.
|
|
649
648
|
* @param {CorrectionTable} correctionTable - The correction table instance to save.
|
|
650
|
-
* @
|
|
649
|
+
* @param {string} filePath - Absolute path of the target JSON file.
|
|
651
650
|
*/
|
|
652
651
|
function saveTable(correctionTable, filePath) {
|
|
653
652
|
correctionTable.saveToFile(filePath);
|
|
@@ -677,6 +676,12 @@ module.exports = function (app) {
|
|
|
677
676
|
if (smoothedBoatSpeed) smoothedBoatSpeed.handler.passOn = !preventDuplication();
|
|
678
677
|
} else if (key === 'estimateBoatSpeed' ) {
|
|
679
678
|
if (smoothedBoatSpeed) smoothedBoatSpeed.handler.passOn = !preventDuplication();
|
|
679
|
+
} else if (key === 'stalenessDetection') {
|
|
680
|
+
const sdVal = Boolean(value);
|
|
681
|
+
for (const s of [smoothedHeading, smoothedAttitude, smoothedBoatSpeed, smoothedGroundSpeed, smoothedCurrent, smoothedResidual]) {
|
|
682
|
+
if (s) s.stalenessDetection = sdVal;
|
|
683
|
+
}
|
|
684
|
+
// noCurrent is always excluded from staleness detection
|
|
680
685
|
}
|
|
681
686
|
|
|
682
687
|
// All other keys (sogFallback, estimateBoatSpeed, updateCorrectionTable,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speedandcurrent",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.7",
|
|
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,12 +31,11 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"kalman-filter": "^2.3.0",
|
|
34
|
-
"signalkutilities": "^1.
|
|
34
|
+
"signalkutilities": "^1.12.0"
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"index.js",
|
|
38
38
|
"correctionTable.js",
|
|
39
|
-
"leakyExtremes.js",
|
|
40
39
|
"public/"
|
|
41
40
|
]
|
|
42
41
|
}
|
package/public/app.js
CHANGED
|
@@ -118,7 +118,7 @@ async function loadMeta() {
|
|
|
118
118
|
|
|
119
119
|
function isStale(item) {
|
|
120
120
|
if (!item) return true;
|
|
121
|
-
return item.state?.
|
|
121
|
+
return item.state?.isStale === true;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
function isNotReady(item) {
|
|
@@ -126,6 +126,36 @@ function isNotReady(item) {
|
|
|
126
126
|
return item.state?.ready !== true;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// Returns a human-readable reason why an item is not ready.
|
|
130
|
+
// Handles three state shapes:
|
|
131
|
+
// handler-nested (MessageSmoother, SmoothedAngle): state.handler.*
|
|
132
|
+
// magnitude/angle (PolarSmoother): state.magnitude.* / state.angle.*
|
|
133
|
+
// flat top-level (MessageHandler, Polar): state.*
|
|
134
|
+
function getNotReadyReason(item) {
|
|
135
|
+
if (!item) return 'not available';
|
|
136
|
+
const s = item.state;
|
|
137
|
+
if (!s) return 'not available';
|
|
138
|
+
|
|
139
|
+
let subscribed, pathKnown;
|
|
140
|
+
if (s.handler !== undefined) {
|
|
141
|
+
subscribed = s.handler.subscribed;
|
|
142
|
+
pathKnown = s.handler.pathKnown;
|
|
143
|
+
} else if (s.magnitude !== undefined || s.angle !== undefined) {
|
|
144
|
+
const mag = s.magnitude, ang = s.angle;
|
|
145
|
+
subscribed = (mag?.subscribed !== false) && (ang?.subscribed !== false);
|
|
146
|
+
pathKnown = (mag?.pathKnown !== false) && (ang?.pathKnown !== false);
|
|
147
|
+
} else {
|
|
148
|
+
subscribed = s.subscribed;
|
|
149
|
+
pathKnown = s.pathKnown;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (subscribed === false) return 'not subscribed to Signal K';
|
|
153
|
+
if (pathKnown === false) return 'path not found in Signal K';
|
|
154
|
+
if (!s.hasDelta) return 'waiting for first data';
|
|
155
|
+
if (s.isStale) return 'data is stale';
|
|
156
|
+
return 'not available';
|
|
157
|
+
}
|
|
158
|
+
|
|
129
159
|
function getAnyItem(id) {
|
|
130
160
|
return state.polarsById[id] || state.deltasById[id] || state.attitudesById[id] || null;
|
|
131
161
|
}
|
|
@@ -143,9 +173,10 @@ function renderWarnings(elId, ids) {
|
|
|
143
173
|
const ul = document.createElement('ul');
|
|
144
174
|
ul.className = 'list-unstyled text-danger small ps-3';
|
|
145
175
|
notReady.forEach(id => {
|
|
176
|
+
const item = getAnyItem(id);
|
|
146
177
|
const label = metaById[id]?.displayName ?? id;
|
|
147
178
|
const li = document.createElement('li');
|
|
148
|
-
li.textContent = `"${label}" —
|
|
179
|
+
li.textContent = `"${label}" — ${getNotReadyReason(item)}`;
|
|
149
180
|
ul.appendChild(li);
|
|
150
181
|
});
|
|
151
182
|
el.appendChild(ul);
|
|
@@ -273,10 +304,16 @@ const paramMeta = {
|
|
|
273
304
|
min: 0.01, max: 0.99, step: 0.01, default: 0.2,
|
|
274
305
|
description: 'Steady-state Kalman gain (0 ≈ slow/smooth, 1 ≈ fast/raw). Clamped to 0.01–0.99.'
|
|
275
306
|
},
|
|
307
|
+
stalenessDetection: {
|
|
308
|
+
label: 'Staleness detection',
|
|
309
|
+
type: 'boolean',
|
|
310
|
+
description: 'Mark inputs as unavailable when no data has been received for a period. '
|
|
311
|
+
},
|
|
276
312
|
};
|
|
277
313
|
|
|
278
314
|
// Settings groups for each UI section
|
|
279
315
|
const INPUTS_SOURCE_KEYS = ['headingSource','boatSpeedSource','SOGSource','attitudeSource'];
|
|
316
|
+
const INPUTS_SETTING_KEYS = ['stalenessDetection'];
|
|
280
317
|
const ESTIMATION_SETTING_KEYS = ['sogFallback','preventDuplication'];
|
|
281
318
|
const LEARNING_SETTING_KEYS = ['stability','assumeCurrent','showStatistics'];
|
|
282
319
|
const SMOOTHER_SETTING_KEYS = [
|
|
@@ -441,6 +478,7 @@ function renderSectionToggles() {
|
|
|
441
478
|
function renderSettingsPanel() {
|
|
442
479
|
if (!config) return;
|
|
443
480
|
renderSettingsRows('inputs-sources-table', INPUTS_SOURCE_KEYS);
|
|
481
|
+
renderSettingsRows('inputs-settings-table', INPUTS_SETTING_KEYS);
|
|
444
482
|
renderSettingsRows('estimation-settings-table', ESTIMATION_SETTING_KEYS);
|
|
445
483
|
renderSettingsRows('smoother-settings-table', SMOOTHER_SETTING_KEYS);
|
|
446
484
|
renderSettingsRows('learning-settings-table', LEARNING_SETTING_KEYS);
|
|
@@ -545,7 +583,7 @@ function renderLiveSections() {
|
|
|
545
583
|
);
|
|
546
584
|
// Estimation — intermediates
|
|
547
585
|
renderGroupInto('estimation-intermediates',
|
|
548
|
-
filterById(state.polarsAll, ['boatSpeedRefGround', 'speedCorrection', 'residual']),
|
|
586
|
+
filterById(state.polarsAll, ['boatSpeedRefGround', 'speedCorrection', 'residual', 'residual.smoothed']),
|
|
549
587
|
[], []
|
|
550
588
|
);
|
|
551
589
|
// Estimation — outputs
|
package/public/index.html
CHANGED
|
@@ -7,16 +7,36 @@
|
|
|
7
7
|
<title>Speed and Current</title>
|
|
8
8
|
<script>
|
|
9
9
|
(async () => {
|
|
10
|
+
const appendStylesheet = (href) => {
|
|
11
|
+
const el = document.createElement('link');
|
|
12
|
+
el.rel = 'stylesheet';
|
|
13
|
+
el.href = href;
|
|
14
|
+
document.head.appendChild(el);
|
|
15
|
+
};
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch('/admin/.vite/manifest.json');
|
|
18
|
+
if (res.ok) {
|
|
19
|
+
const manifest = await res.json();
|
|
20
|
+
let found = false;
|
|
21
|
+
for (const entry of Object.values(manifest)) {
|
|
22
|
+
if (!entry.isEntry) continue;
|
|
23
|
+
for (const css of entry.css ?? []) {
|
|
24
|
+
appendStylesheet('/admin/' + css);
|
|
25
|
+
found = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (found) return;
|
|
29
|
+
}
|
|
30
|
+
} catch (e) {
|
|
31
|
+
// fall through to legacy scrape
|
|
32
|
+
}
|
|
10
33
|
try {
|
|
11
34
|
const res = await fetch('/');
|
|
12
35
|
const html = await res.text();
|
|
13
36
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
14
37
|
const link = doc.querySelector('link[rel="stylesheet"]');
|
|
15
38
|
if (link) {
|
|
16
|
-
|
|
17
|
-
el.rel = 'stylesheet';
|
|
18
|
-
el.href = new URL(link.getAttribute('href'), res.url).href;
|
|
19
|
-
document.head.appendChild(el);
|
|
39
|
+
appendStylesheet(new URL(link.getAttribute('href'), res.url).href);
|
|
20
40
|
}
|
|
21
41
|
} catch (e) {
|
|
22
42
|
console.warn('[webapp] Could not load SignalK admin stylesheet:', e);
|
|
@@ -76,6 +96,10 @@
|
|
|
76
96
|
<table class="table table-sm table-borderless mb-0" id="inputs-sources-table">
|
|
77
97
|
<tbody></tbody>
|
|
78
98
|
</table>
|
|
99
|
+
<h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">Settings</h6>
|
|
100
|
+
<table class="table table-sm table-borderless mb-0" id="inputs-settings-table">
|
|
101
|
+
<tbody></tbody>
|
|
102
|
+
</table>
|
|
79
103
|
<h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">Live Values</h6>
|
|
80
104
|
<div id="inputs-values"></div>
|
|
81
105
|
</div>
|
package/leakyExtremes.js
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
class LeakyExtremes {
|
|
2
|
-
/**
|
|
3
|
-
* @param {Object} opts
|
|
4
|
-
* @param {number} opts.initialMin
|
|
5
|
-
* @param {number} opts.initialMax
|
|
6
|
-
* @param {number} opts.tau - time constant in seconds for exponential decay
|
|
7
|
-
* @param {number} opts.catchupTau - time constant in seconds for fast catchup
|
|
8
|
-
* @param {boolean} opts.isAngle - true if data is angular (radians)
|
|
9
|
-
* @param {number} opts.period - period for angle wrapping (e.g., 2*Math.PI)
|
|
10
|
-
*/
|
|
11
|
-
constructor({ initialMin=0, initialMax=0, tau = .15, catchupTau=.5, isAngle = false, period = 2 * Math.PI }) {
|
|
12
|
-
this.min = initialMin;
|
|
13
|
-
this.max = initialMax;
|
|
14
|
-
this.tau = tau;
|
|
15
|
-
this.catchupTau = catchupTau;
|
|
16
|
-
this.isAngle = isAngle;
|
|
17
|
-
this.period = period;
|
|
18
|
-
this.lastUpdate = Date.now();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Helper for angle difference in [-period/2, period/2]
|
|
22
|
-
angleDiff(a, b) {
|
|
23
|
-
let d = a - b;
|
|
24
|
-
while (d > this.period / 2) d -= this.period;
|
|
25
|
-
while (d < -this.period / 2) d += this.period;
|
|
26
|
-
return d;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Helper for angle normalization to [0, period)
|
|
30
|
-
normalizeAngle(a) {
|
|
31
|
-
while (a < 0) a += this.period;
|
|
32
|
-
while (a >= this.period) a -= this.period;
|
|
33
|
-
return a;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
update(obs) {
|
|
37
|
-
if (!isFinite(obs) ) return;
|
|
38
|
-
const now = Date.now();
|
|
39
|
-
const dt = (now - this.lastUpdate) / 1000;
|
|
40
|
-
if (dt==0) return;
|
|
41
|
-
this.lastUpdate = now;
|
|
42
|
-
|
|
43
|
-
if (!this.isAngle) {
|
|
44
|
-
// Linear data
|
|
45
|
-
// Update min
|
|
46
|
-
if (obs < this.min) {
|
|
47
|
-
const alpha = 1 - Math.exp(-dt / this.catchupTau);
|
|
48
|
-
this.min += (obs - this.min) * alpha;
|
|
49
|
-
} else {
|
|
50
|
-
const alpha = 1 - Math.exp(-dt / this.tau);
|
|
51
|
-
this.min += (obs - this.min) * alpha;
|
|
52
|
-
}
|
|
53
|
-
// Update max
|
|
54
|
-
if (obs > this.max) {
|
|
55
|
-
const alpha = 1 - Math.exp(-dt / this.catchupTau);
|
|
56
|
-
this.max += (obs - this.max) * alpha;
|
|
57
|
-
} else {
|
|
58
|
-
const alpha = 1 - Math.exp(-dt / this.tau);
|
|
59
|
-
this.max += (obs - this.max) * alpha;
|
|
60
|
-
}
|
|
61
|
-
} else {
|
|
62
|
-
// Angular data
|
|
63
|
-
obs=this.normalizeAngle(obs);
|
|
64
|
-
let minDiff = this.angleDiff(obs, this.min);
|
|
65
|
-
let maxDiff = this.angleDiff(obs, this.max);
|
|
66
|
-
|
|
67
|
-
// Update min
|
|
68
|
-
if (minDiff < 0) {
|
|
69
|
-
const alpha = 1 - Math.exp(-dt / this.catchupTau);
|
|
70
|
-
this.min = this.normalizeAngle(this.min + minDiff * alpha);
|
|
71
|
-
} else {
|
|
72
|
-
const alpha = 1 - Math.exp(-dt / this.tau);
|
|
73
|
-
this.min = this.normalizeAngle(this.min + minDiff * alpha);
|
|
74
|
-
}
|
|
75
|
-
// Update max
|
|
76
|
-
if (maxDiff > 0) {
|
|
77
|
-
const alpha = 1 - Math.exp(-dt / this.catchupTau);
|
|
78
|
-
this.max = this.normalizeAngle(this.max + maxDiff * alpha);
|
|
79
|
-
} else {
|
|
80
|
-
const alpha = 1 - Math.exp(-dt / this.tau);
|
|
81
|
-
this.max = this.normalizeAngle(this.max + maxDiff * alpha);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// For angles, returns the minimal arc between min and max
|
|
88
|
-
get range() {
|
|
89
|
-
if (!this.isAngle) {
|
|
90
|
-
return this.max - this.min;
|
|
91
|
-
} else {
|
|
92
|
-
let diff = this.angleDiff(this.max, this.min);
|
|
93
|
-
return Math.abs(diff);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
module.exports = { LeakyExtremes };
|