speedandcurrent 2.1.2 → 2.2.1

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
@@ -6,6 +6,8 @@ Most paddle wheel logs carry a systematic error that varies with boat speed and
6
6
 
7
7
  This plugin builds and maintains a **2-dimensional correction table** — indexed by speed and heel — that is populated automatically from real sailing observations. No calibration runs, no spreadsheets, no manual entry. Once the table has enough coverage the plugin applies per-point corrections to `navigation.speedThroughWater`, derives leeway from the lateral component of the correction, and estimates water current by subtracting the corrected boat speed vector from the GPS ground speed vector.
8
8
 
9
+ In the current implementation the plugin is also explicit about runtime state: if an input goes idle or stale, the webapp shows a warning and the handler resubscribes; when estimation is disabled or the plugin stops, the active output paths are cleared with `null` so downstream consumers do not keep stale values.
10
+
9
11
  In broad terms the plugin:
10
12
 
11
13
  - corrects paddle wheel speed using a learned, heel- and speed-aware correction table
package/index.js CHANGED
@@ -38,8 +38,7 @@ module.exports = function (app) {
38
38
  smootherTau: 3,
39
39
  smootherTimeSpan: 5,
40
40
  smootherSteadyState: 0.2,
41
- showStatistics: false,
42
- stalenessDetection: true
41
+ showStatistics: false
43
42
  };
44
43
 
45
44
 
@@ -140,6 +139,56 @@ module.exports = function (app) {
140
139
  let started = null;
141
140
  let minSpeed = 0;
142
141
  let lastSave = 0;
142
+ let lifecycleWarningMap = new Map();
143
+ let lifecycleWarnings = [];
144
+
145
+ function setLifecycleWarning(id, status, path) {
146
+ const safePath = path || 'unknown path';
147
+ const message = status === 'idle'
148
+ ? `Input ${id} is idle on ${safePath}; resubscribing`
149
+ : `Input ${id} is stale on ${safePath}`;
150
+ lifecycleWarningMap.set(id, {
151
+ id,
152
+ status,
153
+ path: safePath,
154
+ message,
155
+ updatedAt: Date.now()
156
+ });
157
+ lifecycleWarnings = Array.from(lifecycleWarningMap.values())
158
+ .sort((a, b) => b.updatedAt - a.updatedAt);
159
+ }
160
+
161
+ function clearLifecycleWarning(id) {
162
+ if (!lifecycleWarningMap.has(id)) return;
163
+ lifecycleWarningMap.delete(id);
164
+ lifecycleWarnings = Array.from(lifecycleWarningMap.values())
165
+ .sort((a, b) => b.updatedAt - a.updatedAt);
166
+ }
167
+
168
+ function buildLifecycleCallbacks(id, getPath, resubscribe) {
169
+ return {
170
+ onDelta: () => {
171
+ clearLifecycleWarning(id);
172
+ },
173
+ onStale: () => {
174
+ if (!isRunning) return;
175
+ const path = getPath();
176
+ app.debug(`[${plugin.id}] stale input ${id} on ${path}`);
177
+ setLifecycleWarning(id, 'stale', path);
178
+ },
179
+ onIdle: () => {
180
+ if (!isRunning) return;
181
+ const path = getPath();
182
+ app.debug(`[${plugin.id}] idle input ${id} on ${path}; resubscribing`);
183
+ setLifecycleWarning(id, 'idle', path);
184
+ try {
185
+ resubscribe();
186
+ } catch (e) {
187
+ app.debug(`[${plugin.id}] resubscribe failed for ${id}: ${e.message}`);
188
+ }
189
+ }
190
+ };
191
+ }
143
192
 
144
193
  const plugin = {};
145
194
  plugin.id = "SpeedAndCurrent";
@@ -161,7 +210,9 @@ module.exports = function (app) {
161
210
  if (!isRunning) {
162
211
  res.status(503).json({ error: "Plugin is not running" });
163
212
  } else {
164
- res.json(reportFull.report());
213
+ const payload = reportFull.report();
214
+ payload.lifecycleWarnings = lifecycleWarnings;
215
+ res.json(payload);
165
216
  }
166
217
  });
167
218
 
@@ -176,7 +227,7 @@ module.exports = function (app) {
176
227
 
177
228
 
178
229
  router.get('/api/status', (req, res) => {
179
- res.json({ status: pluginStatus, isRunning });
230
+ res.json({ status: pluginStatus, isRunning, lifecycleWarnings });
180
231
  });
181
232
 
182
233
  // --- Settings API ---
@@ -305,6 +356,8 @@ module.exports = function (app) {
305
356
  plugin.start = (settings) => {
306
357
  setStatus('Starting');
307
358
  app.debug("Starting");
359
+ lifecycleWarningMap = new Map();
360
+ lifecycleWarnings = [];
308
361
  readOptions(); // pick up any saves since registerWithRouter ran
309
362
  migrateConfig(); // strip obsolete fields from persisted config
310
363
  const tableName = options.tableName || 'correctionTable';
@@ -320,12 +373,13 @@ module.exports = function (app) {
320
373
  angleRange: '0to2pi',
321
374
  meta: { displayName: 'Heading', plane: 'Ground' },
322
375
  SmootherClass,
323
- smootherOptions
376
+ smootherOptions,
377
+ ...buildLifecycleCallbacks(
378
+ 'heading.angle',
379
+ () => smoothedHeading?.handler?.path || 'navigation.headingTrue',
380
+ () => { smoothedHeading?.unsubscribe(); smoothedHeading?.subscribe(false, true); }
381
+ )
324
382
  });
325
- // The magnitude handler has a fixed value of 1 and is never subscribed, so its timestamp
326
- // stays null forever. Staleness detection on it would always force _stale=true, blocking
327
- // processChanges() from setting polar._ready=true on every heading delta.
328
- smoothedHeading.polar.magnitudeHandler.stalenessDetection = false;
329
383
  rawHeading = smoothedHeading.handler;
330
384
 
331
385
  //attitude
@@ -335,7 +389,12 @@ module.exports = function (app) {
335
389
  path: 'navigation.attitude',
336
390
  subscribe: true,
337
391
  SmootherClass,
338
- smootherOptions
392
+ smootherOptions,
393
+ ...buildLifecycleCallbacks(
394
+ 'attitude.smoothed',
395
+ () => smoothedAttitude?.handler?.path || 'navigation.attitude',
396
+ () => { smoothedAttitude?.unsubscribe(); smoothedAttitude?.subscribe(); }
397
+ )
339
398
  });
340
399
  rawAttitude = smoothedAttitude.handler;
341
400
 
@@ -370,7 +429,6 @@ module.exports = function (app) {
370
429
  });
371
430
  noCurrent.xSmoother.reset(0,0);
372
431
  noCurrent.ySmoother.reset(0,0);
373
- noCurrent.stalenessDetection = false; // noCurrent is a fixed zero-vector, never receives live data
374
432
  PolarSmoother.send(app, plugin.id, [noCurrent]);
375
433
 
376
434
  MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
@@ -388,7 +446,49 @@ module.exports = function (app) {
388
446
  path: 'navigation.speedThroughWater',
389
447
  subscribe: true,
390
448
  SmootherClass,
391
- smootherOptions
449
+ smootherOptions,
450
+ onDelta: () => {
451
+ clearLifecycleWarning('boatSpeed.smoothed');
452
+ // Drain any pending option changes before calculating
453
+ if (Object.keys(changedOptions).length) applyOptionChanges();
454
+
455
+ const wellUnderway = started < new Date() - 60 * 1000;
456
+
457
+ setStatus(wellUnderway ? 'Running' : 'Stabilizing');
458
+ if (options.estimateBoatSpeed) correct(wellUnderway);
459
+ if (options.updateCorrectionTable) {
460
+ if (wellUnderway) {
461
+ updateTable();
462
+ // Save correction table periodically (skip if table is empty to avoid overwriting good data on disk)
463
+ const now = Date.now();
464
+ if (now - lastSave > 60_000) {
465
+ if (!isTableEmpty(table)) {
466
+ saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
467
+ }
468
+ lastSave = now;
469
+ }
470
+ } else {
471
+ table.lastUpdateResult = 'stabilizing';
472
+ }
473
+ } else {
474
+ table.lastUpdateResult = null;
475
+ }
476
+ }
477
+ ,
478
+ onIdle: () => {
479
+ if (!isRunning) return;
480
+ const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
481
+ app.debug(`[${plugin.id}] idle input boatSpeed.smoothed on ${path}; resubscribing`);
482
+ setLifecycleWarning('boatSpeed.smoothed', 'idle', path);
483
+ smoothedBoatSpeed?.unsubscribe();
484
+ smoothedBoatSpeed?.subscribe();
485
+ },
486
+ onStale: () => {
487
+ if (!isRunning) return;
488
+ const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
489
+ app.debug(`[${plugin.id}] stale input boatSpeed.smoothed on ${path}`);
490
+ setLifecycleWarning('boatSpeed.smoothed', 'stale', path);
491
+ }
392
492
  });
393
493
  rawBoatSpeed = smoothedBoatSpeed.handler;
394
494
 
@@ -419,7 +519,12 @@ module.exports = function (app) {
419
519
  angleRange: '0to2pi',
420
520
  meta: { displayName: 'Groundspeed', plane: 'Ground' },
421
521
  SmootherClass,
422
- smootherOptions
522
+ smootherOptions,
523
+ ...buildLifecycleCallbacks(
524
+ 'groundSpeed.smoothed',
525
+ () => `${smoothedGroundSpeed?.polar?.pathMagnitude || 'navigation.speedOverGround'}, ${smoothedGroundSpeed?.polar?.pathAngle || 'navigation.courseOverGroundTrue'}`,
526
+ () => { smoothedGroundSpeed?.unsubscribe(); smoothedGroundSpeed?.subscribe(true, true); }
527
+ )
423
528
  });
424
529
  rawGroundSpeed = smoothedGroundSpeed.polar;
425
530
 
@@ -466,50 +571,12 @@ module.exports = function (app) {
466
571
 
467
572
  //#endregion
468
573
 
469
- // Apply staleness detection to all subscribed smoother instances.
470
- // noCurrent is intentionally excluded — it is a fixed zero-vector and never receives live data.
471
- // Only apply when sd = false: all smoothers already default to stalenessDetection = true.
472
- // Explicitly calling the setter with true at startup forces _stale = true on handlers that have
473
- // never received data, causing the first sample to be dropped (library side-effect).
474
- const sd = options.stalenessDetection ?? true;
475
- if (!sd) {
476
- for (const s of [smoothedHeading, smoothedAttitude, smoothedBoatSpeed, smoothedGroundSpeed, smoothedCurrent, smoothedResidual]) {
477
- if (s) s.stalenessDetection = false;
478
- }
479
- }
480
-
481
574
  isRunning = true;
482
575
  started = Date.now();
483
576
  lastSave = 0;
484
577
  setStatus('Running');
485
578
  app.debug("Running");
486
579
 
487
- smoothedBoatSpeed.onChange = () => {
488
- // Drain any pending option changes before calculating
489
- if (Object.keys(changedOptions).length) applyOptionChanges();
490
-
491
- const wellUnderway = started < new Date() - 60 * 1000;
492
-
493
- setStatus(wellUnderway ? 'Running' : 'Stabilizing');
494
- if (options.estimateBoatSpeed) correct(wellUnderway);
495
- if (options.updateCorrectionTable) {
496
- if (wellUnderway) {
497
- updateTable();
498
- // Save correction table periodically (skip if table is empty to avoid overwriting good data on disk)
499
- const now = Date.now();
500
- if (now - lastSave > 60_000) {
501
- if (!isTableEmpty(table)) {
502
- saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
503
- }
504
- lastSave = now;
505
- }
506
- } else {
507
- table.lastUpdateResult = 'stabilizing';
508
- }
509
- } else {
510
- table.lastUpdateResult = null;
511
- }
512
- };
513
580
  }
514
581
 
515
582
  plugin.stop = () => {
@@ -546,6 +613,8 @@ module.exports = function (app) {
546
613
 
547
614
  pluginStatus = 'Stopped';
548
615
  isRunning = false;
616
+ lifecycleWarningMap = new Map();
617
+ lifecycleWarnings = [];
549
618
  resolve();
550
619
  } catch (error) {
551
620
  reject(error);
@@ -723,13 +792,7 @@ module.exports = function (app) {
723
792
  const value = changedOptions[key];
724
793
  options[key] = value;
725
794
 
726
- if (key === 'stalenessDetection') {
727
- const sdVal = Boolean(value);
728
- for (const s of [smoothedHeading, smoothedAttitude, smoothedBoatSpeed, smoothedGroundSpeed, smoothedCurrent, smoothedResidual]) {
729
- if (s) s.stalenessDetection = sdVal;
730
- }
731
- // noCurrent is always excluded from staleness detection
732
- } else if (key === 'updateCorrectionTable') {
795
+ if (key === 'updateCorrectionTable') {
733
796
  if (!value && table && !isTableEmpty(table)) {
734
797
  // Learning switched off — persist current state before periodic saves stop
735
798
  saveTableSync(table, path.join(app.getDataDirPath(), table.id + '.json'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speedandcurrent",
3
- "version": "2.1.2",
3
+ "version": "2.2.1",
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": {
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "kalman-filter": "^2.3.0",
41
- "signalkutilities": "^3.0.1"
41
+ "signalkutilities": "^3.1.0"
42
42
  },
43
43
  "files": [
44
44
  "icon.png",
package/public/app.js CHANGED
@@ -97,6 +97,7 @@ function normaliseState(data) {
97
97
 
98
98
  // ─── Static meta (fetched once at startup from /api/meta) ─────────────────────
99
99
  let metaById = {}; // keyed by item id
100
+ let lifecycleWarnings = [];
100
101
 
101
102
  async function loadMeta() {
102
103
  const data = await apiGet('/api/meta');
@@ -182,6 +183,29 @@ function renderWarnings(elId, ids) {
182
183
  el.appendChild(ul);
183
184
  }
184
185
 
186
+ function renderInputWarnings(elId, ids) {
187
+ const el = document.getElementById(elId);
188
+ if (!el) return;
189
+ el.innerHTML = '';
190
+ const lines = [];
191
+ lifecycleWarnings.forEach(w => {
192
+ if (w && typeof w.message === 'string') lines.push(w.message);
193
+ });
194
+ if (lines.length === 0) return;
195
+ const h = document.createElement('h6');
196
+ h.className = 'text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small';
197
+ h.textContent = 'Warnings';
198
+ el.appendChild(h);
199
+ const ul = document.createElement('ul');
200
+ ul.className = 'list-unstyled text-danger small ps-3';
201
+ lines.forEach(line => {
202
+ const li = document.createElement('li');
203
+ li.textContent = line;
204
+ ul.appendChild(li);
205
+ });
206
+ el.appendChild(ul);
207
+ }
208
+
185
209
  // ─── Config (live settings mirror) ───────────────────────────────────────────
186
210
  let config = null;
187
211
 
@@ -296,15 +320,15 @@ const paramMeta = {
296
320
  min: 0.01, max: 0.99, step: 0.01, default: 0.2,
297
321
  description: 'Steady-state Kalman gain (0 ≈ slow/smooth, 1 ≈ fast/raw). Clamped to 0.01–0.99.'
298
322
  },
299
- stalenessDetection: {
300
- label: 'Staleness detection',
301
- type: 'boolean',
302
- description: 'Mark inputs as unavailable when no data has been received for a period. '
303
- },
304
323
  };
305
324
 
306
325
  // Settings groups for each UI section
307
- const INPUTS_SETTING_KEYS = ['stalenessDetection'];
326
+ const INPUTS_SETTING_KEYS = [
327
+ 'smootherClass',
328
+ { key: 'smootherTau', showIf: cfg => cfg.smootherClass === 'ExponentialSmoother' },
329
+ { key: 'smootherTimeSpan', showIf: cfg => (cfg.smootherClass || 'MovingAverageSmoother') === 'MovingAverageSmoother' },
330
+ { key: 'smootherSteadyState', showIf: cfg => cfg.smootherClass === 'KalmanSmoother' },
331
+ ];
308
332
  const ESTIMATION_SETTING_KEYS = ['sogFallback'];
309
333
  const LEARNING_SETTING_KEYS = ['stability','assumeCurrent','showStatistics'];
310
334
  const SMOOTHER_SETTING_KEYS = [
@@ -511,12 +535,22 @@ function renderGroupInto(elId, polars, deltas, attitudes) {
511
535
  }
512
536
 
513
537
  function renderLiveSections() {
538
+ const inputPolars = filterById(state.polarsAll, ['groundSpeed']);
539
+ const inputDeltas = filterById(state.deltasAll, ['heading.angle', 'boatSpeed']);
540
+ const inputAttitudes = filterById(state.attitudesAll, ['attitude']);
541
+ const fallbackInputPolars = inputPolars.length ? inputPolars : filterById(state.polarsAll, ['groundSpeed.smoothed']);
542
+ const fallbackInputDeltas = inputDeltas.length ? inputDeltas : filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']);
543
+ const fallbackInputAttitudes = inputAttitudes.length ? inputAttitudes : filterById(state.attitudesAll, ['attitude.smoothed']);
544
+
514
545
  // Inputs section — raw sensor readings only (smoothing is internal to the plugin)
515
546
  renderGroupInto('inputs-values',
516
- filterById(state.polarsAll, ['groundSpeed']),
517
- filterById(state.deltasAll, ['heading.angle', 'boatSpeed']),
518
- filterById(state.attitudesAll, ['attitude'])
547
+ fallbackInputPolars,
548
+ fallbackInputDeltas,
549
+ fallbackInputAttitudes
519
550
  );
551
+ const inputWarningIds = ['boatSpeed', 'attitude', 'heading.angle', 'groundSpeed', 'boatSpeed.smoothed', 'groundSpeed.smoothed', 'heading.smoothed', 'attitude.smoothed'];
552
+ if (config && config.assumeCurrent) inputWarningIds.push('current.smoothed');
553
+ renderInputWarnings('inputs-warnings', inputWarningIds);
520
554
 
521
555
  // Estimation — inputs (raw sensor data used for boat speed estimation)
522
556
  renderGroupInto('estimation-inputs',
@@ -535,9 +569,8 @@ function renderLiveSections() {
535
569
  [], []
536
570
  );
537
571
  // Estimation — warnings
538
- renderWarnings('estimation-warnings',
539
- ['boatSpeed', 'attitude', 'heading.angle', 'groundSpeed']
540
- );
572
+ const estimationWarnings = document.getElementById('estimation-warnings');
573
+ if (estimationWarnings) estimationWarnings.innerHTML = '';
541
574
 
542
575
  // Learning — inputs: smoothed sensors + current if assumeCurrent
543
576
  const learningCurrentPolars = (config && config.assumeCurrent)
@@ -549,9 +582,8 @@ function renderLiveSections() {
549
582
  filterById(state.attitudesAll, ['attitude.smoothed'])
550
583
  );
551
584
  // Learning — warnings
552
- const learningWarningIds = ['boatSpeed.smoothed', 'groundSpeed.smoothed', 'heading.smoothed', 'attitude.smoothed'];
553
- if (config && config.assumeCurrent) learningWarningIds.push('current.smoothed');
554
- renderWarnings('learning-warnings', learningWarningIds);
585
+ const learningWarnings = document.getElementById('learning-warnings');
586
+ if (learningWarnings) learningWarnings.innerHTML = '';
555
587
  // Learning status section
556
588
  const statusTbody = document.querySelector('#learning-status-table tbody');
557
589
  if (statusTbody) {
@@ -620,6 +652,7 @@ async function tick() {
620
652
  .then(r => r.ok ? r.json() : null)
621
653
  .catch(() => null);
622
654
  _pluginStatus = statusData?.status ?? '';
655
+ lifecycleWarnings = Array.isArray(statusData?.lifecycleWarnings) ? statusData.lifecycleWarnings : [];
623
656
  _refreshMessage();
624
657
  }
625
658
 
package/public/index.html CHANGED
@@ -97,6 +97,7 @@
97
97
  </table>
98
98
  <h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">Live Values</h6>
99
99
  <div id="inputs-values"></div>
100
+ <div id="inputs-warnings"></div>
100
101
  </div>
101
102
  </div>
102
103
  </div>