speedandcurrent 2.3.2 → 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 +31 -6
  2. package/package.json +2 -2
  3. package/public/app.js +114 -98
package/index.js CHANGED
@@ -39,6 +39,17 @@ function isCogOverrideActive(groundSpeedPolar, speedThreshold) {
39
39
  && sogHandler.value < speedThreshold;
40
40
  }
41
41
 
42
+ /**
43
+ * Leeway is the angle of the corrected boatspeed vector, so a small lateral
44
+ * correction divided by a near-zero magnitude yields a large, meaningless angle.
45
+ */
46
+ function isLeewayValid(speed, speedThreshold, navigationStateHandler) {
47
+ if (!(speed >= speedThreshold)) return false;
48
+ const state = navigationStateHandler?.state;
49
+ if (state?.ready !== true) return true;
50
+ return !ALWAYS_BLOCKING_NAVIGATION_STATES.has(normalizeNavigationState(navigationStateHandler.value));
51
+ }
52
+
42
53
  function evaluateLearningMode({ options = {}, navigationState, stabilizingUntil = 0, stabilizingReason = null, now = Date.now() }) {
43
54
  const normalizedNavigationState = normalizeNavigationState(navigationState?.value);
44
55
  const navigationGateReady = navigationState?.ready === true;
@@ -316,6 +327,8 @@ module.exports = function (app) {
316
327
  const safePath = path || 'unknown path';
317
328
  const message = status === 'idle'
318
329
  ? `Input ${id} is idle on ${safePath}; resubscribing`
330
+ : status === 'incomplete'
331
+ ? `Input ${id} is missing ${safePath}`
319
332
  : `Input ${id} is stale on ${safePath}`;
320
333
  lifecycleWarningMap.set(id, {
321
334
  id,
@@ -621,7 +634,7 @@ module.exports = function (app) {
621
634
  app, pluginId: plugin.id,
622
635
  id: 'boatSpeed',
623
636
  path: 'navigation.speedThroughWater',
624
- subscribe: true,
637
+ subscribe: false,
625
638
  SmootherClass,
626
639
  smootherOptions,
627
640
  onDelta: () => {
@@ -637,7 +650,7 @@ module.exports = function (app) {
637
650
  now: Date.now()
638
651
  });
639
652
 
640
- const wellUnderway = learningMode.state !== 'stabilizing';
653
+ const wellUnderway = Date.now() >= learningStabilizingUntil;
641
654
  setStatus(learningMode.state === 'stabilizing' ? 'Stabilizing' : 'Running');
642
655
  if (options.estimateBoatSpeed) correct(wellUnderway);
643
656
  updateTable();
@@ -755,6 +768,7 @@ module.exports = function (app) {
755
768
  lastNavigationStateValue = normalizeNavigationState(navigationStateHandler?.value);
756
769
  resetLearningStabilization('startup', LONG_STABILIZING_MS);
757
770
  setStatus('Running');
771
+ smoothedBoatSpeed.subscribe();
758
772
  app.debug("Running");
759
773
 
760
774
  }
@@ -830,10 +844,15 @@ module.exports = function (app) {
830
844
  if (options.sogFallback && rawGroundSpeed.ready && rawBoatSpeed.ready && rawBoatSpeed.value === 0 && rawGroundSpeed.magnitude >= minSpeed) {
831
845
  correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
832
846
  }
833
- else if (rawAttitude.ready) {
847
+ else if (rawAttitude.ready && Number.isFinite(rawAttitude.value?.roll)) {
848
+ clearLifecycleWarning('attitude.roll');
834
849
  if (correctedBoatSpeed.magnitude > 0) {
835
850
  const { correction, variance } = table.getCorrection(correctedBoatSpeed.magnitude, rawAttitude.value?.roll);
836
- speedCorrection.setVectorValue(correction, variance);
851
+ const leewayValid = isLeewayValid(correctedBoatSpeed.magnitude, minSpeed, navigationStateHandler);
852
+ speedCorrection.setVectorValue(
853
+ { x: correction.x, y: leewayValid ? correction.y : 0 },
854
+ { x: variance.x, y: leewayValid ? variance.y : 0 }
855
+ );
837
856
  correctedBoatSpeed.add(speedCorrection);
838
857
  }
839
858
  // Current estimation and residual also require heading (to rotate into ground frame).
@@ -862,7 +881,10 @@ module.exports = function (app) {
862
881
  }
863
882
  }
864
883
  }
865
- // 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
+ }
866
888
 
867
889
  PolarSmoother.send(app, plugin.id, [smoothedCurrent, smoothedResidual]);
868
890
 
@@ -887,7 +909,8 @@ module.exports = function (app) {
887
909
  learningMode.state = 'suspended';
888
910
  learningMode.reason = 'cog_override';
889
911
  }
890
- 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;
891
914
  const currentReady = !options.assumeCurrent || smoothedCurrent.ready;
892
915
  const observationGate = evaluateObservationGate({
893
916
  learningMode,
@@ -901,6 +924,7 @@ module.exports = function (app) {
901
924
 
902
925
  if (observationGate.state !== 'pending') {
903
926
  if (observationGate.state === 'invalid') {
927
+ setObservationStatus('rejected', observationGate.reason);
904
928
  resetLearningStabilization('observation_reset', getShortStabilizingMs(options));
905
929
  }
906
930
  return;
@@ -1050,6 +1074,7 @@ module.exports._test = {
1050
1074
  getShortStabilizingMs,
1051
1075
  evaluateLearningMode,
1052
1076
  isCogOverrideActive,
1077
+ isLeewayValid,
1053
1078
  evaluateObservationGate,
1054
1079
  getDerivedObservationStatus,
1055
1080
  buildNavigationStateStatus,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speedandcurrent",
3
- "version": "2.3.2",
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.1"
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