speedandcurrent 2.3.3 → 2.3.4

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.
Files changed (3) hide show
  1. package/index.js +14 -5
  2. package/package.json +2 -2
  3. package/public/app.js +114 -98
package/index.js CHANGED
@@ -327,6 +327,8 @@ module.exports = function (app) {
327
327
  const safePath = path || 'unknown path';
328
328
  const message = status === 'idle'
329
329
  ? `Input ${id} is idle on ${safePath}; resubscribing`
330
+ : status === 'incomplete'
331
+ ? `Input ${id} is missing ${safePath}`
330
332
  : `Input ${id} is stale on ${safePath}`;
331
333
  lifecycleWarningMap.set(id, {
332
334
  id,
@@ -632,7 +634,7 @@ module.exports = function (app) {
632
634
  app, pluginId: plugin.id,
633
635
  id: 'boatSpeed',
634
636
  path: 'navigation.speedThroughWater',
635
- subscribe: true,
637
+ subscribe: false,
636
638
  SmootherClass,
637
639
  smootherOptions,
638
640
  onDelta: () => {
@@ -648,7 +650,7 @@ module.exports = function (app) {
648
650
  now: Date.now()
649
651
  });
650
652
 
651
- const wellUnderway = learningMode.state !== 'stabilizing';
653
+ const wellUnderway = Date.now() >= learningStabilizingUntil;
652
654
  setStatus(learningMode.state === 'stabilizing' ? 'Stabilizing' : 'Running');
653
655
  if (options.estimateBoatSpeed) correct(wellUnderway);
654
656
  updateTable();
@@ -766,6 +768,7 @@ module.exports = function (app) {
766
768
  lastNavigationStateValue = normalizeNavigationState(navigationStateHandler?.value);
767
769
  resetLearningStabilization('startup', LONG_STABILIZING_MS);
768
770
  setStatus('Running');
771
+ smoothedBoatSpeed.subscribe();
769
772
  app.debug("Running");
770
773
 
771
774
  }
@@ -841,7 +844,8 @@ module.exports = function (app) {
841
844
  if (options.sogFallback && rawGroundSpeed.ready && rawBoatSpeed.ready && rawBoatSpeed.value === 0 && rawGroundSpeed.magnitude >= minSpeed) {
842
845
  correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
843
846
  }
844
- else if (rawAttitude.ready) {
847
+ else if (rawAttitude.ready && Number.isFinite(rawAttitude.value?.roll)) {
848
+ clearLifecycleWarning('attitude.roll');
845
849
  if (correctedBoatSpeed.magnitude > 0) {
846
850
  const { correction, variance } = table.getCorrection(correctedBoatSpeed.magnitude, rawAttitude.value?.roll);
847
851
  const leewayValid = isLeewayValid(correctedBoatSpeed.magnitude, minSpeed, navigationStateHandler);
@@ -877,7 +881,10 @@ module.exports = function (app) {
877
881
  }
878
882
  }
879
883
  }
880
- // Implicit fallback: attitude not ready — correctedBoatSpeed = raw STW, no correction
884
+ // Implicit fallback: attitude not ready or missing roll — correctedBoatSpeed = raw STW, no correction
885
+ else if (rawAttitude.ready) {
886
+ setLifecycleWarning('attitude.roll', 'incomplete', 'navigation.attitude.roll');
887
+ }
881
888
 
882
889
  PolarSmoother.send(app, plugin.id, [smoothedCurrent, smoothedResidual]);
883
890
 
@@ -902,7 +909,8 @@ module.exports = function (app) {
902
909
  learningMode.state = 'suspended';
903
910
  learningMode.reason = 'cog_override';
904
911
  }
905
- const inputsReady = smoothedAttitude.ready && smoothedBoatSpeed.ready && smoothedHeading.ready && smoothedGroundSpeed.ready;
912
+ const inputsReady = smoothedAttitude.ready && Number.isFinite(smoothedAttitude.value?.roll)
913
+ && smoothedBoatSpeed.ready && smoothedHeading.ready && smoothedGroundSpeed.ready;
906
914
  const currentReady = !options.assumeCurrent || smoothedCurrent.ready;
907
915
  const observationGate = evaluateObservationGate({
908
916
  learningMode,
@@ -916,6 +924,7 @@ module.exports = function (app) {
916
924
 
917
925
  if (observationGate.state !== 'pending') {
918
926
  if (observationGate.state === 'invalid') {
927
+ setObservationStatus('rejected', observationGate.reason);
919
928
  resetLearningStabilization('observation_reset', getShortStabilizingMs(options));
920
929
  }
921
930
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speedandcurrent",
3
- "version": "2.3.3",
3
+ "version": "2.3.4",
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": {
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "kalman-filter": "^2.3.0",
44
- "signalkutilities": "^3.1.2"
44
+ "signalkutilities": "^3.1.5"
45
45
  },
46
46
  "files": [
47
47
  "icon.png",
package/public/app.js CHANGED
@@ -97,7 +97,6 @@ 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 = [];
101
100
 
102
101
  async function loadMeta() {
103
102
  const data = await apiGet('/api/meta');
@@ -117,90 +116,99 @@ async function loadMeta() {
117
116
  applyUnitLabels();
118
117
  }
119
118
 
120
- function isStale(item) {
121
- if (!item) return true;
122
- return item.state?.isStale === true;
123
- }
124
-
125
- function isNotReady(item) {
126
- if (!item) return true;
127
- return item.state?.ready !== true;
128
- }
129
-
130
- // Returns a human-readable reason why an item is not ready.
131
- // Handles three state shapes:
132
- // handler-nested (MessageSmoother, SmoothedAngle): state.handler.*
133
- // magnitude/angle (PolarSmoother): state.magnitude.* / state.angle.*
134
- // flat top-level (MessageHandler, Polar): state.*
135
- function getNotReadyReason(item) {
136
- if (!item) return 'not available';
137
- const s = item.state;
138
- if (!s) return 'not available';
119
+ const computedItemIds = new Set([
120
+ 'current.smoothed', 'boatSpeedRefGround', 'speedCorrection',
121
+ 'correctedBoatSpeed', 'residual', 'residual.smoothed'
122
+ ]);
139
123
 
140
- let subscribed, pathKnown;
141
- if (s.handler !== undefined) {
142
- subscribed = s.handler.subscribed;
143
- pathKnown = s.handler.pathKnown;
144
- } else if (s.magnitude !== undefined || s.angle !== undefined) {
145
- const mag = s.magnitude, ang = s.angle;
146
- subscribed = (mag?.subscribed !== false) && (ang?.subscribed !== false);
147
- pathKnown = (mag?.pathKnown !== false) && (ang?.pathKnown !== false);
148
- } else {
149
- subscribed = s.subscribed;
150
- pathKnown = s.pathKnown;
124
+ function getPathStatus(pathState, hasValue, computed = false, computedReady = false) {
125
+ if (computed) {
126
+ return hasValue && computedReady
127
+ ? { category: 'computed', reason: '' }
128
+ : { category: 'inactive', reason: 'not computed' };
129
+ }
130
+ if (pathState?.subscribed === false) {
131
+ return { category: 'inactive', reason: 'not subscribed' };
132
+ }
133
+ if (hasValue && pathState?.ready === true) {
134
+ return { category: 'subscribed', reason: '' };
135
+ }
136
+ if (!pathState) {
137
+ return { category: 'problem', reason: 'not available' };
138
+ }
139
+ if (pathState.pathKnown === false) {
140
+ return { category: 'problem', reason: 'path not found in Signal K' };
141
+ }
142
+ if (pathState.hasDelta === false) {
143
+ return { category: 'problem', reason: 'waiting for first data' };
144
+ }
145
+ if (pathState.isStale === true) {
146
+ return { category: 'problem', reason: 'data is stale' };
147
+ }
148
+ return { category: 'problem', reason: 'not available' };
149
+ }
150
+
151
+ function getScalarPathState(state) {
152
+ if (!state?.handler) return state;
153
+ return {
154
+ ...state,
155
+ subscribed: state.handler.subscribed,
156
+ pathKnown: state.handler.pathKnown,
157
+ hasDelta: state.hasDelta === true && state.handler.hasDelta === true,
158
+ isStale: state.isStale === true || state.handler.isStale === true,
159
+ ready: state.ready === true && state.handler.ready === true
160
+ };
161
+ }
162
+
163
+ function getItemStatuses(item) {
164
+ const label = itemLabel(item);
165
+ const computed = computedItemIds.has(item.id);
166
+ const computedReady = item.state?.ready === true;
167
+ if (state.polarsById[item.id] === item) {
168
+ return [
169
+ {
170
+ label: `${label} magnitude`,
171
+ status: getPathStatus(item.state?.magnitude, Number.isFinite(item.magnitude), computed, computedReady)
172
+ },
173
+ {
174
+ label: `${label} angle`,
175
+ status: getPathStatus(
176
+ item.state?.angle,
177
+ Number.isFinite(item.angle),
178
+ computed || item.state?.angleFallbackActive === true,
179
+ computedReady
180
+ )
181
+ }
182
+ ];
151
183
  }
152
184
 
153
- if (subscribed === false) return 'not subscribed to Signal K';
154
- if (pathKnown === false) return 'path not found in Signal K';
155
- if (!s.hasDelta) return 'waiting for first data';
156
- if (s.isStale) return 'data is stale';
157
- return 'not available';
158
- }
159
-
160
- function getAnyItem(id) {
161
- return state.polarsById[id] || state.deltasById[id] || state.attitudesById[id] || null;
185
+ const pathState = getScalarPathState(item.state);
186
+ const hasValue = state.attitudesById[item.id] === item
187
+ ? Number.isFinite(item.value?.roll) || Number.isFinite(item.value?.pitch)
188
+ : Number.isFinite(item.value);
189
+ return [{ label, status: getPathStatus(pathState, hasValue, computed, computedReady) }];
162
190
  }
163
191
 
164
- function renderWarnings(elId, ids) {
165
- const el = document.getElementById(elId);
166
- if (!el) return;
167
- el.innerHTML = '';
168
- const notReady = ids.filter(id => isNotReady(getAnyItem(id)));
169
- if (notReady.length === 0) return;
170
- const h = document.createElement('h6');
171
- h.className = 'text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small';
172
- h.textContent = 'Warnings';
173
- el.appendChild(h);
174
- const ul = document.createElement('ul');
175
- ul.className = 'list-unstyled text-danger small ps-3';
176
- notReady.forEach(id => {
177
- const item = getAnyItem(id);
178
- const label = metaById[id]?.displayName ?? id;
179
- const li = document.createElement('li');
180
- li.textContent = `"${label}" — ${getNotReadyReason(item)}`;
181
- ul.appendChild(li);
182
- });
183
- el.appendChild(ul);
192
+ function isAvailableStatus(status) {
193
+ return status.category === 'subscribed' || status.category === 'computed';
184
194
  }
185
195
 
186
- function renderInputWarnings(elId, ids) {
196
+ function renderWarnings(elId, items) {
187
197
  const el = document.getElementById(elId);
188
198
  if (!el) return;
189
199
  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;
200
+ const problems = items.flatMap(item => getItemStatuses(item))
201
+ .filter(({ status }) => status.category === 'problem');
202
+ if (problems.length === 0) return;
195
203
  const h = document.createElement('h6');
196
204
  h.className = 'text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small';
197
205
  h.textContent = 'Warnings';
198
206
  el.appendChild(h);
199
207
  const ul = document.createElement('ul');
200
208
  ul.className = 'list-unstyled text-danger small ps-3';
201
- lines.forEach(line => {
209
+ problems.forEach(({ label, status }) => {
202
210
  const li = document.createElement('li');
203
- li.textContent = line;
211
+ li.textContent = `"${label}" — ${status.reason}`;
204
212
  ul.appendChild(li);
205
213
  });
206
214
  el.appendChild(ul);
@@ -447,18 +455,24 @@ function renderSettingsPanel() {
447
455
 
448
456
  function formatPolarValue(p) {
449
457
  if (!p) return '—';
458
+ const statuses = getItemStatuses(p);
450
459
  const m = metaById[p.id];
451
460
  const speedC = buildConverter(m?.magnitude?.displayUnits) || DEFAULTS.speed;
452
461
  const angleC = buildConverter(m?.angle?.displayUnits) || DEFAULTS.angle;
453
- const spd = typeof p.magnitude === 'number' ? speedC.convert(p.magnitude).toFixed(speedC.decimals) : '—';
454
- const ang = typeof p.angle === 'number' ? angleC.convert(p.angle).toFixed(angleC.decimals) : '—';
455
- const sigma = (config?.showStatistics && p.id.endsWith('.smoothed') && typeof p.trace === 'number')
462
+ const spd = isAvailableStatus(statuses[0].status)
463
+ ? `${speedC.convert(p.magnitude).toFixed(speedC.decimals)} ${speedC.symbol}`
464
+ : statuses[0].status.reason;
465
+ const ang = isAvailableStatus(statuses[1].status)
466
+ ? `${angleC.convert(p.angle).toFixed(angleC.decimals)}${angleC.symbol}`
467
+ : statuses[1].status.reason;
468
+ const sigma = (isAvailableStatus(statuses[0].status) && config?.showStatistics && p.id.endsWith('.smoothed') && typeof p.trace === 'number')
456
469
  ? ` (σ=${speedC.convert(Math.sqrt(p.trace)).toFixed(speedC.decimals + 2)})` : '';
457
- return `${spd} ${speedC.symbol} / ${ang}${angleC.symbol}${sigma}`;
470
+ return `${spd}${sigma} / ${ang}`;
458
471
  }
459
472
 
460
473
  function formatDeltaValue(d) {
461
- if (!d || typeof d.value !== 'number') return '—';
474
+ const status = getItemStatuses(d)[0].status;
475
+ if (!isAvailableStatus(status)) return status.reason;
462
476
  const m = metaById[d.id];
463
477
  const uc = buildConverter(m?.displayUnits)
464
478
  || (m?.units === 'm/s' ? DEFAULTS.speed : DEFAULTS.angle);
@@ -468,6 +482,8 @@ function formatDeltaValue(d) {
468
482
  }
469
483
 
470
484
  function formatAttitudeValue(a) {
485
+ const status = getItemStatuses(a)[0].status;
486
+ if (!isAvailableStatus(status)) return status.reason;
471
487
  const v = (a && a.value) || {};
472
488
  const variance = a && a.variance;
473
489
  const m = metaById[a?.id];
@@ -490,7 +506,6 @@ function buildDataTable(rows) {
490
506
  const tbody = document.createElement('tbody');
491
507
  rows.forEach(row => {
492
508
  const tr = document.createElement('tr');
493
- if (row.stale) tr.className = 'stale';
494
509
  const tdL = document.createElement('td'); tdL.textContent = row.label;
495
510
  const tdV = document.createElement('td'); tdV.textContent = row.value;
496
511
  tr.appendChild(tdL); tr.appendChild(tdV);
@@ -518,9 +533,9 @@ function renderGroupInto(elId, polars, deltas, attitudes) {
518
533
  if (!el) return;
519
534
  el.innerHTML = '';
520
535
  const rows = [
521
- ...polars .map(p => ({ label: itemLabel(p), value: formatPolarValue(p), stale: isNotReady(p) })),
522
- ...deltas .map(d => ({ label: itemLabel(d), value: formatDeltaValue(d), stale: isNotReady(d) })),
523
- ...attitudes.map(a => ({ label: itemLabel(a), value: formatAttitudeValue(a), stale: isNotReady(a) }))
536
+ ...polars .map(p => ({ label: itemLabel(p), value: formatPolarValue(p) })),
537
+ ...deltas .map(d => ({ label: itemLabel(d), value: formatDeltaValue(d) })),
538
+ ...attitudes.map(a => ({ label: itemLabel(a), value: formatAttitudeValue(a) }))
524
539
  ];
525
540
  if (rows.length) el.appendChild(buildDataTable(rows));
526
541
  }
@@ -533,6 +548,7 @@ function renderLiveSections() {
533
548
  const fallbackInputPolars = inputPolars.length ? inputPolars : filterById(state.polarsAll, ['groundSpeed.smoothed']);
534
549
  const fallbackInputDeltas = inputDeltas.length ? inputDeltas : filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']);
535
550
  const fallbackInputAttitudes = inputAttitudes.length ? inputAttitudes : filterById(state.attitudesAll, ['attitude.smoothed']);
551
+ const displayedInputs = [...fallbackInputPolars, ...fallbackInputDeltas, ...fallbackInputAttitudes];
536
552
 
537
553
  // Inputs section — raw sensor readings only (smoothing is internal to the plugin)
538
554
  renderGroupInto('inputs-values',
@@ -540,43 +556,44 @@ function renderLiveSections() {
540
556
  fallbackInputDeltas,
541
557
  fallbackInputAttitudes
542
558
  );
543
- const inputWarningIds = ['boatSpeed', 'attitude', 'heading.angle', 'groundSpeed', 'boatSpeed.smoothed', 'groundSpeed.smoothed', 'heading.smoothed', 'attitude.smoothed'];
544
- if (config && config.assumeCurrent) inputWarningIds.push('current.smoothed');
545
- renderInputWarnings('inputs-warnings', inputWarningIds);
559
+ renderWarnings('inputs-warnings', displayedInputs);
546
560
 
547
561
  // Estimation — inputs (raw sensor data used for boat speed estimation)
562
+ const estimationInputs = [
563
+ ...filterById(state.polarsAll, ['groundSpeed']),
564
+ ...filterById(state.deltasAll, ['heading.angle', 'boatSpeed']),
565
+ ...filterById(state.attitudesAll, ['attitude'])
566
+ ];
548
567
  renderGroupInto('estimation-inputs',
549
- filterById(state.polarsAll, ['groundSpeed']),
550
- filterById(state.deltasAll, ['heading.angle', 'boatSpeed']),
551
- filterById(state.attitudesAll, ['attitude'])
568
+ estimationInputs.filter(item => state.polarsById[item.id] === item),
569
+ estimationInputs.filter(item => state.deltasById[item.id] === item),
570
+ estimationInputs.filter(item => state.attitudesById[item.id] === item)
552
571
  );
553
572
  // Estimation — intermediates
554
- renderGroupInto('estimation-intermediates',
555
- filterById(state.polarsAll, ['boatSpeedRefGround', 'speedCorrection', 'residual', 'residual.smoothed']),
556
- [], []
557
- );
573
+ const estimationIntermediates = filterById(state.polarsAll, ['boatSpeedRefGround', 'speedCorrection', 'residual', 'residual.smoothed']);
574
+ renderGroupInto('estimation-intermediates', estimationIntermediates, [], []);
558
575
  // Estimation — outputs
559
- renderGroupInto('estimation-outputs',
560
- filterById(state.polarsAll, ['correctedBoatSpeed', 'current.smoothed']),
561
- [], []
562
- );
576
+ const estimationOutputs = filterById(state.polarsAll, ['correctedBoatSpeed', 'current.smoothed']);
577
+ renderGroupInto('estimation-outputs', estimationOutputs, [], []);
563
578
  // Estimation — warnings
564
- const estimationWarnings = document.getElementById('estimation-warnings');
565
- if (estimationWarnings) estimationWarnings.innerHTML = '';
579
+ renderWarnings('estimation-warnings', [...estimationInputs, ...estimationIntermediates, ...estimationOutputs]);
566
580
 
567
581
  // Learning — inputs: smoothed sensors + current if assumeCurrent
568
582
  const learningCurrentPolars = (config && config.assumeCurrent)
569
583
  ? filterById(state.polarsAll, ['current.smoothed'])
570
584
  : [];
585
+ const learningPolars = [...filterById(state.polarsAll, ['groundSpeed.smoothed']), ...learningCurrentPolars];
586
+ const learningDeltas = filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']);
587
+ const learningAttitudes = filterById(state.attitudesAll, ['attitude.smoothed']);
571
588
  renderGroupInto('learning-inputs',
572
- [...filterById(state.polarsAll, ['groundSpeed.smoothed']), ...learningCurrentPolars],
573
- filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']),
574
- filterById(state.attitudesAll, ['attitude.smoothed'])
589
+ learningPolars,
590
+ learningDeltas,
591
+ learningAttitudes
575
592
  );
576
593
  // Learning — warnings
577
594
  const learningWarnings = document.getElementById('learning-warnings');
595
+ renderWarnings('learning-warnings', [...learningPolars, ...learningDeltas, ...learningAttitudes]);
578
596
  if (learningWarnings) {
579
- learningWarnings.innerHTML = '';
580
597
  const navState = learningState?.navigationState;
581
598
  if (navState?.enabled && navState.pathKnown === false) {
582
599
  const ul = document.createElement('ul');
@@ -675,7 +692,6 @@ async function tick() {
675
692
  .then(r => r.ok ? r.json() : null)
676
693
  .catch(() => null);
677
694
  _pluginStatus = statusData?.status ?? '';
678
- lifecycleWarnings = Array.isArray(statusData?.lifecycleWarnings) ? statusData.lifecycleWarnings : [];
679
695
  _refreshMessage();
680
696
  }
681
697