speedandcurrent 2.3.3 → 2.4.0
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 +74 -45
- package/correctionTable.js +296 -66
- package/docs/correction-table-learning.md +33 -0
- package/docs/correction-table-v1.schema.json +219 -0
- package/docs/correction-table-v2.schema.json +134 -0
- package/docs/correction-table.schema.json +219 -0
- package/index.js +299 -132
- package/package.json +2 -2
- package/public/TableRenderer.js +89 -14
- package/public/app.js +230 -166
- package/public/index.html +14 -6
- package/public/main.css +19 -5
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return
|
|
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) }];
|
|
158
190
|
}
|
|
159
191
|
|
|
160
|
-
function
|
|
161
|
-
return
|
|
192
|
+
function isAvailableStatus(status) {
|
|
193
|
+
return status.category === 'subscribed' || status.category === 'computed';
|
|
162
194
|
}
|
|
163
195
|
|
|
164
|
-
function renderWarnings(elId,
|
|
196
|
+
function renderWarnings(elId, items) {
|
|
165
197
|
const el = document.getElementById(elId);
|
|
166
198
|
if (!el) return;
|
|
167
199
|
el.innerHTML = '';
|
|
168
|
-
const
|
|
169
|
-
|
|
200
|
+
const problems = items.flatMap(item => getItemStatuses(item))
|
|
201
|
+
.filter(({ status }) => status.category === 'problem');
|
|
202
|
+
if (problems.length === 0) return;
|
|
170
203
|
const h = document.createElement('h6');
|
|
171
204
|
h.className = 'text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small';
|
|
172
205
|
h.textContent = 'Warnings';
|
|
173
206
|
el.appendChild(h);
|
|
174
207
|
const ul = document.createElement('ul');
|
|
175
208
|
ul.className = 'list-unstyled text-danger small ps-3';
|
|
176
|
-
|
|
177
|
-
const item = getAnyItem(id);
|
|
178
|
-
const label = metaById[id]?.displayName ?? id;
|
|
209
|
+
problems.forEach(({ label, status }) => {
|
|
179
210
|
const li = document.createElement('li');
|
|
180
|
-
li.textContent = `"${label}" — ${
|
|
181
|
-
ul.appendChild(li);
|
|
182
|
-
});
|
|
183
|
-
el.appendChild(ul);
|
|
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;
|
|
211
|
+
li.textContent = `"${label}" — ${status.reason}`;
|
|
204
212
|
ul.appendChild(li);
|
|
205
213
|
});
|
|
206
214
|
el.appendChild(ul);
|
|
@@ -227,7 +235,12 @@ async function apiGet(path) {
|
|
|
227
235
|
const ct = res.headers.get('content-type') || '';
|
|
228
236
|
if (!ct.includes('application/json')) { showMessage('Unexpected server response.'); return null; }
|
|
229
237
|
showMessage('');
|
|
230
|
-
|
|
238
|
+
try {
|
|
239
|
+
return await res.json();
|
|
240
|
+
} catch (err) {
|
|
241
|
+
showMessage('Unexpected server response.');
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
231
244
|
}
|
|
232
245
|
|
|
233
246
|
// PUT /api/settings: server returns full merged config — store it and re-render.
|
|
@@ -289,46 +302,21 @@ function _refreshMessage() {
|
|
|
289
302
|
const paramMeta = {
|
|
290
303
|
estimateBoatSpeed: { label: 'Estimate boat speed', type: 'boolean' },
|
|
291
304
|
updateCorrectionTable: { label: 'Update correction table', type: 'boolean' },
|
|
292
|
-
suspendLearningOnNavigationState: { label: 'Suspend on navigation.state
|
|
293
|
-
assumeCurrent: { label: 'Assume current during update', type: 'boolean', description: 'Experimental, works best when currents are relatively stable.' },
|
|
305
|
+
suspendLearningOnNavigationState: { label: 'Suspend on navigation.state', type: 'boolean', description: 'When enabled, navigation.state can override the vessel\u2019s own SOG-based moving/not-moving state: anchored, moored, or motoring are treated as not moving (learning suspended, leeway zeroed). It can never force "moving" when SOG says otherwise.' },
|
|
294
306
|
sogFallback: { label: 'Groundspeed fallback', type: 'boolean', description: 'Output Groundspeed as Boatspeed when the paddlewheel sensor is malfunctioning or stalled.' },
|
|
295
|
-
|
|
307
|
+
correctionDriftRate: { label: 'Correction drift', type: 'number', unit: 'knots/month', min: 0, max: 3, step: 0.1, default: 0.3, description: 'How quickly the correction may change over time (knots/month). 0.3 knots/month means the correction can typically change by about 0.3 knots over a month.' },
|
|
296
308
|
showStatistics: { label: 'Show statistics (σ)', type: 'boolean', description: 'Display standard deviation alongside smoothed values for debugging.' },
|
|
297
|
-
smootherClass: {
|
|
298
|
-
label: 'Smoother type', type: 'select',
|
|
299
|
-
description: 'Smoothing applied to all sensor inputs for learning only.',
|
|
300
|
-
options: [
|
|
301
|
-
{ value: 'MovingAverageSmoother', label: 'Moving average (window)' },
|
|
302
|
-
{ value: 'ExponentialSmoother', label: 'Exponential (τ)' },
|
|
303
|
-
{ value: 'KalmanSmoother', label: 'Kalman filter' },
|
|
304
|
-
]
|
|
305
|
-
},
|
|
306
|
-
smootherTau: {
|
|
307
|
-
label: 'Time constant (τ)', type: 'number', unit: 's',
|
|
308
|
-
min: 1, max: 60, step: 0.5, default: 3,
|
|
309
|
-
description: 'Time constant in seconds.'
|
|
310
|
-
},
|
|
311
309
|
smootherTimeSpan: {
|
|
312
310
|
label: 'Window size', type: 'number', unit: 's',
|
|
313
|
-
min:
|
|
314
|
-
description: 'Moving-average window in seconds.'
|
|
315
|
-
},
|
|
316
|
-
smootherSteadyState: {
|
|
317
|
-
label: 'Kalman gain', type: 'number',
|
|
318
|
-
min: 0.01, max: 0.99, step: 0.01, default: 0.2,
|
|
319
|
-
description: 'Steady-state Kalman gain (0 ≈ slow/smooth, 1 ≈ fast/raw). Clamped to 0.01–0.99.'
|
|
311
|
+
min: 5, max: 60, step: 0.5, default: 5,
|
|
312
|
+
description: 'Moving-average window in seconds. Learning runs once per window plus a one-second gap.'
|
|
320
313
|
},
|
|
321
314
|
};
|
|
322
315
|
|
|
323
316
|
// Settings groups for each UI section
|
|
324
317
|
const ESTIMATION_SETTING_KEYS = ['sogFallback'];
|
|
325
|
-
const LEARNING_SETTING_KEYS = ['
|
|
326
|
-
const SMOOTHER_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
|
-
];
|
|
318
|
+
const LEARNING_SETTING_KEYS = ['correctionDriftRate', 'suspendLearningOnNavigationState', 'showStatistics'];
|
|
319
|
+
const SMOOTHER_SETTING_KEYS = ['smootherTimeSpan'];
|
|
332
320
|
|
|
333
321
|
// Build one settings control for a key.
|
|
334
322
|
function createSettingControl(key, meta, value) {
|
|
@@ -447,18 +435,24 @@ function renderSettingsPanel() {
|
|
|
447
435
|
|
|
448
436
|
function formatPolarValue(p) {
|
|
449
437
|
if (!p) return '—';
|
|
438
|
+
const statuses = getItemStatuses(p);
|
|
450
439
|
const m = metaById[p.id];
|
|
451
440
|
const speedC = buildConverter(m?.magnitude?.displayUnits) || DEFAULTS.speed;
|
|
452
441
|
const angleC = buildConverter(m?.angle?.displayUnits) || DEFAULTS.angle;
|
|
453
|
-
const spd =
|
|
454
|
-
|
|
455
|
-
|
|
442
|
+
const spd = isAvailableStatus(statuses[0].status)
|
|
443
|
+
? `${speedC.convert(p.magnitude).toFixed(speedC.decimals)} ${speedC.symbol}`
|
|
444
|
+
: statuses[0].status.reason;
|
|
445
|
+
const ang = isAvailableStatus(statuses[1].status)
|
|
446
|
+
? `${angleC.convert(p.angle).toFixed(angleC.decimals)}${angleC.symbol}`
|
|
447
|
+
: statuses[1].status.reason;
|
|
448
|
+
const sigma = (isAvailableStatus(statuses[0].status) && config?.showStatistics && p.id.endsWith('.smoothed') && typeof p.trace === 'number')
|
|
456
449
|
? ` (σ=${speedC.convert(Math.sqrt(p.trace)).toFixed(speedC.decimals + 2)})` : '';
|
|
457
|
-
return `${spd}
|
|
450
|
+
return `${spd}${sigma} / ${ang}`;
|
|
458
451
|
}
|
|
459
452
|
|
|
460
453
|
function formatDeltaValue(d) {
|
|
461
|
-
|
|
454
|
+
const status = getItemStatuses(d)[0].status;
|
|
455
|
+
if (!isAvailableStatus(status)) return status.reason;
|
|
462
456
|
const m = metaById[d.id];
|
|
463
457
|
const uc = buildConverter(m?.displayUnits)
|
|
464
458
|
|| (m?.units === 'm/s' ? DEFAULTS.speed : DEFAULTS.angle);
|
|
@@ -468,6 +462,8 @@ function formatDeltaValue(d) {
|
|
|
468
462
|
}
|
|
469
463
|
|
|
470
464
|
function formatAttitudeValue(a) {
|
|
465
|
+
const status = getItemStatuses(a)[0].status;
|
|
466
|
+
if (!isAvailableStatus(status)) return status.reason;
|
|
471
467
|
const v = (a && a.value) || {};
|
|
472
468
|
const variance = a && a.variance;
|
|
473
469
|
const m = metaById[a?.id];
|
|
@@ -484,13 +480,21 @@ function itemLabel(item) {
|
|
|
484
480
|
return metaById[item.id]?.displayName ?? item.path ?? item.id;
|
|
485
481
|
}
|
|
486
482
|
|
|
483
|
+
function formatNavigationStateValue(navState) {
|
|
484
|
+
if (!navState) return '—';
|
|
485
|
+
if (navState.pathKnown === false) return 'not available';
|
|
486
|
+
// Use `value` rather than `ready` — navigation.state changes infrequently, so it goes
|
|
487
|
+
// stale (ready=false) between changes even though the last known value is still valid.
|
|
488
|
+
if (navState.value == null) return 'not ready';
|
|
489
|
+
return navState.isStale ? `${navState.value} (stale)` : navState.value;
|
|
490
|
+
}
|
|
491
|
+
|
|
487
492
|
function buildDataTable(rows) {
|
|
488
493
|
const tbl = document.createElement('table');
|
|
489
494
|
tbl.className = 'table table-sm table-borderless mb-0';
|
|
490
495
|
const tbody = document.createElement('tbody');
|
|
491
496
|
rows.forEach(row => {
|
|
492
497
|
const tr = document.createElement('tr');
|
|
493
|
-
if (row.stale) tr.className = 'stale';
|
|
494
498
|
const tdL = document.createElement('td'); tdL.textContent = row.label;
|
|
495
499
|
const tdV = document.createElement('td'); tdV.textContent = row.value;
|
|
496
500
|
tr.appendChild(tdL); tr.appendChild(tdV);
|
|
@@ -513,18 +517,46 @@ function filterById(arr, ids) {
|
|
|
513
517
|
});
|
|
514
518
|
}
|
|
515
519
|
|
|
516
|
-
function renderGroupInto(elId, polars, deltas, attitudes) {
|
|
520
|
+
function renderGroupInto(elId, polars, deltas, attitudes, extraRows = []) {
|
|
517
521
|
const el = document.getElementById(elId);
|
|
518
522
|
if (!el) return;
|
|
519
523
|
el.innerHTML = '';
|
|
520
524
|
const rows = [
|
|
521
|
-
...polars .map(p => ({ label: itemLabel(p), value: formatPolarValue(p)
|
|
522
|
-
...deltas .map(d => ({ label: itemLabel(d), value: formatDeltaValue(d)
|
|
523
|
-
...attitudes.map(a => ({ label: itemLabel(a), value: formatAttitudeValue(a)
|
|
525
|
+
...polars .map(p => ({ label: itemLabel(p), value: formatPolarValue(p) })),
|
|
526
|
+
...deltas .map(d => ({ label: itemLabel(d), value: formatDeltaValue(d) })),
|
|
527
|
+
...attitudes.map(a => ({ label: itemLabel(a), value: formatAttitudeValue(a) })),
|
|
528
|
+
...extraRows
|
|
524
529
|
];
|
|
525
530
|
if (rows.length) el.appendChild(buildDataTable(rows));
|
|
526
531
|
}
|
|
527
532
|
|
|
533
|
+
function renderItemsInto(elId, items) {
|
|
534
|
+
const el = document.getElementById(elId);
|
|
535
|
+
if (!el) return;
|
|
536
|
+
el.innerHTML = '';
|
|
537
|
+
const rows = items.map(item => ({
|
|
538
|
+
label: itemLabel(item),
|
|
539
|
+
value: state.polarsById[item.id] === item
|
|
540
|
+
? formatPolarValue(item)
|
|
541
|
+
: state.deltasById[item.id] === item
|
|
542
|
+
? formatDeltaValue(item)
|
|
543
|
+
: formatAttitudeValue(item)
|
|
544
|
+
}));
|
|
545
|
+
if (rows.length) el.appendChild(buildDataTable(rows));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function formatSpatialVariance(table) {
|
|
549
|
+
if (!Number.isFinite(table?.q)) return null;
|
|
550
|
+
const pairCount = Number.isInteger(table.qPairCount) ? table.qPairCount : 0;
|
|
551
|
+
const rawEstimate = Number.isFinite(table.qEstimate)
|
|
552
|
+
? `; raw estimate ${table.qEstimate.toExponential(3)} from ${pairCount} adjacent pairs`
|
|
553
|
+
: `; ${pairCount} eligible adjacent pairs`;
|
|
554
|
+
const source = table.qSource === 'derived'
|
|
555
|
+
? `derived from ${pairCount} adjacent pairs`
|
|
556
|
+
: `fallback${rawEstimate}`;
|
|
557
|
+
return `${table.q.toExponential(3)} (${source})`;
|
|
558
|
+
}
|
|
559
|
+
|
|
528
560
|
function renderLiveSections() {
|
|
529
561
|
const learningState = state.learningState || null;
|
|
530
562
|
const inputPolars = filterById(state.polarsAll, ['groundSpeed']);
|
|
@@ -533,50 +565,57 @@ function renderLiveSections() {
|
|
|
533
565
|
const fallbackInputPolars = inputPolars.length ? inputPolars : filterById(state.polarsAll, ['groundSpeed.smoothed']);
|
|
534
566
|
const fallbackInputDeltas = inputDeltas.length ? inputDeltas : filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']);
|
|
535
567
|
const fallbackInputAttitudes = inputAttitudes.length ? inputAttitudes : filterById(state.attitudesAll, ['attitude.smoothed']);
|
|
568
|
+
const displayedInputs = [...fallbackInputPolars, ...fallbackInputDeltas, ...fallbackInputAttitudes];
|
|
536
569
|
|
|
537
570
|
// Inputs section — raw sensor readings only (smoothing is internal to the plugin)
|
|
571
|
+
const navigationStateRows = [{ label: 'navigation.state', value: formatNavigationStateValue(learningState?.navigationState) }];
|
|
538
572
|
renderGroupInto('inputs-values',
|
|
539
573
|
fallbackInputPolars,
|
|
540
574
|
fallbackInputDeltas,
|
|
541
|
-
fallbackInputAttitudes
|
|
575
|
+
fallbackInputAttitudes,
|
|
576
|
+
navigationStateRows
|
|
542
577
|
);
|
|
543
|
-
|
|
544
|
-
if (config && config.assumeCurrent) inputWarningIds.push('current.smoothed');
|
|
545
|
-
renderInputWarnings('inputs-warnings', inputWarningIds);
|
|
578
|
+
renderWarnings('inputs-warnings', displayedInputs);
|
|
546
579
|
|
|
547
580
|
// Estimation — inputs (raw sensor data used for boat speed estimation)
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
581
|
+
const groundSpeedInputs = filterById(state.polarsAll, ['groundSpeed']);
|
|
582
|
+
const boatSpeedInputs = filterById(state.deltasAll, ['boatSpeed']);
|
|
583
|
+
const estimationInputs = config?.sogFallback
|
|
584
|
+
? [...groundSpeedInputs, ...boatSpeedInputs]
|
|
585
|
+
: [...boatSpeedInputs, ...groundSpeedInputs];
|
|
586
|
+
estimationInputs.push(
|
|
587
|
+
...filterById(state.deltasAll, ['heading.angle']),
|
|
588
|
+
...filterById(state.attitudesAll, ['attitude'])
|
|
552
589
|
);
|
|
590
|
+
renderItemsInto('estimation-inputs', estimationInputs);
|
|
553
591
|
// Estimation — intermediates
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
592
|
+
const estimationIntermediates = filterById(state.polarsAll, ['boatSpeedRefGround', 'speedCorrection', 'residual', 'residual.smoothed']);
|
|
593
|
+
const spatialVariance = config?.showStatistics
|
|
594
|
+
? formatSpatialVariance(Object.values(state.tablesById)[0])
|
|
595
|
+
: null;
|
|
596
|
+
const statisticRows = spatialVariance === null
|
|
597
|
+
? []
|
|
598
|
+
: [{ label: 'Spatial variance q', value: spatialVariance }];
|
|
599
|
+
renderGroupInto('estimation-intermediates', estimationIntermediates, [], [], statisticRows);
|
|
558
600
|
// Estimation — outputs
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
[], []
|
|
562
|
-
);
|
|
601
|
+
const estimationOutputs = filterById(state.polarsAll, ['correctedBoatSpeed', 'current.smoothed']);
|
|
602
|
+
renderGroupInto('estimation-outputs', estimationOutputs, [], []);
|
|
563
603
|
// Estimation — warnings
|
|
564
|
-
|
|
565
|
-
if (estimationWarnings) estimationWarnings.innerHTML = '';
|
|
604
|
+
renderWarnings('estimation-warnings', [...estimationInputs, ...estimationIntermediates, ...estimationOutputs]);
|
|
566
605
|
|
|
567
|
-
// Learning — inputs
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
606
|
+
// Learning — inputs
|
|
607
|
+
const learningPolars = filterById(state.polarsAll, ['groundSpeed.smoothed']);
|
|
608
|
+
const learningDeltas = filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']);
|
|
609
|
+
const learningAttitudes = filterById(state.attitudesAll, ['attitude.smoothed']);
|
|
571
610
|
renderGroupInto('learning-inputs',
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
611
|
+
learningPolars,
|
|
612
|
+
learningDeltas,
|
|
613
|
+
learningAttitudes
|
|
575
614
|
);
|
|
576
615
|
// Learning — warnings
|
|
577
616
|
const learningWarnings = document.getElementById('learning-warnings');
|
|
617
|
+
renderWarnings('learning-warnings', [...learningPolars, ...learningDeltas, ...learningAttitudes]);
|
|
578
618
|
if (learningWarnings) {
|
|
579
|
-
learningWarnings.innerHTML = '';
|
|
580
619
|
const navState = learningState?.navigationState;
|
|
581
620
|
if (navState?.enabled && navState.pathKnown === false) {
|
|
582
621
|
const ul = document.createElement('ul');
|
|
@@ -607,7 +646,7 @@ function renderLiveSections() {
|
|
|
607
646
|
startup: 'Startup stabilising window',
|
|
608
647
|
observation_reset: 'Recent rejected or invalid observation',
|
|
609
648
|
nav_state_change: 'navigation.state changed',
|
|
610
|
-
|
|
649
|
+
not_moving: 'Vessel not moving',
|
|
611
650
|
cog_override: 'Blocked by COG override',
|
|
612
651
|
accepted: 'Observation recorded',
|
|
613
652
|
estimator_outlier: 'Estimator rejected observation',
|
|
@@ -615,7 +654,7 @@ function renderLiveSections() {
|
|
|
615
654
|
missing_current_when_required: 'Current estimate unavailable',
|
|
616
655
|
learning_off: 'Learning is off',
|
|
617
656
|
stabilizing: 'Stabilising window active',
|
|
618
|
-
|
|
657
|
+
not_moving_blocked: 'Learning blocked \u2014 vessel not moving',
|
|
619
658
|
cog_override_active: 'Learning blocked by COG override',
|
|
620
659
|
stw_below_threshold: 'Below minimum STW for learning',
|
|
621
660
|
sog_below_threshold: 'Below minimum SOG for learning',
|
|
@@ -625,10 +664,11 @@ function renderLiveSections() {
|
|
|
625
664
|
const reason = reasonTextMap[learningState?.observationReason]
|
|
626
665
|
|| reasonTextMap[learningState?.reason]
|
|
627
666
|
|| '\u2014';
|
|
667
|
+
const showReason = learningState?.observationState && learningState.observationState !== 'accepted';
|
|
628
668
|
statusTbody.innerHTML =
|
|
629
669
|
`<tr><td class="text-muted small">Learning</td><td class="small">${learningText}</td></tr>` +
|
|
630
670
|
`<tr><td class="text-muted small">Observation</td><td class="small">${obsText}</td></tr>` +
|
|
631
|
-
`<tr><td class="text-muted small">Reason</td><td class="small">${reason}</td></tr
|
|
671
|
+
(showReason ? `<tr><td class="text-muted small">Reason</td><td class="small">${reason}</td></tr>` : '');
|
|
632
672
|
}
|
|
633
673
|
// Correction table
|
|
634
674
|
const tableEl = document.getElementById('table-container');
|
|
@@ -642,46 +682,70 @@ function renderLiveSections() {
|
|
|
642
682
|
speedSymbol: speedC.symbol,
|
|
643
683
|
heelSymbol: angleC.symbol,
|
|
644
684
|
};
|
|
645
|
-
Object.values(state.tablesById)
|
|
685
|
+
const tables = Object.values(state.tablesById);
|
|
686
|
+
const rawSpeed = state.deltasById['boatSpeed']?.value;
|
|
687
|
+
const heel = state.attitudesById['attitude']?.value?.roll;
|
|
688
|
+
const correctedSpeed = state.polarsById['correctedBoatSpeed']?.magnitude;
|
|
689
|
+
const marker = [rawSpeed, heel, correctedSpeed].every(Number.isFinite)
|
|
690
|
+
? { rawSpeed, heel, correctedSpeed }
|
|
691
|
+
: null;
|
|
692
|
+
tables.forEach(t => {
|
|
693
|
+
const wrap = tableRenderer.render(t, tableOpts);
|
|
694
|
+
tableEl.appendChild(wrap);
|
|
695
|
+
tableRenderer.renderMarkers(wrap, t.row, t.col, marker);
|
|
696
|
+
});
|
|
646
697
|
}
|
|
647
698
|
}
|
|
648
699
|
|
|
649
700
|
// ─── Polling ──────────────────────────────────────────────────────────────────
|
|
701
|
+
const POLL_INTERVAL_MS = 1000;
|
|
702
|
+
const MIN_POLL_INTERVAL_MS = 100;
|
|
703
|
+
const MAX_POLL_INTERVAL_MS = 5000;
|
|
704
|
+
let pollIntervalMs = POLL_INTERVAL_MS;
|
|
650
705
|
let updateTimer = null;
|
|
651
706
|
let lastTickOk = false;
|
|
652
707
|
|
|
653
708
|
async function tick() {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
709
|
+
try {
|
|
710
|
+
const data = await apiGet('/api/report');
|
|
711
|
+
if (data) {
|
|
712
|
+
// Recovered from an error — reload meta and config so unit converters,
|
|
713
|
+
// source lists and settings reflect the (possibly restarted) plugin state.
|
|
714
|
+
if (!lastTickOk) {
|
|
715
|
+
await loadMeta();
|
|
716
|
+
config = await apiGet('/api/settings');
|
|
717
|
+
if (config && config.tableName) setTableName(config.tableName);
|
|
718
|
+
renderSettingsPanel();
|
|
719
|
+
}
|
|
720
|
+
lastTickOk = true;
|
|
721
|
+
pollIntervalMs = Number.isFinite(data.pollIntervalMs) && data.pollIntervalMs > 0
|
|
722
|
+
? Math.min(Math.max(data.pollIntervalMs, MIN_POLL_INTERVAL_MS), MAX_POLL_INTERVAL_MS)
|
|
723
|
+
: POLL_INTERVAL_MS;
|
|
724
|
+
normaliseState(data);
|
|
725
|
+
state.learningState = data.learningState || null;
|
|
726
|
+
renderLiveSections();
|
|
727
|
+
} else {
|
|
728
|
+
lastTickOk = false;
|
|
729
|
+
state.learningState = null;
|
|
663
730
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
731
|
+
} finally {
|
|
732
|
+
// Always poll status separately so the message reflects plugin state
|
|
733
|
+
// even when /api/report fails (plugin stopped/restarting).
|
|
734
|
+
const statusData = await fetch(`${API_BASE}/api/status`, { credentials: 'same-origin' })
|
|
735
|
+
.then(r => r.ok ? r.json() : null)
|
|
736
|
+
.catch(() => null);
|
|
737
|
+
_pluginStatus = statusData?.status ?? '';
|
|
738
|
+
_refreshMessage();
|
|
671
739
|
}
|
|
672
|
-
// Always poll status separately so the message reflects plugin state
|
|
673
|
-
// even when /api/report fails (plugin stopped/restarting).
|
|
674
|
-
const statusData = await fetch(`${API_BASE}/api/status`, { credentials: 'same-origin' })
|
|
675
|
-
.then(r => r.ok ? r.json() : null)
|
|
676
|
-
.catch(() => null);
|
|
677
|
-
_pluginStatus = statusData?.status ?? '';
|
|
678
|
-
lifecycleWarnings = Array.isArray(statusData?.lifecycleWarnings) ? statusData.lifecycleWarnings : [];
|
|
679
|
-
_refreshMessage();
|
|
680
740
|
}
|
|
681
741
|
|
|
682
742
|
function startUpdates() {
|
|
683
|
-
if (updateTimer)
|
|
684
|
-
|
|
743
|
+
if (updateTimer) clearTimeout(updateTimer);
|
|
744
|
+
const poll = async () => {
|
|
745
|
+
await tick();
|
|
746
|
+
updateTimer = setTimeout(poll, pollIntervalMs);
|
|
747
|
+
};
|
|
748
|
+
poll();
|
|
685
749
|
}
|
|
686
750
|
|
|
687
751
|
|
package/public/index.html
CHANGED
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
<table class="table table-sm table-borderless mb-0" id="learning-settings-table">
|
|
153
153
|
<tbody></tbody>
|
|
154
154
|
</table>
|
|
155
|
-
<h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">
|
|
155
|
+
<h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">Learning window</h6>
|
|
156
156
|
<table class="table table-sm table-borderless mb-0" id="smoother-settings-table">
|
|
157
157
|
<tbody></tbody>
|
|
158
158
|
</table>
|
|
@@ -193,15 +193,23 @@
|
|
|
193
193
|
</div>
|
|
194
194
|
<div class="table-legend-row">
|
|
195
195
|
<span class="legend-swatch legend-swatch--stripe"></span>
|
|
196
|
-
<span><strong>Stripe angle</strong> — encodes leeway direction
|
|
196
|
+
<span><strong>Stripe angle</strong> — encodes leeway direction</span>
|
|
197
197
|
</div>
|
|
198
198
|
<div class="table-legend-row">
|
|
199
|
-
<span class="legend-swatch legend-swatch--active"
|
|
200
|
-
<span><strong>
|
|
199
|
+
<span class="legend-swatch legend-swatch--active"></span>
|
|
200
|
+
<span><strong>Black border</strong> — most recently updated cell</span>
|
|
201
201
|
</div>
|
|
202
202
|
<div class="table-legend-row">
|
|
203
|
-
<span class="legend-swatch legend-swatch--
|
|
204
|
-
<span><strong>
|
|
203
|
+
<span class="legend-swatch legend-swatch--weight"></span>
|
|
204
|
+
<span><strong>Blue bar</strong> — normalized fusion weight; full cell width is 100%</span>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="table-legend-row">
|
|
207
|
+
<span class="legend-swatch legend-swatch--dot-red"></span>
|
|
208
|
+
<span><strong>Red dot</strong> — current uncorrected (raw) speed and heel</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="table-legend-row">
|
|
211
|
+
<span class="legend-swatch legend-swatch--dot-blue"></span>
|
|
212
|
+
<span><strong>Blue dot</strong> — current corrected speed and heel</span>
|
|
205
213
|
</div>
|
|
206
214
|
<div class="table-legend-row mt-1">
|
|
207
215
|
<span class="legend-label-wide">Cell values:</span>
|
package/public/main.css
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
.TableCell {
|
|
35
35
|
width: 60px;
|
|
36
36
|
height: 60px;
|
|
37
|
+
position: relative;
|
|
37
38
|
text-align: center;
|
|
38
39
|
vertical-align: middle;
|
|
39
40
|
font-size: 11px;
|
|
@@ -42,11 +43,14 @@
|
|
|
42
43
|
.TableCorner { font-size: 10px; color: #888; font-weight: 400; text-align: center; }
|
|
43
44
|
/* cell states */
|
|
44
45
|
.cell--empty { background-color: #f0f0f0; }
|
|
45
|
-
.cell--neighbour { box-shadow: inset 0 0 0 1px #90a4ae; }
|
|
46
46
|
.cell-factor { font-weight: 600; font-size: 12px; }
|
|
47
47
|
.cell-leeway { font-weight: 600; font-size: 12px; margin-top: 2px; }
|
|
48
|
-
.cell
|
|
49
|
-
.cell--active
|
|
48
|
+
.cell-weight { position: absolute; left: 0; bottom: 0; height: 4px; background-color: #0d6efd; }
|
|
49
|
+
.cell--active { outline: 1px solid #000; outline-offset: -1px; }
|
|
50
|
+
|
|
51
|
+
/* ─── Correction table marker overlay (raw speed dot + corrected speed/leeway arrow) ─ */
|
|
52
|
+
.Table2D-wrap { position: relative; display: inline-block; }
|
|
53
|
+
.Table2D-markers { position: absolute; top: 0; left: 0; pointer-events: none; overflow: visible; }
|
|
50
54
|
|
|
51
55
|
/* ─── Stable table column widths inside cards ──────────────────────────────── */
|
|
52
56
|
.card-body .table {
|
|
@@ -118,6 +122,16 @@ tr.stale td { color: #aaa; font-style: italic; }
|
|
|
118
122
|
#f0f0f0 4px, #f0f0f0 5px
|
|
119
123
|
);
|
|
120
124
|
}
|
|
121
|
-
.legend-swatch--
|
|
122
|
-
.legend-swatch--
|
|
125
|
+
.legend-swatch--weight { position: relative; border: 0; }
|
|
126
|
+
.legend-swatch--weight::after { content: ''; position: absolute; left: 0; right: 25%; bottom: 2px; height: 4px; background-color: #0d6efd; }
|
|
127
|
+
.legend-swatch--active { background: transparent; border-color: #000; border-radius: 0; }
|
|
128
|
+
.legend-swatch--dot-red { background: transparent; border: 0; border-radius: 0; position: relative; }
|
|
129
|
+
.legend-swatch--dot-red::after,
|
|
130
|
+
.legend-swatch--dot-blue::after {
|
|
131
|
+
content: ''; position: absolute; top: 2px; left: 8px;
|
|
132
|
+
width: 12px; height: 12px; border-radius: 50%; border: 1px solid #fff;
|
|
133
|
+
}
|
|
134
|
+
.legend-swatch--dot-red::after { background-color: #dc3545; }
|
|
135
|
+
.legend-swatch--dot-blue { background: transparent; border: 0; border-radius: 0; position: relative; }
|
|
136
|
+
.legend-swatch--dot-blue::after { background-color: #0d6efd; }
|
|
123
137
|
.legend-label-wide { flex-shrink: 0; white-space: nowrap; font-weight: 600; }
|