speedandcurrent 2.2.1 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/index.js +260 -44
- package/package.json +4 -1
- package/public/app.js +56 -33
- package/public/index.html +20 -13
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ Once configured, all consumers on the Signal K bus — KIP, OpenCPN, Instrument
|
|
|
66
66
|
| `environment.current.drift` | m/s | Estimated current speed. |
|
|
67
67
|
| `environment.current.setTrue` | rad | Estimated current direction (the direction the water moves *toward*). |
|
|
68
68
|
|
|
69
|
-
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.
|
|
70
70
|
|
|
71
71
|
---
|
|
72
72
|
|
|
@@ -132,7 +132,7 @@ The panel is organised into three groups:
|
|
|
132
132
|
| Setting | Default | Description |
|
|
133
133
|
|---------|---------|-------------|
|
|
134
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. |
|
|
135
|
-
| **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. |
|
|
136
136
|
|
|
137
137
|
---
|
|
138
138
|
|
|
@@ -148,11 +148,14 @@ When **Assume Current** is enabled, the smoothed current estimate is also shown
|
|
|
148
148
|
|
|
149
149
|
Warnings appear here if any smoothed input is unavailable.
|
|
150
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
|
+
|
|
151
153
|
### Settings
|
|
152
154
|
|
|
153
155
|
| Setting | Default | Description |
|
|
154
156
|
|---------|---------|-------------|
|
|
155
|
-
| **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. |
|
|
156
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. |
|
|
157
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. |
|
|
158
161
|
| **Show Statistics (σ)** | Off | Display standard deviation alongside each smoothed value. Useful for spotting noisy sensors. |
|
|
@@ -208,7 +211,7 @@ Multiple tables can coexist on disk; only the active one is used. This makes it
|
|
|
208
211
|
|
|
209
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.
|
|
210
213
|
|
|
211
|
-
**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.
|
|
212
215
|
|
|
213
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.
|
|
214
217
|
|
package/index.js
CHANGED
|
@@ -19,19 +19,134 @@ 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',
|
|
@@ -67,7 +182,7 @@ module.exports = function (app) {
|
|
|
67
182
|
* writes it back if anything changed. Called once on every start().
|
|
68
183
|
*/
|
|
69
184
|
function migrateConfig() {
|
|
70
|
-
const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication'];
|
|
185
|
+
const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication', 'minSogForLearning'];
|
|
71
186
|
const hadObsolete = obsoleteKeys.some(k => k in options);
|
|
72
187
|
for (const k of obsoleteKeys) delete options[k];
|
|
73
188
|
if (hadObsolete || (options.configVersion || 0) < 2) {
|
|
@@ -109,7 +224,7 @@ module.exports = function (app) {
|
|
|
109
224
|
|
|
110
225
|
function swapTable(newTable) {
|
|
111
226
|
table = newTable;
|
|
112
|
-
minSpeed = table.step[0]
|
|
227
|
+
minSpeed = table.step[0];
|
|
113
228
|
if (reportFull) reportFull.setTables([table]);
|
|
114
229
|
lastSave = Date.now(); // explicit table operations already save; defer next periodic save
|
|
115
230
|
}
|
|
@@ -136,12 +251,67 @@ module.exports = function (app) {
|
|
|
136
251
|
let noCurrent = null;
|
|
137
252
|
let rawBoatSpeed = null;
|
|
138
253
|
let rawGroundSpeed = null;
|
|
139
|
-
let started = null;
|
|
140
254
|
let minSpeed = 0;
|
|
141
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;
|
|
142
262
|
let lifecycleWarningMap = new Map();
|
|
143
263
|
let lifecycleWarnings = [];
|
|
144
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
|
+
|
|
145
315
|
function setLifecycleWarning(id, status, path) {
|
|
146
316
|
const safePath = path || 'unknown path';
|
|
147
317
|
const message = status === 'idle'
|
|
@@ -212,6 +382,7 @@ module.exports = function (app) {
|
|
|
212
382
|
} else {
|
|
213
383
|
const payload = reportFull.report();
|
|
214
384
|
payload.lifecycleWarnings = lifecycleWarnings;
|
|
385
|
+
payload.learningState = getLearningStatePayload();
|
|
215
386
|
res.json(payload);
|
|
216
387
|
}
|
|
217
388
|
});
|
|
@@ -227,7 +398,7 @@ module.exports = function (app) {
|
|
|
227
398
|
|
|
228
399
|
|
|
229
400
|
router.get('/api/status', (req, res) => {
|
|
230
|
-
res.json({ status: pluginStatus, isRunning, lifecycleWarnings });
|
|
401
|
+
res.json({ status: pluginStatus, isRunning, lifecycleWarnings, learningState: getLearningStatePayload() });
|
|
231
402
|
});
|
|
232
403
|
|
|
233
404
|
// --- Settings API ---
|
|
@@ -248,7 +419,6 @@ module.exports = function (app) {
|
|
|
248
419
|
}
|
|
249
420
|
}
|
|
250
421
|
changedOptions = { ...changedOptions, ...body };
|
|
251
|
-
hasPendingChanges = true;
|
|
252
422
|
res.json({ ...options, ...changedOptions });
|
|
253
423
|
});
|
|
254
424
|
|
|
@@ -363,7 +533,7 @@ module.exports = function (app) {
|
|
|
363
533
|
const tableName = options.tableName || 'correctionTable';
|
|
364
534
|
const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
|
|
365
535
|
table = loadTable(options, tableFilePath);
|
|
366
|
-
minSpeed = table.step[0]
|
|
536
|
+
minSpeed = table.step[0];
|
|
367
537
|
|
|
368
538
|
//#region Handler and Polar Initialization
|
|
369
539
|
const { SmootherClass, smootherOptions } = resolveSmootherConfig();
|
|
@@ -382,7 +552,7 @@ module.exports = function (app) {
|
|
|
382
552
|
});
|
|
383
553
|
rawHeading = smoothedHeading.handler;
|
|
384
554
|
|
|
385
|
-
//attitude
|
|
555
|
+
// attitude
|
|
386
556
|
smoothedAttitude = createSmoothedHandler({
|
|
387
557
|
app, pluginId: plugin.id,
|
|
388
558
|
id: 'attitude',
|
|
@@ -398,6 +568,13 @@ module.exports = function (app) {
|
|
|
398
568
|
});
|
|
399
569
|
rawAttitude = smoothedAttitude.handler;
|
|
400
570
|
|
|
571
|
+
navigationStateHandler = new MessageHandler(app, plugin.id, 'navigationState');
|
|
572
|
+
navigationStateHandler.configure('navigation.state');
|
|
573
|
+
navigationStateHandler.onDelta = () => {
|
|
574
|
+
handleNavigationStateDelta();
|
|
575
|
+
};
|
|
576
|
+
navigationStateHandler.subscribe();
|
|
577
|
+
|
|
401
578
|
|
|
402
579
|
// current
|
|
403
580
|
// send metadata for current
|
|
@@ -452,26 +629,26 @@ module.exports = function (app) {
|
|
|
452
629
|
// Drain any pending option changes before calculating
|
|
453
630
|
if (Object.keys(changedOptions).length) applyOptionChanges();
|
|
454
631
|
|
|
455
|
-
const
|
|
632
|
+
const learningMode = evaluateLearningMode({
|
|
633
|
+
options,
|
|
634
|
+
navigationState: buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState),
|
|
635
|
+
stabilizingUntil: learningStabilizingUntil,
|
|
636
|
+
stabilizingReason: learningStabilizingReason,
|
|
637
|
+
now: Date.now()
|
|
638
|
+
});
|
|
456
639
|
|
|
457
|
-
|
|
640
|
+
const wellUnderway = learningMode.state !== 'stabilizing';
|
|
641
|
+
setStatus(learningMode.state === 'stabilizing' ? 'Stabilizing' : 'Running');
|
|
458
642
|
if (options.estimateBoatSpeed) correct(wellUnderway);
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if (!isTableEmpty(table)) {
|
|
466
|
-
saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
|
|
467
|
-
}
|
|
468
|
-
lastSave = now;
|
|
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'));
|
|
469
649
|
}
|
|
470
|
-
|
|
471
|
-
table.lastUpdateResult = 'stabilizing';
|
|
650
|
+
lastSave = now;
|
|
472
651
|
}
|
|
473
|
-
} else {
|
|
474
|
-
table.lastUpdateResult = null;
|
|
475
652
|
}
|
|
476
653
|
}
|
|
477
654
|
,
|
|
@@ -572,8 +749,11 @@ module.exports = function (app) {
|
|
|
572
749
|
//#endregion
|
|
573
750
|
|
|
574
751
|
isRunning = true;
|
|
575
|
-
started = Date.now();
|
|
576
752
|
lastSave = 0;
|
|
753
|
+
lastObservationState = null;
|
|
754
|
+
lastObservationReason = null;
|
|
755
|
+
lastNavigationStateValue = normalizeNavigationState(navigationStateHandler?.value);
|
|
756
|
+
resetLearningStabilization('startup', LONG_STABILIZING_MS);
|
|
577
757
|
setStatus('Running');
|
|
578
758
|
app.debug("Running");
|
|
579
759
|
|
|
@@ -592,6 +772,7 @@ module.exports = function (app) {
|
|
|
592
772
|
smoothedAttitude = smoothedAttitude?.terminate();
|
|
593
773
|
rawCurrent = rawCurrent?.terminate();
|
|
594
774
|
smoothedCurrent = smoothedCurrent?.terminate?.();
|
|
775
|
+
navigationStateHandler = navigationStateHandler?.terminate();
|
|
595
776
|
smoothedBoatSpeed = smoothedBoatSpeed?.terminate();
|
|
596
777
|
correctedBoatSpeed = correctedBoatSpeed?.terminate();
|
|
597
778
|
lrnBoatSpeed = lrnBoatSpeed?.terminate();
|
|
@@ -607,7 +788,11 @@ module.exports = function (app) {
|
|
|
607
788
|
noCurrent = null;
|
|
608
789
|
rawBoatSpeed = null;
|
|
609
790
|
rawGroundSpeed = null;
|
|
610
|
-
|
|
791
|
+
lastNavigationStateValue = null;
|
|
792
|
+
learningStabilizingUntil = 0;
|
|
793
|
+
learningStabilizingReason = 'startup';
|
|
794
|
+
lastObservationState = null;
|
|
795
|
+
lastObservationReason = null;
|
|
611
796
|
app.setPluginStatus("Stopped");
|
|
612
797
|
app.debug("Stopped");
|
|
613
798
|
|
|
@@ -642,7 +827,7 @@ module.exports = function (app) {
|
|
|
642
827
|
|
|
643
828
|
// Priority 1: SOG fallback when STW is stuck at zero
|
|
644
829
|
// (onChange only fires when STW is ready, so a missing STW cannot reach here)
|
|
645
|
-
if (options.sogFallback && rawGroundSpeed.ready &&
|
|
830
|
+
if (options.sogFallback && rawGroundSpeed.ready && rawBoatSpeed.ready && rawBoatSpeed.value === 0 && rawGroundSpeed.magnitude >= minSpeed) {
|
|
646
831
|
correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
|
|
647
832
|
}
|
|
648
833
|
else if (rawAttitude.ready) {
|
|
@@ -653,11 +838,7 @@ module.exports = function (app) {
|
|
|
653
838
|
}
|
|
654
839
|
// Current estimation and residual also require heading (to rotate into ground frame).
|
|
655
840
|
// Also handle near-zero SOG where COG is unavailable: treat groundspeed as zero vector.
|
|
656
|
-
const
|
|
657
|
-
const nearZeroGroundSpeed = !rawGroundSpeed.ready &&
|
|
658
|
-
sogHandler.ready &&
|
|
659
|
-
Number.isFinite(sogHandler.value) &&
|
|
660
|
-
sogHandler.value < 0.3;
|
|
841
|
+
const nearZeroGroundSpeed = isCogOverrideActive(rawGroundSpeed, minSpeed);
|
|
661
842
|
if (rawHeading.ready && (rawGroundSpeed.ready || nearZeroGroundSpeed)) {
|
|
662
843
|
boatSpeedRefGround.copyFrom(correctedBoatSpeed);
|
|
663
844
|
boatSpeedRefGround.rotate(rawHeading.value);
|
|
@@ -695,18 +876,43 @@ module.exports = function (app) {
|
|
|
695
876
|
*/
|
|
696
877
|
function updateTable() {
|
|
697
878
|
lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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';
|
|
701
889
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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;
|
|
708
907
|
}
|
|
709
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
|
+
}
|
|
710
916
|
}
|
|
711
917
|
|
|
712
918
|
/**
|
|
@@ -812,7 +1018,6 @@ module.exports = function (app) {
|
|
|
812
1018
|
}
|
|
813
1019
|
}
|
|
814
1020
|
}
|
|
815
|
-
|
|
816
1021
|
// All other keys (sogFallback, estimateBoatSpeed, assumeCurrent,
|
|
817
1022
|
// stability, smootherClass, etc.) are read
|
|
818
1023
|
// directly from options.* so no extra action needed.
|
|
@@ -834,10 +1039,21 @@ module.exports = function (app) {
|
|
|
834
1039
|
}
|
|
835
1040
|
if (smoothedAttitude) { smoothedAttitude.setSmootherClass(SC); smoothedAttitude.setSmootherOptions(so); }
|
|
836
1041
|
}
|
|
837
|
-
|
|
838
|
-
hasPendingChanges = false;
|
|
839
1042
|
saveOptions();
|
|
840
1043
|
}
|
|
841
1044
|
|
|
842
1045
|
return plugin;
|
|
843
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.1",
|
|
4
4
|
"description": "A plugin that automatically corrects boat speed and calculates current and leeway using sensor fusion.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
"./docs/correctionTable.png",
|
|
35
35
|
"./docs/STW_estimation.png",
|
|
36
36
|
"./docs/learning.png"
|
|
37
|
+
],
|
|
38
|
+
"recommends": [
|
|
39
|
+
"@meri-imperiumi/signalk-autostate"
|
|
37
40
|
]
|
|
38
41
|
},
|
|
39
42
|
"dependencies": {
|
package/public/app.js
CHANGED
|
@@ -275,11 +275,8 @@ function _refreshMessage() {
|
|
|
275
275
|
else el.textContent = _explicitMsg;
|
|
276
276
|
return;
|
|
277
277
|
}
|
|
278
|
-
const isRunningOk = (_pluginStatus === 'Running' || _pluginStatus === '');
|
|
278
|
+
const isRunningOk = (_pluginStatus === 'Running' || _pluginStatus === 'Stabilizing' || _pluginStatus === '');
|
|
279
279
|
if (!isRunningOk) {
|
|
280
|
-
el.style.color =
|
|
281
|
-
_pluginStatus === 'Stabilizing' ? '#084298' :
|
|
282
|
-
_pluginStatus === 'Falling back to Speed Over Ground' ? '#664d03' : '#842029';
|
|
283
280
|
el.textContent = _pluginStatus;
|
|
284
281
|
} else {
|
|
285
282
|
el.style.color = '';
|
|
@@ -292,6 +289,7 @@ function _refreshMessage() {
|
|
|
292
289
|
const paramMeta = {
|
|
293
290
|
estimateBoatSpeed: { label: 'Estimate boat speed', type: 'boolean' },
|
|
294
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.' },
|
|
295
293
|
assumeCurrent: { label: 'Assume current during update', type: 'boolean', description: 'Experimental, works best when currents are relatively stable.' },
|
|
296
294
|
sogFallback: { label: 'Groundspeed fallback', type: 'boolean', description: 'Output Groundspeed as Boatspeed when the paddlewheel sensor is malfunctioning or stalled.' },
|
|
297
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.' },
|
|
@@ -323,14 +321,8 @@ const paramMeta = {
|
|
|
323
321
|
};
|
|
324
322
|
|
|
325
323
|
// Settings groups for each UI section
|
|
326
|
-
const INPUTS_SETTING_KEYS = [
|
|
327
|
-
'smootherClass',
|
|
328
|
-
{ key: 'smootherTau', showIf: cfg => cfg.smootherClass === 'ExponentialSmoother' },
|
|
329
|
-
{ key: 'smootherTimeSpan', showIf: cfg => (cfg.smootherClass || 'MovingAverageSmoother') === 'MovingAverageSmoother' },
|
|
330
|
-
{ key: 'smootherSteadyState', showIf: cfg => cfg.smootherClass === 'KalmanSmoother' },
|
|
331
|
-
];
|
|
332
324
|
const ESTIMATION_SETTING_KEYS = ['sogFallback'];
|
|
333
|
-
const LEARNING_SETTING_KEYS = ['stability','assumeCurrent','showStatistics'];
|
|
325
|
+
const LEARNING_SETTING_KEYS = ['stability', 'suspendLearningOnNavigationState', 'assumeCurrent', 'showStatistics'];
|
|
334
326
|
const SMOOTHER_SETTING_KEYS = [
|
|
335
327
|
'smootherClass',
|
|
336
328
|
{ key: 'smootherTau', showIf: cfg => cfg.smootherClass === 'ExponentialSmoother' },
|
|
@@ -445,7 +437,6 @@ function renderSectionToggles() {
|
|
|
445
437
|
|
|
446
438
|
function renderSettingsPanel() {
|
|
447
439
|
if (!config) return;
|
|
448
|
-
renderSettingsRows('inputs-settings-table', INPUTS_SETTING_KEYS);
|
|
449
440
|
renderSettingsRows('estimation-settings-table', ESTIMATION_SETTING_KEYS);
|
|
450
441
|
renderSettingsRows('smoother-settings-table', SMOOTHER_SETTING_KEYS);
|
|
451
442
|
renderSettingsRows('learning-settings-table', LEARNING_SETTING_KEYS);
|
|
@@ -535,6 +526,7 @@ function renderGroupInto(elId, polars, deltas, attitudes) {
|
|
|
535
526
|
}
|
|
536
527
|
|
|
537
528
|
function renderLiveSections() {
|
|
529
|
+
const learningState = state.learningState || null;
|
|
538
530
|
const inputPolars = filterById(state.polarsAll, ['groundSpeed']);
|
|
539
531
|
const inputDeltas = filterById(state.deltasAll, ['heading.angle', 'boatSpeed']);
|
|
540
532
|
const inputAttitudes = filterById(state.attitudesAll, ['attitude']);
|
|
@@ -583,31 +575,60 @@ function renderLiveSections() {
|
|
|
583
575
|
);
|
|
584
576
|
// Learning — warnings
|
|
585
577
|
const learningWarnings = document.getElementById('learning-warnings');
|
|
586
|
-
if (learningWarnings)
|
|
578
|
+
if (learningWarnings) {
|
|
579
|
+
learningWarnings.innerHTML = '';
|
|
580
|
+
const navState = learningState?.navigationState;
|
|
581
|
+
if (navState?.enabled && navState.pathKnown === false) {
|
|
582
|
+
const ul = document.createElement('ul');
|
|
583
|
+
ul.className = 'list-unstyled small ps-3';
|
|
584
|
+
const li = document.createElement('li');
|
|
585
|
+
li.textContent = 'navigation.state is not available; navigation-state learning gate is inactive.';
|
|
586
|
+
ul.appendChild(li);
|
|
587
|
+
learningWarnings.appendChild(ul);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
587
590
|
// Learning status section
|
|
588
591
|
const statusTbody = document.querySelector('#learning-status-table tbody');
|
|
589
592
|
if (statusTbody) {
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
593
|
+
const learningTextMap = {
|
|
594
|
+
off: 'Off',
|
|
595
|
+
stabilizing: 'Stabilising',
|
|
596
|
+
active: 'Active',
|
|
597
|
+
suspended: 'Suspended',
|
|
598
|
+
};
|
|
599
|
+
const observationTextMap = {
|
|
600
|
+
accepted: 'Accepted',
|
|
601
|
+
rejected: 'Rejected',
|
|
602
|
+
invalid: 'Invalid',
|
|
603
|
+
skipped: 'Skipped',
|
|
604
|
+
};
|
|
605
|
+
const reasonTextMap = {
|
|
606
|
+
manual: 'Manual toggle off',
|
|
607
|
+
startup: 'Startup stabilising window',
|
|
608
|
+
observation_reset: 'Recent rejected or invalid observation',
|
|
609
|
+
nav_state_change: 'navigation.state changed',
|
|
610
|
+
nav_state: 'Blocked by navigation.state',
|
|
611
|
+
cog_override: 'Blocked by COG override',
|
|
612
|
+
accepted: 'Observation recorded',
|
|
613
|
+
estimator_outlier: 'Estimator rejected observation',
|
|
614
|
+
missing_input: 'Required learning input unavailable',
|
|
615
|
+
missing_current_when_required: 'Current estimate unavailable',
|
|
616
|
+
learning_off: 'Learning is off',
|
|
617
|
+
stabilizing: 'Stabilising window active',
|
|
618
|
+
nav_state_blocked: 'Learning blocked by navigation.state',
|
|
619
|
+
cog_override_active: 'Learning blocked by COG override',
|
|
620
|
+
stw_below_threshold: 'Below minimum STW for learning',
|
|
621
|
+
sog_below_threshold: 'Below minimum SOG for learning',
|
|
622
|
+
};
|
|
623
|
+
const learningText = learningTextMap[learningState?.state] || '\u2014';
|
|
624
|
+
const obsText = observationTextMap[learningState?.observationState] || '\u2014';
|
|
625
|
+
const reason = reasonTextMap[learningState?.observationReason]
|
|
626
|
+
|| reasonTextMap[learningState?.reason]
|
|
627
|
+
|| '\u2014';
|
|
608
628
|
statusTbody.innerHTML =
|
|
609
|
-
|
|
610
|
-
|
|
629
|
+
`<tr><td class="text-muted small">Learning</td><td class="small">${learningText}</td></tr>` +
|
|
630
|
+
`<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>`;
|
|
611
632
|
}
|
|
612
633
|
// Correction table
|
|
613
634
|
const tableEl = document.getElementById('table-container');
|
|
@@ -642,9 +663,11 @@ async function tick() {
|
|
|
642
663
|
}
|
|
643
664
|
lastTickOk = true;
|
|
644
665
|
normaliseState(data);
|
|
666
|
+
state.learningState = data.learningState || null;
|
|
645
667
|
renderLiveSections();
|
|
646
668
|
} else {
|
|
647
669
|
lastTickOk = false;
|
|
670
|
+
state.learningState = null;
|
|
648
671
|
}
|
|
649
672
|
// Always poll status separately so the message reflects plugin state
|
|
650
673
|
// even when /api/report fails (plugin stopped/restarting).
|
package/public/index.html
CHANGED
|
@@ -13,22 +13,31 @@
|
|
|
13
13
|
el.href = href;
|
|
14
14
|
document.head.appendChild(el);
|
|
15
15
|
};
|
|
16
|
+
let manifestFailure = 'manifest contained no CSS assets';
|
|
16
17
|
try {
|
|
17
18
|
const res = await fetch('/admin/.vite/manifest.json');
|
|
18
19
|
if (res.ok) {
|
|
19
20
|
const manifest = await res.json();
|
|
20
|
-
|
|
21
|
+
const stylesheets = new Set();
|
|
21
22
|
for (const entry of Object.values(manifest)) {
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
appendStylesheet('/admin/' + css);
|
|
25
|
-
found = true;
|
|
23
|
+
if (entry.file?.endsWith('.css')) {
|
|
24
|
+
stylesheets.add(entry.file);
|
|
26
25
|
}
|
|
26
|
+
if (entry.isEntry) {
|
|
27
|
+
for (const css of entry.css ?? []) {
|
|
28
|
+
stylesheets.add(css);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (stylesheets.size > 0) {
|
|
33
|
+
for (const css of stylesheets) appendStylesheet('/admin/' + css);
|
|
34
|
+
return;
|
|
27
35
|
}
|
|
28
|
-
|
|
36
|
+
} else {
|
|
37
|
+
manifestFailure = `manifest request returned ${res.status}`;
|
|
29
38
|
}
|
|
30
39
|
} catch (e) {
|
|
31
|
-
|
|
40
|
+
manifestFailure = `manifest request failed: ${e.message}`;
|
|
32
41
|
}
|
|
33
42
|
try {
|
|
34
43
|
const res = await fetch('/');
|
|
@@ -37,9 +46,11 @@
|
|
|
37
46
|
const link = doc.querySelector('link[rel="stylesheet"]');
|
|
38
47
|
if (link) {
|
|
39
48
|
appendStylesheet(new URL(link.getAttribute('href'), res.url).href);
|
|
49
|
+
return;
|
|
40
50
|
}
|
|
51
|
+
console.warn(`[webapp] Could not load SignalK admin stylesheet: ${manifestFailure}; legacy admin page contained no stylesheet link.`);
|
|
41
52
|
} catch (e) {
|
|
42
|
-
console.warn(
|
|
53
|
+
console.warn(`[webapp] Could not load SignalK admin stylesheet: ${manifestFailure}; legacy fallback failed: ${e.message}`);
|
|
43
54
|
}
|
|
44
55
|
})();
|
|
45
56
|
</script>
|
|
@@ -91,11 +102,7 @@
|
|
|
91
102
|
<div class="card">
|
|
92
103
|
<div class="card-header fw-bold text-uppercase">Inputs</div>
|
|
93
104
|
<div class="card-body">
|
|
94
|
-
<h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mb-1 small">
|
|
95
|
-
<table class="table table-sm table-borderless mb-0" id="inputs-settings-table">
|
|
96
|
-
<tbody></tbody>
|
|
97
|
-
</table>
|
|
98
|
-
<h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">Live Values</h6>
|
|
105
|
+
<h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mb-1 small">Live Values</h6>
|
|
99
106
|
<div id="inputs-values"></div>
|
|
100
107
|
<div id="inputs-warnings"></div>
|
|
101
108
|
</div>
|