speedandcurrent 2.1.2 → 2.3.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 +9 -4
- package/index.js +364 -85
- package/package.json +2 -2
- package/public/app.js +103 -40
- package/public/index.html +1 -0
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
|
|
@@ -64,7 +66,7 @@ Once configured, all consumers on the Signal K bus — KIP, OpenCPN, Instrument
|
|
|
64
66
|
| `environment.current.drift` | m/s | Estimated current speed. |
|
|
65
67
|
| `environment.current.setTrue` | rad | Estimated current direction (the direction the water moves *toward*). |
|
|
66
68
|
|
|
67
|
-
A 60-second stabilisation period applies after startup. No output is published during this window
|
|
69
|
+
A 60-second stabilisation period applies after startup. No output is published during this window.
|
|
68
70
|
|
|
69
71
|
---
|
|
70
72
|
|
|
@@ -130,7 +132,7 @@ The panel is organised into three groups:
|
|
|
130
132
|
| Setting | Default | Description |
|
|
131
133
|
|---------|---------|-------------|
|
|
132
134
|
| **Estimate Boat Speed** | Off | Master toggle. Apply the correction table and publish corrected STW, leeway, and current. Turn on once the table has reasonable coverage. |
|
|
133
|
-
| **Groundspeed Fallback** | Off | When the paddle wheel reads zero and SOG is above
|
|
135
|
+
| **Groundspeed Fallback** | Off | When the paddle wheel reads zero and SOG is above the current table speed-step threshold, publish SOG as boatspeed. Primarily intended for clogged or stuck paddle wheels where the instrument is physically present but not turning — giving the rest of the instrument system a usable boat speed until the wheel is cleared. |
|
|
134
136
|
|
|
135
137
|
---
|
|
136
138
|
|
|
@@ -146,11 +148,14 @@ When **Assume Current** is enabled, the smoothed current estimate is also shown
|
|
|
146
148
|
|
|
147
149
|
Warnings appear here if any smoothed input is unavailable.
|
|
148
150
|
|
|
151
|
+
The panel also shows whether learning is currently active, suspended, or skipped for the latest observation. Learning is automatically suspended when `navigation.state` is `anchored` or `moored` when that path is available. It can also be suspended for `motoring` if enabled in the settings. Observations below the current table speed-step threshold are skipped.
|
|
152
|
+
|
|
149
153
|
### Settings
|
|
150
154
|
|
|
151
155
|
| Setting | Default | Description |
|
|
152
156
|
|---------|---------|-------------|
|
|
153
|
-
| **Update Correction Table** | On | Master toggle. Allow the table to update from current observations.
|
|
157
|
+
| **Update Correction Table** | On | Master toggle. Allow the table to update from current observations. |
|
|
158
|
+
| **Suspend on navigation.state = motoring** | Off | Suspend learning when `navigation.state` reports `motoring`. `anchored` and `moored` always suspend learning when that path is available. |
|
|
154
159
|
| **Stability (1–20)** | 7 | How quickly the correction table adapts to new observations. Higher = slower, more conservative. Lower = faster but noisier. See the technical section for detail. |
|
|
155
160
|
| **Assume Current (experimental)** | Off | Include the running current estimate in the table update calculation. Only enable once the current estimate has had time to stabilise and tidal conditions are relatively steady. |
|
|
156
161
|
| **Show Statistics (σ)** | Off | Display standard deviation alongside each smoothed value. Useful for spotting noisy sensors. |
|
|
@@ -206,7 +211,7 @@ Multiple tables can coexist on disk; only the active one is used. This makes it
|
|
|
206
211
|
|
|
207
212
|
**Be patient with the correction table.** A fresh table has no data and produces no corrections. Cover a range of speeds and heel angles over a few sails and the table fills in progressively. Upwind sailing covers the heel bins well; downwind and reaching fill the low-heel, varying-speed bins.
|
|
208
213
|
|
|
209
|
-
**The table learns while sailing normally.** No dedicated calibration runs are needed. Just sail with **Update Correction Table** on.
|
|
214
|
+
**The table learns while sailing normally.** No dedicated calibration runs are needed. Just sail with **Update Correction Table** on. Learning pauses automatically during startup stabilisation, when `navigation.state` indicates `anchored` or `moored`, optionally when it indicates `motoring`, and when SOG or STW are below the current table speed-step threshold.
|
|
210
215
|
|
|
211
216
|
**Port and starboard are tracked independently.** Heel is signed: starboard positive, port negative. An asymmetric paddle wheel installation will show different corrections on each tack, and the table captures this naturally.
|
|
212
217
|
|
package/index.js
CHANGED
|
@@ -19,27 +19,141 @@ const {
|
|
|
19
19
|
|
|
20
20
|
const { CorrectionTable } = require('./correctionTable.js');
|
|
21
21
|
|
|
22
|
+
const LONG_STABILIZING_MS = 60 * 1000;
|
|
23
|
+
const ALWAYS_BLOCKING_NAVIGATION_STATES = new Set(['anchored', 'moored']);
|
|
24
|
+
const OPTIONAL_BLOCKING_NAVIGATION_STATES = new Set(['motoring']);
|
|
25
|
+
|
|
26
|
+
function normalizeNavigationState(value) {
|
|
27
|
+
return typeof value === 'string' ? value.trim().toLowerCase() : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getShortStabilizingMs(options = {}) {
|
|
31
|
+
return Math.max(0, Number(options.smootherTimeSpan) || 5) * 1000;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isCogOverrideActive(groundSpeedPolar, speedThreshold) {
|
|
35
|
+
const sogHandler = groundSpeedPolar?.magnitudeHandler;
|
|
36
|
+
return !groundSpeedPolar?.ready
|
|
37
|
+
&& sogHandler?.ready
|
|
38
|
+
&& Number.isFinite(sogHandler.value)
|
|
39
|
+
&& sogHandler.value < speedThreshold;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function evaluateLearningMode({ options = {}, navigationState, stabilizingUntil = 0, stabilizingReason = null, now = Date.now() }) {
|
|
43
|
+
const normalizedNavigationState = normalizeNavigationState(navigationState?.value);
|
|
44
|
+
const navigationGateReady = navigationState?.ready === true;
|
|
45
|
+
const navigationGateBlocking = navigationGateReady && (
|
|
46
|
+
ALWAYS_BLOCKING_NAVIGATION_STATES.has(normalizedNavigationState)
|
|
47
|
+
|| (!!options.suspendLearningOnNavigationState && OPTIONAL_BLOCKING_NAVIGATION_STATES.has(normalizedNavigationState))
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (!options.updateCorrectionTable) {
|
|
51
|
+
return { state: 'off', reason: 'manual', navigationGateBlocking, normalizedNavigationState };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (navigationGateBlocking) {
|
|
55
|
+
return { state: 'suspended', reason: 'nav_state', navigationGateBlocking, normalizedNavigationState };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (now < stabilizingUntil) {
|
|
59
|
+
return { state: 'stabilizing', reason: stabilizingReason || 'startup', navigationGateBlocking, normalizedNavigationState };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { state: 'active', reason: null, navigationGateBlocking, normalizedNavigationState };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function evaluateObservationGate({
|
|
66
|
+
learningMode,
|
|
67
|
+
inputsReady,
|
|
68
|
+
assumeCurrent,
|
|
69
|
+
currentReady,
|
|
70
|
+
stw,
|
|
71
|
+
sog,
|
|
72
|
+
speedThreshold,
|
|
73
|
+
}) {
|
|
74
|
+
if (learningMode.state === 'off') {
|
|
75
|
+
return { state: 'skipped', reason: 'learning_off' };
|
|
76
|
+
}
|
|
77
|
+
if (learningMode.state === 'suspended') {
|
|
78
|
+
return { state: 'skipped', reason: learningMode.reason === 'cog_override' ? 'cog_override_active' : 'nav_state_blocked' };
|
|
79
|
+
}
|
|
80
|
+
if (learningMode.state === 'stabilizing') {
|
|
81
|
+
return { state: 'skipped', reason: 'stabilizing' };
|
|
82
|
+
}
|
|
83
|
+
if (!inputsReady) {
|
|
84
|
+
return { state: 'invalid', reason: assumeCurrent && !currentReady ? 'missing_current_when_required' : 'missing_input' };
|
|
85
|
+
}
|
|
86
|
+
if (!(stw > speedThreshold)) {
|
|
87
|
+
return { state: 'skipped', reason: 'stw_below_threshold' };
|
|
88
|
+
}
|
|
89
|
+
if (!(sog >= speedThreshold)) {
|
|
90
|
+
return { state: 'skipped', reason: 'sog_below_threshold' };
|
|
91
|
+
}
|
|
92
|
+
return { state: 'pending', reason: null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildNavigationStateStatus(handler, gateEnabled) {
|
|
96
|
+
if (!handler) {
|
|
97
|
+
return {
|
|
98
|
+
enabled: !!gateEnabled,
|
|
99
|
+
path: 'navigation.state',
|
|
100
|
+
pathKnown: false,
|
|
101
|
+
ready: false,
|
|
102
|
+
value: null,
|
|
103
|
+
blocking: false
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const state = handler.state;
|
|
108
|
+
const value = handler.value ?? null;
|
|
109
|
+
const normalized = normalizeNavigationState(value);
|
|
110
|
+
return {
|
|
111
|
+
enabled: !!gateEnabled,
|
|
112
|
+
path: handler.path,
|
|
113
|
+
pathKnown: state.pathKnown,
|
|
114
|
+
ready: state.ready,
|
|
115
|
+
isStale: state.isStale,
|
|
116
|
+
value,
|
|
117
|
+
blocking: state.ready && (
|
|
118
|
+
ALWAYS_BLOCKING_NAVIGATION_STATES.has(normalized)
|
|
119
|
+
|| (!!gateEnabled && OPTIONAL_BLOCKING_NAVIGATION_STATES.has(normalized))
|
|
120
|
+
)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getDerivedObservationStatus(learningMode, lastState, lastReason) {
|
|
125
|
+
if (learningMode.state === 'off') {
|
|
126
|
+
return { state: 'skipped', reason: 'learning_off' };
|
|
127
|
+
}
|
|
128
|
+
if (learningMode.state === 'stabilizing') {
|
|
129
|
+
return { state: 'skipped', reason: 'stabilizing' };
|
|
130
|
+
}
|
|
131
|
+
if (learningMode.state === 'suspended') {
|
|
132
|
+
return { state: 'skipped', reason: learningMode.reason === 'cog_override' ? 'cog_override_active' : 'nav_state_blocked' };
|
|
133
|
+
}
|
|
134
|
+
return { state: lastState, reason: lastReason };
|
|
135
|
+
}
|
|
136
|
+
|
|
22
137
|
module.exports = function (app) {
|
|
23
138
|
|
|
24
139
|
const DEFAULT_DIMS = { maxSpeed: 9, speedStep: 1, maxHeel: 32, heelStep: 8 };
|
|
25
140
|
|
|
26
141
|
let options = {};
|
|
27
142
|
let changedOptions = {};
|
|
28
|
-
let hasPendingChanges = false;
|
|
29
143
|
const defaultOptions = {
|
|
30
144
|
sogFallback: true,
|
|
31
145
|
estimateBoatSpeed: false,
|
|
32
146
|
updateCorrectionTable: true,
|
|
33
147
|
stability: 7,
|
|
34
148
|
assumeCurrent: false,
|
|
149
|
+
suspendLearningOnNavigationState: false,
|
|
35
150
|
tableName: 'correctionTable',
|
|
36
151
|
configVersion: 2,
|
|
37
152
|
smootherClass: 'MovingAverageSmoother',
|
|
38
153
|
smootherTau: 3,
|
|
39
154
|
smootherTimeSpan: 5,
|
|
40
155
|
smootherSteadyState: 0.2,
|
|
41
|
-
showStatistics: false
|
|
42
|
-
stalenessDetection: true
|
|
156
|
+
showStatistics: false
|
|
43
157
|
};
|
|
44
158
|
|
|
45
159
|
|
|
@@ -68,7 +182,7 @@ module.exports = function (app) {
|
|
|
68
182
|
* writes it back if anything changed. Called once on every start().
|
|
69
183
|
*/
|
|
70
184
|
function migrateConfig() {
|
|
71
|
-
const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication'];
|
|
185
|
+
const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication', 'minSogForLearning'];
|
|
72
186
|
const hadObsolete = obsoleteKeys.some(k => k in options);
|
|
73
187
|
for (const k of obsoleteKeys) delete options[k];
|
|
74
188
|
if (hadObsolete || (options.configVersion || 0) < 2) {
|
|
@@ -110,7 +224,7 @@ module.exports = function (app) {
|
|
|
110
224
|
|
|
111
225
|
function swapTable(newTable) {
|
|
112
226
|
table = newTable;
|
|
113
|
-
minSpeed = table.step[0]
|
|
227
|
+
minSpeed = table.step[0];
|
|
114
228
|
if (reportFull) reportFull.setTables([table]);
|
|
115
229
|
lastSave = Date.now(); // explicit table operations already save; defer next periodic save
|
|
116
230
|
}
|
|
@@ -137,9 +251,114 @@ module.exports = function (app) {
|
|
|
137
251
|
let noCurrent = null;
|
|
138
252
|
let rawBoatSpeed = null;
|
|
139
253
|
let rawGroundSpeed = null;
|
|
140
|
-
let started = null;
|
|
141
254
|
let minSpeed = 0;
|
|
142
255
|
let lastSave = 0;
|
|
256
|
+
let navigationStateHandler = null;
|
|
257
|
+
let lastNavigationStateValue = null;
|
|
258
|
+
let learningStabilizingUntil = 0;
|
|
259
|
+
let learningStabilizingReason = 'startup';
|
|
260
|
+
let lastObservationState = null;
|
|
261
|
+
let lastObservationReason = null;
|
|
262
|
+
let lifecycleWarningMap = new Map();
|
|
263
|
+
let lifecycleWarnings = [];
|
|
264
|
+
|
|
265
|
+
function setObservationStatus(state, reason = null) {
|
|
266
|
+
lastObservationState = state;
|
|
267
|
+
lastObservationReason = reason;
|
|
268
|
+
if (table) {
|
|
269
|
+
table.lastUpdateResult = state;
|
|
270
|
+
table.lastUpdateReason = reason;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function resetLearningStabilization(reason, durationMs) {
|
|
275
|
+
const delay = Math.max(0, Number(durationMs) || 0);
|
|
276
|
+
learningStabilizingUntil = Date.now() + delay;
|
|
277
|
+
learningStabilizingReason = reason;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function handleNavigationStateDelta() {
|
|
281
|
+
const normalized = normalizeNavigationState(navigationStateHandler?.value);
|
|
282
|
+
if (normalized === lastNavigationStateValue) return;
|
|
283
|
+
if (lastNavigationStateValue !== null) {
|
|
284
|
+
resetLearningStabilization('nav_state_change', LONG_STABILIZING_MS);
|
|
285
|
+
}
|
|
286
|
+
lastNavigationStateValue = normalized;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function getLearningStatePayload() {
|
|
290
|
+
const navigationState = buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState);
|
|
291
|
+
const learningMode = evaluateLearningMode({
|
|
292
|
+
options,
|
|
293
|
+
navigationState,
|
|
294
|
+
stabilizingUntil: learningStabilizingUntil,
|
|
295
|
+
stabilizingReason: learningStabilizingReason,
|
|
296
|
+
now: Date.now()
|
|
297
|
+
});
|
|
298
|
+
if (learningMode.state === 'active' && isCogOverrideActive(rawGroundSpeed, minSpeed)) {
|
|
299
|
+
learningMode.state = 'suspended';
|
|
300
|
+
learningMode.reason = 'cog_override';
|
|
301
|
+
}
|
|
302
|
+
const observation = getDerivedObservationStatus(learningMode, lastObservationState, lastObservationReason);
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
state: learningMode.state,
|
|
306
|
+
reason: learningMode.reason,
|
|
307
|
+
observationState: observation.state,
|
|
308
|
+
observationReason: observation.reason,
|
|
309
|
+
minStwForLearning: minSpeed,
|
|
310
|
+
minSogForLearning: minSpeed,
|
|
311
|
+
navigationState
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function setLifecycleWarning(id, status, path) {
|
|
316
|
+
const safePath = path || 'unknown path';
|
|
317
|
+
const message = status === 'idle'
|
|
318
|
+
? `Input ${id} is idle on ${safePath}; resubscribing`
|
|
319
|
+
: `Input ${id} is stale on ${safePath}`;
|
|
320
|
+
lifecycleWarningMap.set(id, {
|
|
321
|
+
id,
|
|
322
|
+
status,
|
|
323
|
+
path: safePath,
|
|
324
|
+
message,
|
|
325
|
+
updatedAt: Date.now()
|
|
326
|
+
});
|
|
327
|
+
lifecycleWarnings = Array.from(lifecycleWarningMap.values())
|
|
328
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function clearLifecycleWarning(id) {
|
|
332
|
+
if (!lifecycleWarningMap.has(id)) return;
|
|
333
|
+
lifecycleWarningMap.delete(id);
|
|
334
|
+
lifecycleWarnings = Array.from(lifecycleWarningMap.values())
|
|
335
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildLifecycleCallbacks(id, getPath, resubscribe) {
|
|
339
|
+
return {
|
|
340
|
+
onDelta: () => {
|
|
341
|
+
clearLifecycleWarning(id);
|
|
342
|
+
},
|
|
343
|
+
onStale: () => {
|
|
344
|
+
if (!isRunning) return;
|
|
345
|
+
const path = getPath();
|
|
346
|
+
app.debug(`[${plugin.id}] stale input ${id} on ${path}`);
|
|
347
|
+
setLifecycleWarning(id, 'stale', path);
|
|
348
|
+
},
|
|
349
|
+
onIdle: () => {
|
|
350
|
+
if (!isRunning) return;
|
|
351
|
+
const path = getPath();
|
|
352
|
+
app.debug(`[${plugin.id}] idle input ${id} on ${path}; resubscribing`);
|
|
353
|
+
setLifecycleWarning(id, 'idle', path);
|
|
354
|
+
try {
|
|
355
|
+
resubscribe();
|
|
356
|
+
} catch (e) {
|
|
357
|
+
app.debug(`[${plugin.id}] resubscribe failed for ${id}: ${e.message}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
143
362
|
|
|
144
363
|
const plugin = {};
|
|
145
364
|
plugin.id = "SpeedAndCurrent";
|
|
@@ -161,7 +380,10 @@ module.exports = function (app) {
|
|
|
161
380
|
if (!isRunning) {
|
|
162
381
|
res.status(503).json({ error: "Plugin is not running" });
|
|
163
382
|
} else {
|
|
164
|
-
|
|
383
|
+
const payload = reportFull.report();
|
|
384
|
+
payload.lifecycleWarnings = lifecycleWarnings;
|
|
385
|
+
payload.learningState = getLearningStatePayload();
|
|
386
|
+
res.json(payload);
|
|
165
387
|
}
|
|
166
388
|
});
|
|
167
389
|
|
|
@@ -176,7 +398,7 @@ module.exports = function (app) {
|
|
|
176
398
|
|
|
177
399
|
|
|
178
400
|
router.get('/api/status', (req, res) => {
|
|
179
|
-
res.json({ status: pluginStatus, isRunning });
|
|
401
|
+
res.json({ status: pluginStatus, isRunning, lifecycleWarnings, learningState: getLearningStatePayload() });
|
|
180
402
|
});
|
|
181
403
|
|
|
182
404
|
// --- Settings API ---
|
|
@@ -197,7 +419,6 @@ module.exports = function (app) {
|
|
|
197
419
|
}
|
|
198
420
|
}
|
|
199
421
|
changedOptions = { ...changedOptions, ...body };
|
|
200
|
-
hasPendingChanges = true;
|
|
201
422
|
res.json({ ...options, ...changedOptions });
|
|
202
423
|
});
|
|
203
424
|
|
|
@@ -305,12 +526,14 @@ module.exports = function (app) {
|
|
|
305
526
|
plugin.start = (settings) => {
|
|
306
527
|
setStatus('Starting');
|
|
307
528
|
app.debug("Starting");
|
|
529
|
+
lifecycleWarningMap = new Map();
|
|
530
|
+
lifecycleWarnings = [];
|
|
308
531
|
readOptions(); // pick up any saves since registerWithRouter ran
|
|
309
532
|
migrateConfig(); // strip obsolete fields from persisted config
|
|
310
533
|
const tableName = options.tableName || 'correctionTable';
|
|
311
534
|
const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
|
|
312
535
|
table = loadTable(options, tableFilePath);
|
|
313
|
-
minSpeed = table.step[0]
|
|
536
|
+
minSpeed = table.step[0];
|
|
314
537
|
|
|
315
538
|
//#region Handler and Polar Initialization
|
|
316
539
|
const { SmootherClass, smootherOptions } = resolveSmootherConfig();
|
|
@@ -320,25 +543,38 @@ module.exports = function (app) {
|
|
|
320
543
|
angleRange: '0to2pi',
|
|
321
544
|
meta: { displayName: 'Heading', plane: 'Ground' },
|
|
322
545
|
SmootherClass,
|
|
323
|
-
smootherOptions
|
|
546
|
+
smootherOptions,
|
|
547
|
+
...buildLifecycleCallbacks(
|
|
548
|
+
'heading.angle',
|
|
549
|
+
() => smoothedHeading?.handler?.path || 'navigation.headingTrue',
|
|
550
|
+
() => { smoothedHeading?.unsubscribe(); smoothedHeading?.subscribe(false, true); }
|
|
551
|
+
)
|
|
324
552
|
});
|
|
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
553
|
rawHeading = smoothedHeading.handler;
|
|
330
554
|
|
|
331
|
-
//attitude
|
|
555
|
+
// attitude
|
|
332
556
|
smoothedAttitude = createSmoothedHandler({
|
|
333
557
|
app, pluginId: plugin.id,
|
|
334
558
|
id: 'attitude',
|
|
335
559
|
path: 'navigation.attitude',
|
|
336
560
|
subscribe: true,
|
|
337
561
|
SmootherClass,
|
|
338
|
-
smootherOptions
|
|
562
|
+
smootherOptions,
|
|
563
|
+
...buildLifecycleCallbacks(
|
|
564
|
+
'attitude.smoothed',
|
|
565
|
+
() => smoothedAttitude?.handler?.path || 'navigation.attitude',
|
|
566
|
+
() => { smoothedAttitude?.unsubscribe(); smoothedAttitude?.subscribe(); }
|
|
567
|
+
)
|
|
339
568
|
});
|
|
340
569
|
rawAttitude = smoothedAttitude.handler;
|
|
341
570
|
|
|
571
|
+
navigationStateHandler = new MessageHandler(app, plugin.id, 'navigationState');
|
|
572
|
+
navigationStateHandler.configure('navigation.state');
|
|
573
|
+
navigationStateHandler.onDelta = () => {
|
|
574
|
+
handleNavigationStateDelta();
|
|
575
|
+
};
|
|
576
|
+
navigationStateHandler.subscribe();
|
|
577
|
+
|
|
342
578
|
|
|
343
579
|
// current
|
|
344
580
|
// send metadata for current
|
|
@@ -370,7 +606,6 @@ module.exports = function (app) {
|
|
|
370
606
|
});
|
|
371
607
|
noCurrent.xSmoother.reset(0,0);
|
|
372
608
|
noCurrent.ySmoother.reset(0,0);
|
|
373
|
-
noCurrent.stalenessDetection = false; // noCurrent is a fixed zero-vector, never receives live data
|
|
374
609
|
PolarSmoother.send(app, plugin.id, [noCurrent]);
|
|
375
610
|
|
|
376
611
|
MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
|
|
@@ -388,7 +623,49 @@ module.exports = function (app) {
|
|
|
388
623
|
path: 'navigation.speedThroughWater',
|
|
389
624
|
subscribe: true,
|
|
390
625
|
SmootherClass,
|
|
391
|
-
smootherOptions
|
|
626
|
+
smootherOptions,
|
|
627
|
+
onDelta: () => {
|
|
628
|
+
clearLifecycleWarning('boatSpeed.smoothed');
|
|
629
|
+
// Drain any pending option changes before calculating
|
|
630
|
+
if (Object.keys(changedOptions).length) applyOptionChanges();
|
|
631
|
+
|
|
632
|
+
const learningMode = evaluateLearningMode({
|
|
633
|
+
options,
|
|
634
|
+
navigationState: buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState),
|
|
635
|
+
stabilizingUntil: learningStabilizingUntil,
|
|
636
|
+
stabilizingReason: learningStabilizingReason,
|
|
637
|
+
now: Date.now()
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
const wellUnderway = learningMode.state !== 'stabilizing';
|
|
641
|
+
setStatus(learningMode.state === 'stabilizing' ? 'Stabilizing' : 'Running');
|
|
642
|
+
if (options.estimateBoatSpeed) correct(wellUnderway);
|
|
643
|
+
updateTable();
|
|
644
|
+
if (lastObservationState === 'accepted' && options.updateCorrectionTable) {
|
|
645
|
+
const now = Date.now();
|
|
646
|
+
if (now - lastSave > 60_000) {
|
|
647
|
+
if (!isTableEmpty(table)) {
|
|
648
|
+
saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
|
|
649
|
+
}
|
|
650
|
+
lastSave = now;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
,
|
|
655
|
+
onIdle: () => {
|
|
656
|
+
if (!isRunning) return;
|
|
657
|
+
const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
|
|
658
|
+
app.debug(`[${plugin.id}] idle input boatSpeed.smoothed on ${path}; resubscribing`);
|
|
659
|
+
setLifecycleWarning('boatSpeed.smoothed', 'idle', path);
|
|
660
|
+
smoothedBoatSpeed?.unsubscribe();
|
|
661
|
+
smoothedBoatSpeed?.subscribe();
|
|
662
|
+
},
|
|
663
|
+
onStale: () => {
|
|
664
|
+
if (!isRunning) return;
|
|
665
|
+
const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
|
|
666
|
+
app.debug(`[${plugin.id}] stale input boatSpeed.smoothed on ${path}`);
|
|
667
|
+
setLifecycleWarning('boatSpeed.smoothed', 'stale', path);
|
|
668
|
+
}
|
|
392
669
|
});
|
|
393
670
|
rawBoatSpeed = smoothedBoatSpeed.handler;
|
|
394
671
|
|
|
@@ -419,7 +696,12 @@ module.exports = function (app) {
|
|
|
419
696
|
angleRange: '0to2pi',
|
|
420
697
|
meta: { displayName: 'Groundspeed', plane: 'Ground' },
|
|
421
698
|
SmootherClass,
|
|
422
|
-
smootherOptions
|
|
699
|
+
smootherOptions,
|
|
700
|
+
...buildLifecycleCallbacks(
|
|
701
|
+
'groundSpeed.smoothed',
|
|
702
|
+
() => `${smoothedGroundSpeed?.polar?.pathMagnitude || 'navigation.speedOverGround'}, ${smoothedGroundSpeed?.polar?.pathAngle || 'navigation.courseOverGroundTrue'}`,
|
|
703
|
+
() => { smoothedGroundSpeed?.unsubscribe(); smoothedGroundSpeed?.subscribe(true, true); }
|
|
704
|
+
)
|
|
423
705
|
});
|
|
424
706
|
rawGroundSpeed = smoothedGroundSpeed.polar;
|
|
425
707
|
|
|
@@ -466,50 +748,15 @@ module.exports = function (app) {
|
|
|
466
748
|
|
|
467
749
|
//#endregion
|
|
468
750
|
|
|
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
751
|
isRunning = true;
|
|
482
|
-
started = Date.now();
|
|
483
752
|
lastSave = 0;
|
|
753
|
+
lastObservationState = null;
|
|
754
|
+
lastObservationReason = null;
|
|
755
|
+
lastNavigationStateValue = normalizeNavigationState(navigationStateHandler?.value);
|
|
756
|
+
resetLearningStabilization('startup', LONG_STABILIZING_MS);
|
|
484
757
|
setStatus('Running');
|
|
485
758
|
app.debug("Running");
|
|
486
759
|
|
|
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
760
|
}
|
|
514
761
|
|
|
515
762
|
plugin.stop = () => {
|
|
@@ -525,6 +772,7 @@ module.exports = function (app) {
|
|
|
525
772
|
smoothedAttitude = smoothedAttitude?.terminate();
|
|
526
773
|
rawCurrent = rawCurrent?.terminate();
|
|
527
774
|
smoothedCurrent = smoothedCurrent?.terminate?.();
|
|
775
|
+
navigationStateHandler = navigationStateHandler?.terminate();
|
|
528
776
|
smoothedBoatSpeed = smoothedBoatSpeed?.terminate();
|
|
529
777
|
correctedBoatSpeed = correctedBoatSpeed?.terminate();
|
|
530
778
|
lrnBoatSpeed = lrnBoatSpeed?.terminate();
|
|
@@ -540,12 +788,18 @@ module.exports = function (app) {
|
|
|
540
788
|
noCurrent = null;
|
|
541
789
|
rawBoatSpeed = null;
|
|
542
790
|
rawGroundSpeed = null;
|
|
543
|
-
|
|
791
|
+
lastNavigationStateValue = null;
|
|
792
|
+
learningStabilizingUntil = 0;
|
|
793
|
+
learningStabilizingReason = 'startup';
|
|
794
|
+
lastObservationState = null;
|
|
795
|
+
lastObservationReason = null;
|
|
544
796
|
app.setPluginStatus("Stopped");
|
|
545
797
|
app.debug("Stopped");
|
|
546
798
|
|
|
547
799
|
pluginStatus = 'Stopped';
|
|
548
800
|
isRunning = false;
|
|
801
|
+
lifecycleWarningMap = new Map();
|
|
802
|
+
lifecycleWarnings = [];
|
|
549
803
|
resolve();
|
|
550
804
|
} catch (error) {
|
|
551
805
|
reject(error);
|
|
@@ -573,7 +827,7 @@ module.exports = function (app) {
|
|
|
573
827
|
|
|
574
828
|
// Priority 1: SOG fallback when STW is stuck at zero
|
|
575
829
|
// (onChange only fires when STW is ready, so a missing STW cannot reach here)
|
|
576
|
-
if (options.sogFallback && rawGroundSpeed.ready &&
|
|
830
|
+
if (options.sogFallback && rawGroundSpeed.ready && rawBoatSpeed.ready && rawBoatSpeed.value === 0 && rawGroundSpeed.magnitude >= minSpeed) {
|
|
577
831
|
correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
|
|
578
832
|
}
|
|
579
833
|
else if (rawAttitude.ready) {
|
|
@@ -584,11 +838,7 @@ module.exports = function (app) {
|
|
|
584
838
|
}
|
|
585
839
|
// Current estimation and residual also require heading (to rotate into ground frame).
|
|
586
840
|
// Also handle near-zero SOG where COG is unavailable: treat groundspeed as zero vector.
|
|
587
|
-
const
|
|
588
|
-
const nearZeroGroundSpeed = !rawGroundSpeed.ready &&
|
|
589
|
-
sogHandler.ready &&
|
|
590
|
-
Number.isFinite(sogHandler.value) &&
|
|
591
|
-
sogHandler.value < 0.3;
|
|
841
|
+
const nearZeroGroundSpeed = isCogOverrideActive(rawGroundSpeed, minSpeed);
|
|
592
842
|
if (rawHeading.ready && (rawGroundSpeed.ready || nearZeroGroundSpeed)) {
|
|
593
843
|
boatSpeedRefGround.copyFrom(correctedBoatSpeed);
|
|
594
844
|
boatSpeedRefGround.rotate(rawHeading.value);
|
|
@@ -626,18 +876,43 @@ module.exports = function (app) {
|
|
|
626
876
|
*/
|
|
627
877
|
function updateTable() {
|
|
628
878
|
lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
879
|
+
const learningMode = evaluateLearningMode({
|
|
880
|
+
options,
|
|
881
|
+
navigationState: buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState),
|
|
882
|
+
stabilizingUntil: learningStabilizingUntil,
|
|
883
|
+
stabilizingReason: learningStabilizingReason,
|
|
884
|
+
now: Date.now()
|
|
885
|
+
});
|
|
886
|
+
if (learningMode.state === 'active' && isCogOverrideActive(rawGroundSpeed, minSpeed)) {
|
|
887
|
+
learningMode.state = 'suspended';
|
|
888
|
+
learningMode.reason = 'cog_override';
|
|
632
889
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
890
|
+
const inputsReady = smoothedAttitude.ready && smoothedBoatSpeed.ready && smoothedHeading.ready && smoothedGroundSpeed.ready;
|
|
891
|
+
const currentReady = !options.assumeCurrent || smoothedCurrent.ready;
|
|
892
|
+
const observationGate = evaluateObservationGate({
|
|
893
|
+
learningMode,
|
|
894
|
+
inputsReady,
|
|
895
|
+
assumeCurrent: options.assumeCurrent,
|
|
896
|
+
currentReady,
|
|
897
|
+
stw: smoothedBoatSpeed.value,
|
|
898
|
+
sog: smoothedGroundSpeed.magnitude,
|
|
899
|
+
speedThreshold: minSpeed
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
if (observationGate.state !== 'pending') {
|
|
903
|
+
if (observationGate.state === 'invalid') {
|
|
904
|
+
resetLearningStabilization('observation_reset', getShortStabilizingMs(options));
|
|
905
|
+
}
|
|
906
|
+
return;
|
|
639
907
|
}
|
|
640
908
|
|
|
909
|
+
table.update(smoothedBoatSpeed.value, smoothedAttitude.value?.roll, smoothedGroundSpeed, options.assumeCurrent ? smoothedCurrent : noCurrent, lrnBoatSpeed, smoothedHeading.value);
|
|
910
|
+
if (table.lastUpdateResult === 'accepted') {
|
|
911
|
+
setObservationStatus('accepted', 'accepted');
|
|
912
|
+
} else if (table.lastUpdateResult === 'rejected') {
|
|
913
|
+
setObservationStatus('rejected', 'estimator_outlier');
|
|
914
|
+
resetLearningStabilization('observation_reset', getShortStabilizingMs(options));
|
|
915
|
+
}
|
|
641
916
|
}
|
|
642
917
|
|
|
643
918
|
/**
|
|
@@ -723,13 +998,7 @@ module.exports = function (app) {
|
|
|
723
998
|
const value = changedOptions[key];
|
|
724
999
|
options[key] = value;
|
|
725
1000
|
|
|
726
|
-
if (key === '
|
|
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') {
|
|
1001
|
+
if (key === 'updateCorrectionTable') {
|
|
733
1002
|
if (!value && table && !isTableEmpty(table)) {
|
|
734
1003
|
// Learning switched off — persist current state before periodic saves stop
|
|
735
1004
|
saveTableSync(table, path.join(app.getDataDirPath(), table.id + '.json'));
|
|
@@ -749,7 +1018,6 @@ module.exports = function (app) {
|
|
|
749
1018
|
}
|
|
750
1019
|
}
|
|
751
1020
|
}
|
|
752
|
-
|
|
753
1021
|
// All other keys (sogFallback, estimateBoatSpeed, assumeCurrent,
|
|
754
1022
|
// stability, smootherClass, etc.) are read
|
|
755
1023
|
// directly from options.* so no extra action needed.
|
|
@@ -771,10 +1039,21 @@ module.exports = function (app) {
|
|
|
771
1039
|
}
|
|
772
1040
|
if (smoothedAttitude) { smoothedAttitude.setSmootherClass(SC); smoothedAttitude.setSmootherOptions(so); }
|
|
773
1041
|
}
|
|
774
|
-
|
|
775
|
-
hasPendingChanges = false;
|
|
776
1042
|
saveOptions();
|
|
777
1043
|
}
|
|
778
1044
|
|
|
779
1045
|
return plugin;
|
|
780
1046
|
};
|
|
1047
|
+
|
|
1048
|
+
module.exports._test = {
|
|
1049
|
+
normalizeNavigationState,
|
|
1050
|
+
getShortStabilizingMs,
|
|
1051
|
+
evaluateLearningMode,
|
|
1052
|
+
isCogOverrideActive,
|
|
1053
|
+
evaluateObservationGate,
|
|
1054
|
+
getDerivedObservationStatus,
|
|
1055
|
+
buildNavigationStateStatus,
|
|
1056
|
+
ALWAYS_BLOCKING_NAVIGATION_STATES,
|
|
1057
|
+
OPTIONAL_BLOCKING_NAVIGATION_STATES,
|
|
1058
|
+
LONG_STABILIZING_MS,
|
|
1059
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speedandcurrent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
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
|
|
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
|
|
|
@@ -251,11 +275,8 @@ function _refreshMessage() {
|
|
|
251
275
|
else el.textContent = _explicitMsg;
|
|
252
276
|
return;
|
|
253
277
|
}
|
|
254
|
-
const isRunningOk = (_pluginStatus === 'Running' || _pluginStatus === '');
|
|
278
|
+
const isRunningOk = (_pluginStatus === 'Running' || _pluginStatus === 'Stabilizing' || _pluginStatus === '');
|
|
255
279
|
if (!isRunningOk) {
|
|
256
|
-
el.style.color =
|
|
257
|
-
_pluginStatus === 'Stabilizing' ? '#084298' :
|
|
258
|
-
_pluginStatus === 'Falling back to Speed Over Ground' ? '#664d03' : '#842029';
|
|
259
280
|
el.textContent = _pluginStatus;
|
|
260
281
|
} else {
|
|
261
282
|
el.style.color = '';
|
|
@@ -268,6 +289,7 @@ function _refreshMessage() {
|
|
|
268
289
|
const paramMeta = {
|
|
269
290
|
estimateBoatSpeed: { label: 'Estimate boat speed', type: 'boolean' },
|
|
270
291
|
updateCorrectionTable: { label: 'Update correction table', type: 'boolean' },
|
|
292
|
+
suspendLearningOnNavigationState: { label: 'Suspend on navigation.state = motoring', type: 'boolean', description: 'Suspend learning when navigation.state is motoring. Anchored and moored always suspend learning when the path is available.' },
|
|
271
293
|
assumeCurrent: { label: 'Assume current during update', type: 'boolean', description: 'Experimental, works best when currents are relatively stable.' },
|
|
272
294
|
sogFallback: { label: 'Groundspeed fallback', type: 'boolean', description: 'Output Groundspeed as Boatspeed when the paddlewheel sensor is malfunctioning or stalled.' },
|
|
273
295
|
stability: { label: 'Stability (1–20)', type: 'number', min: 1, max: 20, step: 1, default: 7, description: 'How quickly the correction table adapts to new observations. Higher values mean slower, more stable changes.' },
|
|
@@ -296,17 +318,17 @@ const paramMeta = {
|
|
|
296
318
|
min: 0.01, max: 0.99, step: 0.01, default: 0.2,
|
|
297
319
|
description: 'Steady-state Kalman gain (0 ≈ slow/smooth, 1 ≈ fast/raw). Clamped to 0.01–0.99.'
|
|
298
320
|
},
|
|
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
321
|
};
|
|
305
322
|
|
|
306
323
|
// Settings groups for each UI section
|
|
307
|
-
const INPUTS_SETTING_KEYS = [
|
|
324
|
+
const INPUTS_SETTING_KEYS = [
|
|
325
|
+
'smootherClass',
|
|
326
|
+
{ key: 'smootherTau', showIf: cfg => cfg.smootherClass === 'ExponentialSmoother' },
|
|
327
|
+
{ key: 'smootherTimeSpan', showIf: cfg => (cfg.smootherClass || 'MovingAverageSmoother') === 'MovingAverageSmoother' },
|
|
328
|
+
{ key: 'smootherSteadyState', showIf: cfg => cfg.smootherClass === 'KalmanSmoother' },
|
|
329
|
+
];
|
|
308
330
|
const ESTIMATION_SETTING_KEYS = ['sogFallback'];
|
|
309
|
-
const LEARNING_SETTING_KEYS = ['stability','assumeCurrent','showStatistics'];
|
|
331
|
+
const LEARNING_SETTING_KEYS = ['stability', 'suspendLearningOnNavigationState', 'assumeCurrent', 'showStatistics'];
|
|
310
332
|
const SMOOTHER_SETTING_KEYS = [
|
|
311
333
|
'smootherClass',
|
|
312
334
|
{ key: 'smootherTau', showIf: cfg => cfg.smootherClass === 'ExponentialSmoother' },
|
|
@@ -511,12 +533,23 @@ function renderGroupInto(elId, polars, deltas, attitudes) {
|
|
|
511
533
|
}
|
|
512
534
|
|
|
513
535
|
function renderLiveSections() {
|
|
536
|
+
const learningState = state.learningState || null;
|
|
537
|
+
const inputPolars = filterById(state.polarsAll, ['groundSpeed']);
|
|
538
|
+
const inputDeltas = filterById(state.deltasAll, ['heading.angle', 'boatSpeed']);
|
|
539
|
+
const inputAttitudes = filterById(state.attitudesAll, ['attitude']);
|
|
540
|
+
const fallbackInputPolars = inputPolars.length ? inputPolars : filterById(state.polarsAll, ['groundSpeed.smoothed']);
|
|
541
|
+
const fallbackInputDeltas = inputDeltas.length ? inputDeltas : filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']);
|
|
542
|
+
const fallbackInputAttitudes = inputAttitudes.length ? inputAttitudes : filterById(state.attitudesAll, ['attitude.smoothed']);
|
|
543
|
+
|
|
514
544
|
// Inputs section — raw sensor readings only (smoothing is internal to the plugin)
|
|
515
545
|
renderGroupInto('inputs-values',
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
546
|
+
fallbackInputPolars,
|
|
547
|
+
fallbackInputDeltas,
|
|
548
|
+
fallbackInputAttitudes
|
|
519
549
|
);
|
|
550
|
+
const inputWarningIds = ['boatSpeed', 'attitude', 'heading.angle', 'groundSpeed', 'boatSpeed.smoothed', 'groundSpeed.smoothed', 'heading.smoothed', 'attitude.smoothed'];
|
|
551
|
+
if (config && config.assumeCurrent) inputWarningIds.push('current.smoothed');
|
|
552
|
+
renderInputWarnings('inputs-warnings', inputWarningIds);
|
|
520
553
|
|
|
521
554
|
// Estimation — inputs (raw sensor data used for boat speed estimation)
|
|
522
555
|
renderGroupInto('estimation-inputs',
|
|
@@ -535,9 +568,8 @@ function renderLiveSections() {
|
|
|
535
568
|
[], []
|
|
536
569
|
);
|
|
537
570
|
// Estimation — warnings
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
);
|
|
571
|
+
const estimationWarnings = document.getElementById('estimation-warnings');
|
|
572
|
+
if (estimationWarnings) estimationWarnings.innerHTML = '';
|
|
541
573
|
|
|
542
574
|
// Learning — inputs: smoothed sensors + current if assumeCurrent
|
|
543
575
|
const learningCurrentPolars = (config && config.assumeCurrent)
|
|
@@ -549,33 +581,61 @@ function renderLiveSections() {
|
|
|
549
581
|
filterById(state.attitudesAll, ['attitude.smoothed'])
|
|
550
582
|
);
|
|
551
583
|
// Learning — warnings
|
|
552
|
-
const
|
|
553
|
-
if (
|
|
554
|
-
|
|
584
|
+
const learningWarnings = document.getElementById('learning-warnings');
|
|
585
|
+
if (learningWarnings) {
|
|
586
|
+
learningWarnings.innerHTML = '';
|
|
587
|
+
const navState = learningState?.navigationState;
|
|
588
|
+
if (navState?.enabled && navState.pathKnown === false) {
|
|
589
|
+
const ul = document.createElement('ul');
|
|
590
|
+
ul.className = 'list-unstyled small ps-3';
|
|
591
|
+
const li = document.createElement('li');
|
|
592
|
+
li.textContent = 'navigation.state is not available; navigation-state learning gate is inactive.';
|
|
593
|
+
ul.appendChild(li);
|
|
594
|
+
learningWarnings.appendChild(ul);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
555
597
|
// Learning status section
|
|
556
598
|
const statusTbody = document.querySelector('#learning-status-table tbody');
|
|
557
599
|
if (statusTbody) {
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
600
|
+
const learningTextMap = {
|
|
601
|
+
off: 'Off',
|
|
602
|
+
stabilizing: 'Stabilising',
|
|
603
|
+
active: 'Active',
|
|
604
|
+
suspended: 'Suspended',
|
|
605
|
+
};
|
|
606
|
+
const observationTextMap = {
|
|
607
|
+
accepted: 'Accepted',
|
|
608
|
+
rejected: 'Rejected',
|
|
609
|
+
invalid: 'Invalid',
|
|
610
|
+
skipped: 'Skipped',
|
|
611
|
+
};
|
|
612
|
+
const reasonTextMap = {
|
|
613
|
+
manual: 'Manual toggle off',
|
|
614
|
+
startup: 'Startup stabilising window',
|
|
615
|
+
observation_reset: 'Recent rejected or invalid observation',
|
|
616
|
+
nav_state_change: 'navigation.state changed',
|
|
617
|
+
nav_state: 'Blocked by navigation.state',
|
|
618
|
+
cog_override: 'Blocked by COG override',
|
|
619
|
+
accepted: 'Observation recorded',
|
|
620
|
+
estimator_outlier: 'Estimator rejected observation',
|
|
621
|
+
missing_input: 'Required learning input unavailable',
|
|
622
|
+
missing_current_when_required: 'Current estimate unavailable',
|
|
623
|
+
learning_off: 'Learning is off',
|
|
624
|
+
stabilizing: 'Stabilising window active',
|
|
625
|
+
nav_state_blocked: 'Learning blocked by navigation.state',
|
|
626
|
+
cog_override_active: 'Learning blocked by COG override',
|
|
627
|
+
stw_below_threshold: 'Below minimum STW for learning',
|
|
628
|
+
sog_below_threshold: 'Below minimum SOG for learning',
|
|
629
|
+
};
|
|
630
|
+
const learningText = learningTextMap[learningState?.state] || '\u2014';
|
|
631
|
+
const obsText = observationTextMap[learningState?.observationState] || '\u2014';
|
|
632
|
+
const reason = reasonTextMap[learningState?.observationReason]
|
|
633
|
+
|| reasonTextMap[learningState?.reason]
|
|
634
|
+
|| '\u2014';
|
|
576
635
|
statusTbody.innerHTML =
|
|
577
|
-
|
|
578
|
-
|
|
636
|
+
`<tr><td class="text-muted small">Learning</td><td class="small">${learningText}</td></tr>` +
|
|
637
|
+
`<tr><td class="text-muted small">Observation</td><td class="small">${obsText}</td></tr>` +
|
|
638
|
+
`<tr><td class="text-muted small">Reason</td><td class="small">${reason}</td></tr>`;
|
|
579
639
|
}
|
|
580
640
|
// Correction table
|
|
581
641
|
const tableEl = document.getElementById('table-container');
|
|
@@ -610,9 +670,11 @@ async function tick() {
|
|
|
610
670
|
}
|
|
611
671
|
lastTickOk = true;
|
|
612
672
|
normaliseState(data);
|
|
673
|
+
state.learningState = data.learningState || null;
|
|
613
674
|
renderLiveSections();
|
|
614
675
|
} else {
|
|
615
676
|
lastTickOk = false;
|
|
677
|
+
state.learningState = null;
|
|
616
678
|
}
|
|
617
679
|
// Always poll status separately so the message reflects plugin state
|
|
618
680
|
// even when /api/report fails (plugin stopped/restarting).
|
|
@@ -620,6 +682,7 @@ async function tick() {
|
|
|
620
682
|
.then(r => r.ok ? r.json() : null)
|
|
621
683
|
.catch(() => null);
|
|
622
684
|
_pluginStatus = statusData?.status ?? '';
|
|
685
|
+
lifecycleWarnings = Array.isArray(statusData?.lifecycleWarnings) ? statusData.lifecycleWarnings : [];
|
|
623
686
|
_refreshMessage();
|
|
624
687
|
}
|
|
625
688
|
|