speedandcurrent 1.7.6 → 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 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 5 seconds.
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.
@@ -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.variance[0], 0], [0, boatSpeed.variance[1]]];
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;
@@ -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,17 +600,12 @@ module.exports = function (app) {
587
600
  }
588
601
 
589
602
  /**
590
- * Updates the correction table and calculates the residual error.
591
- *
592
- * This function uses the current smoothed attitude, boat speed, and heading to update the correction table
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 });
608
+ lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
601
609
  if (!smoothedAttitude.ready || !smoothedBoatSpeed.ready || !smoothedHeading.ready || !smoothedGroundSpeed.ready || (options.assumeCurrent ? !smoothedCurrent.ready : !noCurrent.ready)) return;
602
610
 
603
611
  // update correction table
@@ -608,37 +616,12 @@ module.exports = function (app) {
608
616
  }
609
617
 
610
618
  /**
611
- * Checks if the correction table in settings matches the expected row and column configuration.
612
- *
613
- * This function compares the min, max, and step values of the row and column definitions
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
- * This function checks if the correction table in the options matches the expected row and column configuration.
638
- * If so, it loads the table from JSON; otherwise, it creates a new correction table with the specified parameters.
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 the plugin options and logs the save event.
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
- * @returns {Date} The date and time when the table was saved.
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.6",
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.11.0"
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?.stale === true;
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}" — not available`;
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
- const el = document.createElement('link');
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 };