speedandcurrent 1.7.6 → 1.7.8
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 +3 -3
- package/correctionTable.js +1 -36
- package/index.js +37 -53
- package/package.json +2 -3
- package/public/app.js +40 -2
- package/public/index.html +54 -4
- package/public/main.css +27 -2
- 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.
|
|
@@ -148,8 +148,8 @@ Each populated cell shows:
|
|
|
148
148
|
- **Leeway** (°): the estimated sideways drift angle; also encoded visually as the stripe direction in the cell background.
|
|
149
149
|
|
|
150
150
|
Cell highlighting:
|
|
151
|
-
- **Blue
|
|
152
|
-
- **
|
|
151
|
+
- **Blue values**: the most recently updated table cell (factor and leeway text rendered in blue).
|
|
152
|
+
- **Thin border**: cells contributing to the current interpolation.
|
|
153
153
|
- **No content**: not yet populated by learning.
|
|
154
154
|
|
|
155
155
|
### Correction Table Health Checklist
|
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],
|
|
@@ -295,7 +261,6 @@ class CorrectionEstimator {
|
|
|
295
261
|
groundCov[1][1] + currentCov[1][1] + boatCov[1][1]],
|
|
296
262
|
];
|
|
297
263
|
this.filterState = this.filter.filter({ previousCorrected: this.filterState, observation, observationCovariance });
|
|
298
|
-
//console.log("Filterstate:", this.filterState, "Covariance:", observationCovariance);
|
|
299
264
|
return true;
|
|
300
265
|
}
|
|
301
266
|
|
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;
|
|
@@ -124,7 +124,6 @@ module.exports = function (app) {
|
|
|
124
124
|
let reportFull = null;
|
|
125
125
|
let table = null;
|
|
126
126
|
|
|
127
|
-
// add missing declarations
|
|
128
127
|
let rawHeading = null;
|
|
129
128
|
let rawAttitude = null;
|
|
130
129
|
let noCurrent = null;
|
|
@@ -314,6 +313,10 @@ module.exports = function (app) {
|
|
|
314
313
|
SmootherClass,
|
|
315
314
|
smootherOptions
|
|
316
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;
|
|
317
320
|
rawHeading = smoothedHeading.handler;
|
|
318
321
|
|
|
319
322
|
//attitude
|
|
@@ -360,6 +363,7 @@ module.exports = function (app) {
|
|
|
360
363
|
});
|
|
361
364
|
noCurrent.xSmoother.reset(0,0);
|
|
362
365
|
noCurrent.ySmoother.reset(0,0);
|
|
366
|
+
noCurrent.stalenessDetection = false; // noCurrent is a fixed zero-vector, never receives live data
|
|
363
367
|
PolarSmoother.send(app, plugin.id, [noCurrent]);
|
|
364
368
|
|
|
365
369
|
MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
|
|
@@ -425,8 +429,6 @@ module.exports = function (app) {
|
|
|
425
429
|
residual.setMeta({ displayName: "Residual", plane: "Ground" });
|
|
426
430
|
smoothedResidual = new PolarSmoother(residual, ExponentialSmoother, { tau: 30, timeSpan: 30 }); // id auto-derived: 'residual.smoothed'
|
|
427
431
|
smoothedResidual.setAngleRange('0to2pi');
|
|
428
|
-
// smoothedResidual.xSmoother.reset(0, 0.00000001);
|
|
429
|
-
// smoothedResidual.ySmoother.reset(0, 0.00000001);
|
|
430
432
|
|
|
431
433
|
//#endregion
|
|
432
434
|
|
|
@@ -458,6 +460,18 @@ module.exports = function (app) {
|
|
|
458
460
|
|
|
459
461
|
//#endregion
|
|
460
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
|
+
|
|
461
475
|
isRunning = true;
|
|
462
476
|
started = new Date();
|
|
463
477
|
setStatus('Running');
|
|
@@ -488,7 +502,6 @@ module.exports = function (app) {
|
|
|
488
502
|
return new Promise((resolve, reject) => {
|
|
489
503
|
try {
|
|
490
504
|
smoothedHeading = smoothedHeading?.terminate();
|
|
491
|
-
stability = stability?.terminate?.();
|
|
492
505
|
smoothedAttitude = smoothedAttitude?.terminate();
|
|
493
506
|
rawCurrent = rawCurrent?.terminate();
|
|
494
507
|
smoothedCurrent = smoothedCurrent?.terminate?.();
|
|
@@ -587,18 +600,13 @@ module.exports = function (app) {
|
|
|
587
600
|
}
|
|
588
601
|
|
|
589
602
|
/**
|
|
590
|
-
* Updates the correction table
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
* if the boat speed exceeds the specified minimum. It also calculates the residual between the measured ground speed
|
|
594
|
-
* and the expected ground speed (corrected boat speed plus current). The correction table is periodically saved.
|
|
595
|
-
*
|
|
596
|
-
* @param {boolean} [assumeCurrent=false] - If true, uses the smoothed current for correction; otherwise, assumes no current.
|
|
597
|
-
* @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.
|
|
598
606
|
*/
|
|
599
607
|
function updateTable() {
|
|
600
|
-
lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 });
|
|
601
|
-
if (!smoothedAttitude.ready || !smoothedBoatSpeed.ready || !smoothedHeading.ready || !smoothedGroundSpeed.ready || (options.assumeCurrent
|
|
608
|
+
lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
|
|
609
|
+
if (!smoothedAttitude.ready || !smoothedBoatSpeed.ready || !smoothedHeading.ready || !smoothedGroundSpeed.ready || (options.assumeCurrent && !smoothedCurrent.ready)) return;
|
|
602
610
|
|
|
603
611
|
// update correction table
|
|
604
612
|
if (smoothedBoatSpeed.value > minSpeed) {
|
|
@@ -608,37 +616,12 @@ module.exports = function (app) {
|
|
|
608
616
|
}
|
|
609
617
|
|
|
610
618
|
/**
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
* in the current correction table against the provided row and col objects. Returns true
|
|
615
|
-
* if all values match, otherwise false. If the table is missing or not properly structured,
|
|
616
|
-
* returns false.
|
|
617
|
-
*
|
|
618
|
-
* @param {Object} row - The expected row configuration ({ min, max, step }).
|
|
619
|
-
* @param {Object} col - The expected column configuration ({ min, max, step }).
|
|
620
|
-
* @returns {boolean} True if the table matches the configuration, false otherwise.
|
|
621
|
-
*/
|
|
622
|
-
function enforceConsistancy(data, row, col) {
|
|
623
|
-
if (!data) return false;
|
|
624
|
-
if (!data.row) return false;
|
|
625
|
-
if (data.row.min != row.min) return false;
|
|
626
|
-
if (data.row.max != row.max) return false;
|
|
627
|
-
if (data.row.step != row.step) return false;
|
|
628
|
-
if (data.col.min != col.min) return false;
|
|
629
|
-
if (data.col.max != col.max) return false;
|
|
630
|
-
if (data.col.step != col.step) return false;
|
|
631
|
-
return true;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
/**
|
|
635
|
-
* Loads or creates a correction table based on the provided 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.
|
|
636
622
|
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
* The table is labeled and, if newly created, saved to the plugin options.
|
|
640
|
-
*
|
|
641
|
-
* @param {Object} options - The plugin options containing correction table settings and parameters.
|
|
623
|
+
* @param {Object} options - Plugin options (stability, tableName, and dimension defaults).
|
|
624
|
+
* @param {string} filePath - Absolute path of the JSON file to read.
|
|
642
625
|
* @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
|
|
643
626
|
*/
|
|
644
627
|
function loadTable(options, filePath) {
|
|
@@ -660,15 +643,10 @@ module.exports = function (app) {
|
|
|
660
643
|
}
|
|
661
644
|
|
|
662
645
|
/**
|
|
663
|
-
* Saves the correction table to
|
|
664
|
-
*
|
|
665
|
-
* This function serializes the provided correction table, updates the options object,
|
|
666
|
-
* and triggers the plugin's save mechanism. It returns the current date/time to indicate
|
|
667
|
-
* when the save occurred.
|
|
646
|
+
* Saves the correction table to disk as JSON.
|
|
668
647
|
*
|
|
669
|
-
* @param {Object} options - The plugin options object to update.
|
|
670
648
|
* @param {CorrectionTable} correctionTable - The correction table instance to save.
|
|
671
|
-
* @
|
|
649
|
+
* @param {string} filePath - Absolute path of the target JSON file.
|
|
672
650
|
*/
|
|
673
651
|
function saveTable(correctionTable, filePath) {
|
|
674
652
|
correctionTable.saveToFile(filePath);
|
|
@@ -698,6 +676,12 @@ module.exports = function (app) {
|
|
|
698
676
|
if (smoothedBoatSpeed) smoothedBoatSpeed.handler.passOn = !preventDuplication();
|
|
699
677
|
} else if (key === 'estimateBoatSpeed' ) {
|
|
700
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
|
|
701
685
|
}
|
|
702
686
|
|
|
703
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.8",
|
|
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);
|
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>
|
|
@@ -151,6 +175,32 @@
|
|
|
151
175
|
</div>
|
|
152
176
|
</div>
|
|
153
177
|
<div class="card-body">
|
|
178
|
+
<div class="table-legend small text-muted mb-3">
|
|
179
|
+
<div class="table-legend-row">
|
|
180
|
+
<span class="legend-swatch legend-swatch--green"></span>
|
|
181
|
+
<span><strong>Green</strong> — paddlewheel reads slow (factor > 1, speed is corrected up)</span>
|
|
182
|
+
</div>
|
|
183
|
+
<div class="table-legend-row">
|
|
184
|
+
<span class="legend-swatch legend-swatch--orange"></span>
|
|
185
|
+
<span><strong>Orange</strong> — paddlewheel reads fast (factor < 1, speed is corrected down)</span>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="table-legend-row">
|
|
188
|
+
<span class="legend-swatch legend-swatch--stripe"></span>
|
|
189
|
+
<span><strong>Stripe angle</strong> — encodes leeway direction; stripes are tilted proportionally to the leeway correction (°)</span>
|
|
190
|
+
</div>
|
|
191
|
+
<div class="table-legend-row">
|
|
192
|
+
<span class="legend-swatch legend-swatch--active">+1.2%</span>
|
|
193
|
+
<span><strong>Blue text</strong> — most recently updated cell</span>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="table-legend-row">
|
|
196
|
+
<span class="legend-swatch legend-swatch--neighbour"></span>
|
|
197
|
+
<span><strong>Faint tint</strong> — interpolation neighbours used in the current estimate</span>
|
|
198
|
+
</div>
|
|
199
|
+
<div class="table-legend-row mt-1">
|
|
200
|
+
<span class="legend-label-wide">Cell values:</span>
|
|
201
|
+
<span>top = speed-correction factor (±%), bottom = leeway correction (±°)</span>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
154
204
|
<div id="table-container"></div>
|
|
155
205
|
</div>
|
|
156
206
|
</div>
|
package/public/main.css
CHANGED
|
@@ -42,10 +42,11 @@
|
|
|
42
42
|
.TableCorner { font-size: 10px; color: #888; font-weight: 400; text-align: center; }
|
|
43
43
|
/* cell states */
|
|
44
44
|
.cell--empty { background-color: #f0f0f0; }
|
|
45
|
-
.cell--
|
|
46
|
-
.cell--neighbour { outline: 1px dashed #90a4ae; outline-offset: -2px; }
|
|
45
|
+
.cell--neighbour { box-shadow: inset 0 0 0 1px #90a4ae; }
|
|
47
46
|
.cell-factor { font-weight: 600; font-size: 12px; }
|
|
48
47
|
.cell-leeway { font-weight: 600; font-size: 12px; margin-top: 2px; }
|
|
48
|
+
.cell--active .cell-factor,
|
|
49
|
+
.cell--active .cell-leeway { color: #1565c0; }
|
|
49
50
|
|
|
50
51
|
/* ─── Stable table column widths inside cards ──────────────────────────────── */
|
|
51
52
|
.card-body .table {
|
|
@@ -96,3 +97,27 @@ line { stroke-width: 2; }
|
|
|
96
97
|
|
|
97
98
|
/* ─── Stale data rows ──────────────────────────────────────────────────────── */
|
|
98
99
|
tr.stale td { color: #aaa; font-style: italic; }
|
|
100
|
+
|
|
101
|
+
/* ─── Correction table legend ─────────────────────────────────────────────── */
|
|
102
|
+
.table-legend { display: flex; flex-direction: column; gap: 3px; }
|
|
103
|
+
.table-legend-row { display: flex; align-items: center; gap: 6px; }
|
|
104
|
+
.legend-swatch {
|
|
105
|
+
flex-shrink: 0;
|
|
106
|
+
width: 28px;
|
|
107
|
+
height: 16px;
|
|
108
|
+
border: 1px solid #ccc;
|
|
109
|
+
border-radius: 2px;
|
|
110
|
+
display: inline-block;
|
|
111
|
+
}
|
|
112
|
+
.legend-swatch--green { background-color: rgb(0, 160, 80); }
|
|
113
|
+
.legend-swatch--orange { background-color: rgb(255, 165, 0); }
|
|
114
|
+
.legend-swatch--stripe {
|
|
115
|
+
background: repeating-linear-gradient(
|
|
116
|
+
120deg,
|
|
117
|
+
rgb(100, 180, 100) 0px, rgb(100, 180, 100) 4px,
|
|
118
|
+
#f0f0f0 4px, #f0f0f0 5px
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
.legend-swatch--active { background: transparent; border: none; width: auto; height: auto; font-weight: 600; color: #1565c0; font-size: 11px; white-space: nowrap; }
|
|
122
|
+
.legend-swatch--neighbour { background: #fff; box-shadow: inset 0 0 0 1px #90a4ae; }
|
|
123
|
+
.legend-label-wide { flex-shrink: 0; white-space: nowrap; font-weight: 600; }
|
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 };
|