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/index.js
CHANGED
|
@@ -17,18 +17,79 @@ const {
|
|
|
17
17
|
Table2D
|
|
18
18
|
} = require('signalkutilities');
|
|
19
19
|
|
|
20
|
-
const {
|
|
20
|
+
const {
|
|
21
|
+
CorrectionTable,
|
|
22
|
+
DEFAULT_CORRECTION_DRIFT_RATE,
|
|
23
|
+
DEFAULT_PROCESS_NOISE_RATE,
|
|
24
|
+
SECONDS_PER_MONTH,
|
|
25
|
+
KNOT_TO_MPS
|
|
26
|
+
} = require('./correctionTable.js');
|
|
21
27
|
|
|
22
28
|
const LONG_STABILIZING_MS = 60 * 1000;
|
|
23
|
-
const
|
|
24
|
-
const
|
|
29
|
+
const MIN_SMOOTHING_WINDOW_SECONDS = 5;
|
|
30
|
+
const LEARNING_GAP_SECONDS = 1;
|
|
31
|
+
// navigation.state values that indicate the vessel is not moving. Only applied as an
|
|
32
|
+
// override (see isVesselMoving) when options.suspendLearningOnNavigationState is enabled.
|
|
33
|
+
const NOT_MOVING_NAVIGATION_STATES = new Set(['anchored', 'moored', 'motoring']);
|
|
34
|
+
const OBSOLETE_SMOOTHER_KEYS = new Set(['smootherClass', 'smootherTau', 'smootherSteadyState']);
|
|
35
|
+
const MAX_CORRECTION_DRIFT_RATE = 3;
|
|
36
|
+
|
|
37
|
+
function correctionDriftRateToProcessNoiseRate(driftRate) {
|
|
38
|
+
const rate = Number.isFinite(Number(driftRate))
|
|
39
|
+
? Math.min(MAX_CORRECTION_DRIFT_RATE, Math.max(0, Number(driftRate)))
|
|
40
|
+
: DEFAULT_CORRECTION_DRIFT_RATE;
|
|
41
|
+
return (rate * KNOT_TO_MPS) ** 2 / SECONDS_PER_MONTH;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function legacyStabilityToCorrectionDriftRate(stability) {
|
|
45
|
+
if (!Number.isFinite(Number(stability))) return DEFAULT_CORRECTION_DRIFT_RATE;
|
|
46
|
+
return Math.sqrt((10 ** -Number(stability)) * SECONDS_PER_MONTH) / KNOT_TO_MPS;
|
|
47
|
+
}
|
|
25
48
|
|
|
26
49
|
function normalizeNavigationState(value) {
|
|
27
50
|
return typeof value === 'string' ? value.trim().toLowerCase() : null;
|
|
28
51
|
}
|
|
29
52
|
|
|
30
53
|
function getShortStabilizingMs(options = {}) {
|
|
31
|
-
return Math.max(
|
|
54
|
+
return Math.max(MIN_SMOOTHING_WINDOW_SECONDS, Number(options.smootherTimeSpan) || MIN_SMOOTHING_WINDOW_SECONDS) * 1000;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getSmoothingWindowSeconds(options = {}) {
|
|
58
|
+
return Math.max(MIN_SMOOTHING_WINDOW_SECONDS, Number(options.smootherTimeSpan) || MIN_SMOOTHING_WINDOW_SECONDS);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getLearningIntervalMs(options = {}) {
|
|
62
|
+
return (getSmoothingWindowSeconds(options) + LEARNING_GAP_SECONDS) * 1000;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function migrateCorrectionTableData(data, { filePath, statSync = fs.statSync, now = Date.now(), debug = () => {} } = {}) {
|
|
66
|
+
if (!data || (data.schemaVersion !== undefined && data.schemaVersion !== 1) || data.schemaVersion === 2) {
|
|
67
|
+
return { data, migrated: false };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let lastAcceptedAt;
|
|
71
|
+
try {
|
|
72
|
+
const stat = statSync(filePath);
|
|
73
|
+
lastAcceptedAt = Number.isFinite(stat?.mtimeMs) ? stat.mtimeMs : null;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
lastAcceptedAt = null;
|
|
76
|
+
}
|
|
77
|
+
if (!Number.isFinite(lastAcceptedAt)) {
|
|
78
|
+
lastAcceptedAt = now;
|
|
79
|
+
debug(`Correction table migration used load time for ${filePath}: file modification time unavailable`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
migrated: true,
|
|
84
|
+
data: {
|
|
85
|
+
...data,
|
|
86
|
+
schemaVersion: 2,
|
|
87
|
+
table: data.table.map(row => row.map(cell => ({
|
|
88
|
+
...cell,
|
|
89
|
+
lastAcceptedAt: cell?.state ? lastAcceptedAt : null
|
|
90
|
+
})))
|
|
91
|
+
}
|
|
92
|
+
};
|
|
32
93
|
}
|
|
33
94
|
|
|
34
95
|
function isCogOverrideActive(groundSpeedPolar, speedThreshold) {
|
|
@@ -39,45 +100,50 @@ function isCogOverrideActive(groundSpeedPolar, speedThreshold) {
|
|
|
39
100
|
&& sogHandler.value < speedThreshold;
|
|
40
101
|
}
|
|
41
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The vessel's own "moving" state, derived from SOG. When navigation.state is known
|
|
105
|
+
* (has ever delivered a value) and the suspend-on-navigation-state setting is enabled,
|
|
106
|
+
* it can additionally force "not moving" (anchored/moored/motoring) — but it can never
|
|
107
|
+
* force "moving" when SOG says otherwise, since navigation.state can go stale or wrong
|
|
108
|
+
* without a way to verify it independently.
|
|
109
|
+
*/
|
|
110
|
+
function isVesselMoving({ sogMagnitude, speedThreshold, navigationStateHandler, gateEnabled }) {
|
|
111
|
+
const ownMoving = Number.isFinite(sogMagnitude) && sogMagnitude >= speedThreshold;
|
|
112
|
+
if (!ownMoving || !gateEnabled) return ownMoving;
|
|
113
|
+
const state = navigationStateHandler?.state;
|
|
114
|
+
const navigationKnown = state?.pathKnown === true && state?.hasDelta === true;
|
|
115
|
+
if (!navigationKnown) return true;
|
|
116
|
+
return !NOT_MOVING_NAVIGATION_STATES.has(normalizeNavigationState(navigationStateHandler.value));
|
|
117
|
+
}
|
|
118
|
+
|
|
42
119
|
/**
|
|
43
120
|
* Leeway is the angle of the corrected boatspeed vector, so a small lateral
|
|
44
121
|
* correction divided by a near-zero magnitude yields a large, meaningless angle.
|
|
45
122
|
*/
|
|
46
|
-
function isLeewayValid(speed, speedThreshold,
|
|
123
|
+
function isLeewayValid(speed, speedThreshold, vesselMoving) {
|
|
47
124
|
if (!(speed >= speedThreshold)) return false;
|
|
48
|
-
|
|
49
|
-
if (state?.ready !== true) return true;
|
|
50
|
-
return !ALWAYS_BLOCKING_NAVIGATION_STATES.has(normalizeNavigationState(navigationStateHandler.value));
|
|
125
|
+
return vesselMoving;
|
|
51
126
|
}
|
|
52
127
|
|
|
53
|
-
function evaluateLearningMode({ options = {},
|
|
54
|
-
const normalizedNavigationState = normalizeNavigationState(navigationState?.value);
|
|
55
|
-
const navigationGateReady = navigationState?.ready === true;
|
|
56
|
-
const navigationGateBlocking = navigationGateReady && (
|
|
57
|
-
ALWAYS_BLOCKING_NAVIGATION_STATES.has(normalizedNavigationState)
|
|
58
|
-
|| (!!options.suspendLearningOnNavigationState && OPTIONAL_BLOCKING_NAVIGATION_STATES.has(normalizedNavigationState))
|
|
59
|
-
);
|
|
60
|
-
|
|
128
|
+
function evaluateLearningMode({ options = {}, vesselMoving = true, stabilizingUntil = 0, stabilizingReason = null, now = Date.now() }) {
|
|
61
129
|
if (!options.updateCorrectionTable) {
|
|
62
|
-
return { state: 'off', reason: 'manual'
|
|
130
|
+
return { state: 'off', reason: 'manual' };
|
|
63
131
|
}
|
|
64
132
|
|
|
65
|
-
if (
|
|
66
|
-
return { state: 'suspended', reason: '
|
|
133
|
+
if (!vesselMoving) {
|
|
134
|
+
return { state: 'suspended', reason: 'not_moving' };
|
|
67
135
|
}
|
|
68
136
|
|
|
69
137
|
if (now < stabilizingUntil) {
|
|
70
|
-
return { state: 'stabilizing', reason: stabilizingReason || 'startup'
|
|
138
|
+
return { state: 'stabilizing', reason: stabilizingReason || 'startup' };
|
|
71
139
|
}
|
|
72
140
|
|
|
73
|
-
return { state: 'active', reason: null
|
|
141
|
+
return { state: 'active', reason: null };
|
|
74
142
|
}
|
|
75
143
|
|
|
76
144
|
function evaluateObservationGate({
|
|
77
145
|
learningMode,
|
|
78
146
|
inputsReady,
|
|
79
|
-
assumeCurrent,
|
|
80
|
-
currentReady,
|
|
81
147
|
stw,
|
|
82
148
|
sog,
|
|
83
149
|
speedThreshold,
|
|
@@ -86,13 +152,13 @@ function evaluateObservationGate({
|
|
|
86
152
|
return { state: 'skipped', reason: 'learning_off' };
|
|
87
153
|
}
|
|
88
154
|
if (learningMode.state === 'suspended') {
|
|
89
|
-
return { state: 'skipped', reason: learningMode.reason === 'cog_override' ? 'cog_override_active' : '
|
|
155
|
+
return { state: 'skipped', reason: learningMode.reason === 'cog_override' ? 'cog_override_active' : 'not_moving_blocked' };
|
|
90
156
|
}
|
|
91
157
|
if (learningMode.state === 'stabilizing') {
|
|
92
158
|
return { state: 'skipped', reason: 'stabilizing' };
|
|
93
159
|
}
|
|
94
160
|
if (!inputsReady) {
|
|
95
|
-
return { state: 'invalid', reason:
|
|
161
|
+
return { state: 'invalid', reason: 'missing_input' };
|
|
96
162
|
}
|
|
97
163
|
if (!(stw > speedThreshold)) {
|
|
98
164
|
return { state: 'skipped', reason: 'stw_below_threshold' };
|
|
@@ -118,6 +184,7 @@ function buildNavigationStateStatus(handler, gateEnabled) {
|
|
|
118
184
|
const state = handler.state;
|
|
119
185
|
const value = handler.value ?? null;
|
|
120
186
|
const normalized = normalizeNavigationState(value);
|
|
187
|
+
const known = state.pathKnown === true && state.hasDelta === true;
|
|
121
188
|
return {
|
|
122
189
|
enabled: !!gateEnabled,
|
|
123
190
|
path: handler.path,
|
|
@@ -125,10 +192,7 @@ function buildNavigationStateStatus(handler, gateEnabled) {
|
|
|
125
192
|
ready: state.ready,
|
|
126
193
|
isStale: state.isStale,
|
|
127
194
|
value,
|
|
128
|
-
blocking:
|
|
129
|
-
ALWAYS_BLOCKING_NAVIGATION_STATES.has(normalized)
|
|
130
|
-
|| (!!gateEnabled && OPTIONAL_BLOCKING_NAVIGATION_STATES.has(normalized))
|
|
131
|
-
)
|
|
195
|
+
blocking: !!gateEnabled && known && NOT_MOVING_NAVIGATION_STATES.has(normalized)
|
|
132
196
|
};
|
|
133
197
|
}
|
|
134
198
|
|
|
@@ -140,14 +204,14 @@ function getDerivedObservationStatus(learningMode, lastState, lastReason) {
|
|
|
140
204
|
return { state: 'skipped', reason: 'stabilizing' };
|
|
141
205
|
}
|
|
142
206
|
if (learningMode.state === 'suspended') {
|
|
143
|
-
return { state: 'skipped', reason: learningMode.reason === 'cog_override' ? 'cog_override_active' : '
|
|
207
|
+
return { state: 'skipped', reason: learningMode.reason === 'cog_override' ? 'cog_override_active' : 'not_moving_blocked' };
|
|
144
208
|
}
|
|
145
209
|
return { state: lastState, reason: lastReason };
|
|
146
210
|
}
|
|
147
211
|
|
|
148
212
|
module.exports = function (app) {
|
|
149
213
|
|
|
150
|
-
const DEFAULT_DIMS = { maxSpeed:
|
|
214
|
+
const DEFAULT_DIMS = { maxSpeed: 10, speedStep: 1, maxHeel: 24, heelStep: 6 };
|
|
151
215
|
|
|
152
216
|
let options = {};
|
|
153
217
|
let changedOptions = {};
|
|
@@ -155,15 +219,11 @@ module.exports = function (app) {
|
|
|
155
219
|
sogFallback: true,
|
|
156
220
|
estimateBoatSpeed: false,
|
|
157
221
|
updateCorrectionTable: true,
|
|
158
|
-
|
|
159
|
-
assumeCurrent: false,
|
|
222
|
+
correctionDriftRate: DEFAULT_CORRECTION_DRIFT_RATE,
|
|
160
223
|
suspendLearningOnNavigationState: false,
|
|
161
224
|
tableName: 'correctionTable',
|
|
162
|
-
configVersion:
|
|
163
|
-
smootherClass: 'MovingAverageSmoother',
|
|
164
|
-
smootherTau: 3,
|
|
225
|
+
configVersion: 3,
|
|
165
226
|
smootherTimeSpan: 5,
|
|
166
|
-
smootherSteadyState: 0.2,
|
|
167
227
|
showStatistics: false
|
|
168
228
|
};
|
|
169
229
|
|
|
@@ -175,6 +235,10 @@ module.exports = function (app) {
|
|
|
175
235
|
// Strip embedded table — stored separately on disk
|
|
176
236
|
const { correctionTable: _drop, ...rest } = raw;
|
|
177
237
|
options = { ...defaultOptions, ...rest };
|
|
238
|
+
if (!('correctionDriftRate' in rest) && Number.isFinite(Number(rest.stability))) {
|
|
239
|
+
options.correctionDriftRate = legacyStabilityToCorrectionDriftRate(rest.stability);
|
|
240
|
+
}
|
|
241
|
+
delete options.stability;
|
|
178
242
|
}
|
|
179
243
|
|
|
180
244
|
function saveOptions() {
|
|
@@ -193,43 +257,28 @@ module.exports = function (app) {
|
|
|
193
257
|
* writes it back if anything changed. Called once on every start().
|
|
194
258
|
*/
|
|
195
259
|
function migrateConfig() {
|
|
196
|
-
const obsoleteKeys = [
|
|
260
|
+
const obsoleteKeys = [
|
|
261
|
+
'headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource',
|
|
262
|
+
'preventDuplication', 'minSogForLearning', 'stability', 'smootherClass',
|
|
263
|
+
'smootherTau', 'smootherSteadyState', 'assumeCurrent'
|
|
264
|
+
];
|
|
197
265
|
const hadObsolete = obsoleteKeys.some(k => k in options);
|
|
198
266
|
for (const k of obsoleteKeys) delete options[k];
|
|
199
|
-
|
|
200
|
-
|
|
267
|
+
const previousWindow = options.smootherTimeSpan;
|
|
268
|
+
options.smootherTimeSpan = getSmoothingWindowSeconds(options);
|
|
269
|
+
const windowChanged = previousWindow !== options.smootherTimeSpan;
|
|
270
|
+
if (hadObsolete || windowChanged || (options.configVersion || 0) < 4) {
|
|
271
|
+
options.configVersion = 4;
|
|
201
272
|
saveOptions();
|
|
202
|
-
app.debug('Config migrated to
|
|
273
|
+
app.debug('Config migrated to v3: standardized correction-table learning smoothing');
|
|
203
274
|
}
|
|
204
275
|
}
|
|
205
276
|
|
|
206
|
-
/**
|
|
207
|
-
* Derives { SmootherClass, smootherOptions } from the current options.
|
|
208
|
-
* Enforces minimums so the smoother always has at least two observations for
|
|
209
|
-
* variance to be meaningful:
|
|
210
|
-
* - MovingAverageSmoother: timeSpan >= 2 s (≥ 2 samples at typical 1 Hz)
|
|
211
|
-
* - ExponentialSmoother: tau >= 1 s
|
|
212
|
-
* - KalmanSmoother: steadyState in [0.01, 0.99]
|
|
213
|
-
*/
|
|
277
|
+
/** Returns the fixed smoother used for all learning inputs. */
|
|
214
278
|
function resolveSmootherConfig() {
|
|
215
|
-
const cls = options.smootherClass || 'MovingAverageSmoother';
|
|
216
|
-
if (cls === 'ExponentialSmoother') {
|
|
217
|
-
return {
|
|
218
|
-
SmootherClass: ExponentialSmoother,
|
|
219
|
-
smootherOptions: { timeConstant: Math.max(1, Number(options.smootherTau) || 3) }
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
if (cls === 'KalmanSmoother') {
|
|
223
|
-
const K = Math.min(0.99, Math.max(0.01, Number(options.smootherSteadyState) || 0.2));
|
|
224
|
-
return {
|
|
225
|
-
SmootherClass: KalmanSmoother,
|
|
226
|
-
smootherOptions: { steadyState: K }
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
// Default: MovingAverageSmoother
|
|
230
279
|
return {
|
|
231
280
|
SmootherClass: MovingAverageSmoother,
|
|
232
|
-
smootherOptions: { timeSpan:
|
|
281
|
+
smootherOptions: { timeSpan: getSmoothingWindowSeconds(options) }
|
|
233
282
|
};
|
|
234
283
|
}
|
|
235
284
|
|
|
@@ -256,6 +305,9 @@ module.exports = function (app) {
|
|
|
256
305
|
let smoothedResidual = null;
|
|
257
306
|
let reportFull = null;
|
|
258
307
|
let table = null;
|
|
308
|
+
let calculationIntervalSmoother = null;
|
|
309
|
+
let lastCalculationTime = null;
|
|
310
|
+
let lastLearningAttemptTime = null;
|
|
259
311
|
|
|
260
312
|
let rawHeading = null;
|
|
261
313
|
let rawAttitude = null;
|
|
@@ -299,9 +351,15 @@ module.exports = function (app) {
|
|
|
299
351
|
|
|
300
352
|
function getLearningStatePayload() {
|
|
301
353
|
const navigationState = buildNavigationStateStatus(navigationStateHandler, options.suspendLearningOnNavigationState);
|
|
354
|
+
const vesselMoving = isVesselMoving({
|
|
355
|
+
sogMagnitude: smoothedGroundSpeed?.magnitude,
|
|
356
|
+
speedThreshold: minSpeed,
|
|
357
|
+
navigationStateHandler,
|
|
358
|
+
gateEnabled: options.suspendLearningOnNavigationState
|
|
359
|
+
});
|
|
302
360
|
const learningMode = evaluateLearningMode({
|
|
303
361
|
options,
|
|
304
|
-
|
|
362
|
+
vesselMoving,
|
|
305
363
|
stabilizingUntil: learningStabilizingUntil,
|
|
306
364
|
stabilizingReason: learningStabilizingReason,
|
|
307
365
|
now: Date.now()
|
|
@@ -319,6 +377,7 @@ module.exports = function (app) {
|
|
|
319
377
|
observationReason: observation.reason,
|
|
320
378
|
minStwForLearning: minSpeed,
|
|
321
379
|
minSogForLearning: minSpeed,
|
|
380
|
+
vesselMoving,
|
|
322
381
|
navigationState
|
|
323
382
|
};
|
|
324
383
|
}
|
|
@@ -327,6 +386,8 @@ module.exports = function (app) {
|
|
|
327
386
|
const safePath = path || 'unknown path';
|
|
328
387
|
const message = status === 'idle'
|
|
329
388
|
? `Input ${id} is idle on ${safePath}; resubscribing`
|
|
389
|
+
: status === 'incomplete'
|
|
390
|
+
? `Input ${id} is missing ${safePath}`
|
|
330
391
|
: `Input ${id} is stale on ${safePath}`;
|
|
331
392
|
lifecycleWarningMap.set(id, {
|
|
332
393
|
id,
|
|
@@ -394,6 +455,9 @@ module.exports = function (app) {
|
|
|
394
455
|
const payload = reportFull.report();
|
|
395
456
|
payload.lifecycleWarnings = lifecycleWarnings;
|
|
396
457
|
payload.learningState = getLearningStatePayload();
|
|
458
|
+
payload.pollIntervalMs = calculationIntervalSmoother?.estimate
|
|
459
|
+
? calculationIntervalSmoother.estimate * 1000
|
|
460
|
+
: 1000;
|
|
397
461
|
res.json(payload);
|
|
398
462
|
}
|
|
399
463
|
});
|
|
@@ -414,7 +478,9 @@ module.exports = function (app) {
|
|
|
414
478
|
|
|
415
479
|
// --- Settings API ---
|
|
416
480
|
router.get('/api/settings', (req, res) => {
|
|
417
|
-
|
|
481
|
+
const active = { ...options, ...changedOptions };
|
|
482
|
+
for (const key of OBSOLETE_SMOOTHER_KEYS) delete active[key];
|
|
483
|
+
res.json(active);
|
|
418
484
|
});
|
|
419
485
|
|
|
420
486
|
router.put('/api/settings', (req, res) => {
|
|
@@ -429,8 +495,22 @@ module.exports = function (app) {
|
|
|
429
495
|
return res.status(400).json({ error: `Key '${k}' is managed via the table manager` });
|
|
430
496
|
}
|
|
431
497
|
}
|
|
432
|
-
|
|
433
|
-
|
|
498
|
+
const accepted = Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'stability' && !OBSOLETE_SMOOTHER_KEYS.has(key)));
|
|
499
|
+
if ('correctionDriftRate' in accepted) {
|
|
500
|
+
const driftRate = Number(accepted.correctionDriftRate);
|
|
501
|
+
if (Number.isFinite(driftRate)) {
|
|
502
|
+
accepted.correctionDriftRate = Math.min(MAX_CORRECTION_DRIFT_RATE, Math.max(0, driftRate));
|
|
503
|
+
} else {
|
|
504
|
+
delete accepted.correctionDriftRate;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if ('smootherTimeSpan' in accepted) {
|
|
508
|
+
accepted.smootherTimeSpan = getSmoothingWindowSeconds(accepted);
|
|
509
|
+
}
|
|
510
|
+
changedOptions = { ...changedOptions, ...accepted };
|
|
511
|
+
const active = { ...options, ...changedOptions };
|
|
512
|
+
for (const key of OBSOLETE_SMOOTHER_KEYS) delete active[key];
|
|
513
|
+
res.json(active);
|
|
434
514
|
});
|
|
435
515
|
|
|
436
516
|
// --- Correction Table Manager API ---
|
|
@@ -468,7 +548,7 @@ module.exports = function (app) {
|
|
|
468
548
|
}
|
|
469
549
|
const row = { min: 0, max: body.maxSpeed, step: body.speedStep };
|
|
470
550
|
const col = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
|
|
471
|
-
const newTable = new CorrectionTable(name, row, col, options.
|
|
551
|
+
const newTable = new CorrectionTable(name, row, col, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
472
552
|
newTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
|
|
473
553
|
saveTable(newTable, path.join(app.getDataDirPath(), name + '.json'));
|
|
474
554
|
if (isRunning) swapTable(newTable);
|
|
@@ -484,12 +564,25 @@ module.exports = function (app) {
|
|
|
484
564
|
return res.status(400).json({ error: 'Invalid table name' });
|
|
485
565
|
const filePath = path.join(app.getDataDirPath(), name + '.json');
|
|
486
566
|
const fileData = Table2D.readFromFile(filePath);
|
|
487
|
-
if (!fileData)
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
567
|
+
if (!fileData) {
|
|
568
|
+
if (!fs.existsSync(filePath)) return res.status(404).json({ error: `Table '${name}' not found` });
|
|
569
|
+
const message = `Correction table load rejected for ${name}: file could not be read`;
|
|
570
|
+
app.error(message);
|
|
571
|
+
app.debug(message);
|
|
572
|
+
return res.status(422).json({ error: message });
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
const loadedTable = CorrectionTable.fromJSON(fileData, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
576
|
+
loadedTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
|
|
577
|
+
if (isRunning) swapTable(loadedTable);
|
|
578
|
+
saveTableName(name);
|
|
579
|
+
res.json({ name });
|
|
580
|
+
} catch (err) {
|
|
581
|
+
const message = `Correction table load rejected for ${name}: ${err.message}`;
|
|
582
|
+
app.error(message);
|
|
583
|
+
app.debug(message);
|
|
584
|
+
res.status(422).json({ error: message });
|
|
585
|
+
}
|
|
493
586
|
});
|
|
494
587
|
|
|
495
588
|
// Copy active table under a new name and hot-swap to it
|
|
@@ -501,7 +594,7 @@ module.exports = function (app) {
|
|
|
501
594
|
return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
|
|
502
595
|
const data = table.toJSON();
|
|
503
596
|
data.id = newName;
|
|
504
|
-
const copiedTable = CorrectionTable.fromJSON(data, options.
|
|
597
|
+
const copiedTable = CorrectionTable.fromJSON(data, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
505
598
|
copiedTable.setDisplayAttributes({ label: newName }); // Table2D API unchanged
|
|
506
599
|
saveTable(copiedTable, path.join(app.getDataDirPath(), newName + '.json'));
|
|
507
600
|
swapTable(copiedTable);
|
|
@@ -520,7 +613,7 @@ module.exports = function (app) {
|
|
|
520
613
|
}
|
|
521
614
|
const newRow = { min: 0, max: body.maxSpeed, step: body.speedStep };
|
|
522
615
|
const newCol = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
|
|
523
|
-
const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, options.
|
|
616
|
+
const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate), 1e-4);
|
|
524
617
|
resized.setDisplayAttributes({ label: resized.id }); // Table2D API unchanged
|
|
525
618
|
saveTable(resized, path.join(app.getDataDirPath(), resized.id + '.json'));
|
|
526
619
|
swapTable(resized);
|
|
@@ -545,6 +638,9 @@ module.exports = function (app) {
|
|
|
545
638
|
const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
|
|
546
639
|
table = loadTable(options, tableFilePath);
|
|
547
640
|
minSpeed = table.step[0];
|
|
641
|
+
calculationIntervalSmoother = new ExponentialSmoother({ tau: 10 });
|
|
642
|
+
lastCalculationTime = null;
|
|
643
|
+
lastLearningAttemptTime = null;
|
|
548
644
|
|
|
549
645
|
//#region Handler and Polar Initialization
|
|
550
646
|
const { SmootherClass, smootherOptions } = resolveSmootherConfig();
|
|
@@ -581,6 +677,10 @@ module.exports = function (app) {
|
|
|
581
677
|
|
|
582
678
|
navigationStateHandler = new MessageHandler(app, plugin.id, 'navigationState');
|
|
583
679
|
navigationStateHandler.configure('navigation.state');
|
|
680
|
+
// navigation.state is an event path — it is only sent on transition, not on a regular
|
|
681
|
+
// period — so the generic telemetry staleness timer (which would otherwise mark it
|
|
682
|
+
// stale, and therefore not `ready`, within a few seconds of silence) does not apply.
|
|
683
|
+
navigationStateHandler.stalePeriod = 0;
|
|
584
684
|
navigationStateHandler.onDelta = () => {
|
|
585
685
|
handleNavigationStateDelta();
|
|
586
686
|
};
|
|
@@ -617,7 +717,6 @@ module.exports = function (app) {
|
|
|
617
717
|
});
|
|
618
718
|
noCurrent.xSmoother.reset(0,0);
|
|
619
719
|
noCurrent.ySmoother.reset(0,0);
|
|
620
|
-
PolarSmoother.send(app, plugin.id, [noCurrent]);
|
|
621
720
|
|
|
622
721
|
MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
|
|
623
722
|
units: 'rad',
|
|
@@ -632,23 +731,34 @@ module.exports = function (app) {
|
|
|
632
731
|
app, pluginId: plugin.id,
|
|
633
732
|
id: 'boatSpeed',
|
|
634
733
|
path: 'navigation.speedThroughWater',
|
|
635
|
-
subscribe:
|
|
734
|
+
subscribe: false,
|
|
636
735
|
SmootherClass,
|
|
637
736
|
smootherOptions,
|
|
638
737
|
onDelta: () => {
|
|
639
738
|
clearLifecycleWarning('boatSpeed.smoothed');
|
|
739
|
+
const now = Date.now();
|
|
740
|
+
if (lastCalculationTime !== null) {
|
|
741
|
+
const interval = (now - lastCalculationTime) / 1000;
|
|
742
|
+
if (interval > 0) calculationIntervalSmoother.add(interval);
|
|
743
|
+
}
|
|
744
|
+
lastCalculationTime = now;
|
|
640
745
|
// Drain any pending option changes before calculating
|
|
641
746
|
if (Object.keys(changedOptions).length) applyOptionChanges();
|
|
642
747
|
|
|
643
748
|
const learningMode = evaluateLearningMode({
|
|
644
749
|
options,
|
|
645
|
-
|
|
750
|
+
vesselMoving: isVesselMoving({
|
|
751
|
+
sogMagnitude: smoothedGroundSpeed?.magnitude,
|
|
752
|
+
speedThreshold: minSpeed,
|
|
753
|
+
navigationStateHandler,
|
|
754
|
+
gateEnabled: options.suspendLearningOnNavigationState
|
|
755
|
+
}),
|
|
646
756
|
stabilizingUntil: learningStabilizingUntil,
|
|
647
757
|
stabilizingReason: learningStabilizingReason,
|
|
648
758
|
now: Date.now()
|
|
649
759
|
});
|
|
650
760
|
|
|
651
|
-
const wellUnderway =
|
|
761
|
+
const wellUnderway = Date.now() >= learningStabilizingUntil;
|
|
652
762
|
setStatus(learningMode.state === 'stabilizing' ? 'Stabilizing' : 'Running');
|
|
653
763
|
if (options.estimateBoatSpeed) correct(wellUnderway);
|
|
654
764
|
updateTable();
|
|
@@ -750,11 +860,6 @@ module.exports = function (app) {
|
|
|
750
860
|
reportFull.addAttitude(smoothedAttitude);
|
|
751
861
|
reportFull.addDelta(smoothedBoatSpeed);
|
|
752
862
|
reportFull.addPolar(smoothedGroundSpeed);
|
|
753
|
-
// smoothedCurrent is already added by the estimateBoatSpeed block when that is on;
|
|
754
|
-
// only add it here when estimateBoatSpeed is off, to avoid a duplicate entry.
|
|
755
|
-
if (options.assumeCurrent && !options.estimateBoatSpeed) {
|
|
756
|
-
reportFull.addPolar(smoothedCurrent);
|
|
757
|
-
}
|
|
758
863
|
reportFull.addTable(table);
|
|
759
864
|
|
|
760
865
|
//#endregion
|
|
@@ -766,6 +871,7 @@ module.exports = function (app) {
|
|
|
766
871
|
lastNavigationStateValue = normalizeNavigationState(navigationStateHandler?.value);
|
|
767
872
|
resetLearningStabilization('startup', LONG_STABILIZING_MS);
|
|
768
873
|
setStatus('Running');
|
|
874
|
+
smoothedBoatSpeed.subscribe();
|
|
769
875
|
app.debug("Running");
|
|
770
876
|
|
|
771
877
|
}
|
|
@@ -841,10 +947,17 @@ module.exports = function (app) {
|
|
|
841
947
|
if (options.sogFallback && rawGroundSpeed.ready && rawBoatSpeed.ready && rawBoatSpeed.value === 0 && rawGroundSpeed.magnitude >= minSpeed) {
|
|
842
948
|
correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
|
|
843
949
|
}
|
|
844
|
-
else if (rawAttitude.ready) {
|
|
950
|
+
else if (rawAttitude.ready && Number.isFinite(rawAttitude.value?.roll)) {
|
|
951
|
+
clearLifecycleWarning('attitude.roll');
|
|
845
952
|
if (correctedBoatSpeed.magnitude > 0) {
|
|
846
953
|
const { correction, variance } = table.getCorrection(correctedBoatSpeed.magnitude, rawAttitude.value?.roll);
|
|
847
|
-
const
|
|
954
|
+
const vesselMoving = isVesselMoving({
|
|
955
|
+
sogMagnitude: rawGroundSpeed?.magnitude,
|
|
956
|
+
speedThreshold: minSpeed,
|
|
957
|
+
navigationStateHandler,
|
|
958
|
+
gateEnabled: options.suspendLearningOnNavigationState
|
|
959
|
+
});
|
|
960
|
+
const leewayValid = isLeewayValid(correctedBoatSpeed.magnitude, minSpeed, vesselMoving);
|
|
848
961
|
speedCorrection.setVectorValue(
|
|
849
962
|
{ x: correction.x, y: leewayValid ? correction.y : 0 },
|
|
850
963
|
{ x: variance.x, y: leewayValid ? variance.y : 0 }
|
|
@@ -877,23 +990,33 @@ module.exports = function (app) {
|
|
|
877
990
|
}
|
|
878
991
|
}
|
|
879
992
|
}
|
|
880
|
-
// Implicit fallback: attitude not ready — correctedBoatSpeed = raw STW, no correction
|
|
993
|
+
// Implicit fallback: attitude not ready or missing roll — correctedBoatSpeed = raw STW, no correction
|
|
994
|
+
else if (rawAttitude.ready) {
|
|
995
|
+
setLifecycleWarning('attitude.roll', 'incomplete', 'navigation.attitude.roll');
|
|
996
|
+
}
|
|
881
997
|
|
|
882
|
-
PolarSmoother.send(app, plugin.id, [smoothedCurrent
|
|
998
|
+
PolarSmoother.send(app, plugin.id, [smoothedCurrent]);
|
|
883
999
|
|
|
884
1000
|
Polar.send(app, plugin.id, [correctedBoatSpeed]);
|
|
885
1001
|
}
|
|
886
1002
|
|
|
887
1003
|
/**
|
|
888
1004
|
* Updates the correction table from the current smoothed inputs.
|
|
889
|
-
|
|
1005
|
+
* Reads minSpeed from module-level state;
|
|
890
1006
|
* silently returns if any required input is not yet ready.
|
|
891
1007
|
*/
|
|
892
1008
|
function updateTable() {
|
|
1009
|
+
const now = Date.now();
|
|
1010
|
+
if (lastLearningAttemptTime !== null && now - lastLearningAttemptTime < getLearningIntervalMs(options)) return;
|
|
893
1011
|
lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
|
|
894
1012
|
const learningMode = evaluateLearningMode({
|
|
895
1013
|
options,
|
|
896
|
-
|
|
1014
|
+
vesselMoving: isVesselMoving({
|
|
1015
|
+
sogMagnitude: smoothedGroundSpeed.magnitude,
|
|
1016
|
+
speedThreshold: minSpeed,
|
|
1017
|
+
navigationStateHandler,
|
|
1018
|
+
gateEnabled: options.suspendLearningOnNavigationState
|
|
1019
|
+
}),
|
|
897
1020
|
stabilizingUntil: learningStabilizingUntil,
|
|
898
1021
|
stabilizingReason: learningStabilizingReason,
|
|
899
1022
|
now: Date.now()
|
|
@@ -902,13 +1025,11 @@ module.exports = function (app) {
|
|
|
902
1025
|
learningMode.state = 'suspended';
|
|
903
1026
|
learningMode.reason = 'cog_override';
|
|
904
1027
|
}
|
|
905
|
-
const inputsReady = smoothedAttitude.ready &&
|
|
906
|
-
|
|
1028
|
+
const inputsReady = smoothedAttitude.ready && Number.isFinite(smoothedAttitude.value?.roll)
|
|
1029
|
+
&& smoothedBoatSpeed.ready && smoothedHeading.ready && smoothedGroundSpeed.ready;
|
|
907
1030
|
const observationGate = evaluateObservationGate({
|
|
908
1031
|
learningMode,
|
|
909
1032
|
inputsReady,
|
|
910
|
-
assumeCurrent: options.assumeCurrent,
|
|
911
|
-
currentReady,
|
|
912
1033
|
stw: smoothedBoatSpeed.value,
|
|
913
1034
|
sog: smoothedGroundSpeed.magnitude,
|
|
914
1035
|
speedThreshold: minSpeed
|
|
@@ -916,12 +1037,22 @@ module.exports = function (app) {
|
|
|
916
1037
|
|
|
917
1038
|
if (observationGate.state !== 'pending') {
|
|
918
1039
|
if (observationGate.state === 'invalid') {
|
|
1040
|
+
setObservationStatus('rejected', observationGate.reason);
|
|
919
1041
|
resetLearningStabilization('observation_reset', getShortStabilizingMs(options));
|
|
920
1042
|
}
|
|
921
1043
|
return;
|
|
922
1044
|
}
|
|
923
1045
|
|
|
924
|
-
|
|
1046
|
+
lastLearningAttemptTime = now;
|
|
1047
|
+
table.update(
|
|
1048
|
+
smoothedBoatSpeed.value,
|
|
1049
|
+
smoothedAttitude.value?.roll,
|
|
1050
|
+
smoothedGroundSpeed,
|
|
1051
|
+
noCurrent,
|
|
1052
|
+
lrnBoatSpeed,
|
|
1053
|
+
smoothedHeading.value,
|
|
1054
|
+
smoothedHeading.variance
|
|
1055
|
+
);
|
|
925
1056
|
if (table.lastUpdateResult === 'accepted') {
|
|
926
1057
|
setObservationStatus('accepted', 'accepted');
|
|
927
1058
|
} else if (table.lastUpdateResult === 'rejected') {
|
|
@@ -935,39 +1066,55 @@ module.exports = function (app) {
|
|
|
935
1066
|
* If the file exists it is deserialized; otherwise a new table is created
|
|
936
1067
|
* with default dimensions and saved to disk.
|
|
937
1068
|
*
|
|
938
|
-
|
|
1069
|
+
* @param {Object} options - Plugin options (correctionDriftRate, tableName, and dimension defaults).
|
|
939
1070
|
* @param {string} filePath - Absolute path of the JSON file to read.
|
|
940
1071
|
* @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
|
|
941
1072
|
*/
|
|
942
1073
|
function loadTable(options, filePath) {
|
|
943
|
-
const
|
|
1074
|
+
const processNoiseRate = correctionDriftRateToProcessNoiseRate(options.correctionDriftRate);
|
|
944
1075
|
let fileData = Table2D.readFromFile(filePath);
|
|
945
|
-
let table;
|
|
946
1076
|
if (fileData) {
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
app.debug("Correction table loaded
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
|
|
960
|
-
const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
|
|
961
|
-
table = new CorrectionTable(name, row, col, stability);
|
|
1077
|
+
try {
|
|
1078
|
+
const migration = migrateCorrectionTableData(fileData, {
|
|
1079
|
+
filePath,
|
|
1080
|
+
debug: message => app.debug(message)
|
|
1081
|
+
});
|
|
1082
|
+
const table = CorrectionTable.fromJSON(migration.data, processNoiseRate);
|
|
1083
|
+
table.setDisplayAttributes({ label: table.id }); // Table2D API unchanged
|
|
1084
|
+
if (migration.migrated) saveMigratedTable(table, filePath);
|
|
1085
|
+
app.debug("Correction table loaded: " + (fileData.id || filePath));
|
|
1086
|
+
return table;
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
return recoverTable(`could not deserialize ${filePath}: ${err.message}`);
|
|
962
1089
|
}
|
|
963
|
-
} else {
|
|
964
|
-
const name = options.tableName || 'correctionTable';
|
|
965
|
-
const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
|
|
966
|
-
const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
|
|
967
|
-
table = new CorrectionTable(name, row, col, stability);
|
|
968
|
-
app.debug("Correction table created: " + name);
|
|
969
1090
|
}
|
|
970
|
-
|
|
1091
|
+
|
|
1092
|
+
if (fs.existsSync(filePath)) {
|
|
1093
|
+
return recoverTable(`could not read ${filePath}`);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const table = createDefaultTable(options.tableName || 'correctionTable', processNoiseRate);
|
|
1097
|
+
app.debug("Correction table created: " + table.id);
|
|
1098
|
+
return table;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function createDefaultTable(name, processNoiseRate = DEFAULT_PROCESS_NOISE_RATE) {
|
|
1102
|
+
const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
|
|
1103
|
+
const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
|
|
1104
|
+
const table = new CorrectionTable(name, row, col, processNoiseRate);
|
|
1105
|
+
table.setDisplayAttributes({ label: name }); // Table2D API unchanged
|
|
1106
|
+
return table;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function recoverTable(reason) {
|
|
1110
|
+
const recoveryName = `correctionTable-${new Date().toISOString().slice(0, 10)}`;
|
|
1111
|
+
const recoveryPath = path.join(app.getDataDirPath(), recoveryName + '.json');
|
|
1112
|
+
const table = createDefaultTable(recoveryName, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
1113
|
+
const message = `Correction table recovery: ${reason}; loaded empty table ${recoveryName}`;
|
|
1114
|
+
app.error(message);
|
|
1115
|
+
app.debug(message);
|
|
1116
|
+
saveTableSync(table, recoveryPath);
|
|
1117
|
+
saveTableName(recoveryName);
|
|
971
1118
|
return table;
|
|
972
1119
|
}
|
|
973
1120
|
|
|
@@ -995,6 +1142,18 @@ module.exports = function (app) {
|
|
|
995
1142
|
}
|
|
996
1143
|
}
|
|
997
1144
|
|
|
1145
|
+
function saveMigratedTable(correctionTable, filePath) {
|
|
1146
|
+
const temporaryPath = `${filePath}.migration-${process.pid}-${Date.now()}.tmp`;
|
|
1147
|
+
try {
|
|
1148
|
+
fs.writeFileSync(temporaryPath, JSON.stringify(correctionTable.toJSON(), null, 2));
|
|
1149
|
+
fs.renameSync(temporaryPath, filePath);
|
|
1150
|
+
app.debug(`Correction table migrated to schema v2: ${filePath}`);
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
try { fs.unlinkSync(temporaryPath); } catch {}
|
|
1153
|
+
app.debug(`Correction table migration could not be persisted for ${filePath}: ${err.message}`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
998
1157
|
/**
|
|
999
1158
|
* Returns true if every cell in the table has N === 0 (no learned data).
|
|
1000
1159
|
* Used to guard against overwriting a good on-disk file with an empty table.
|
|
@@ -1024,7 +1183,7 @@ module.exports = function (app) {
|
|
|
1024
1183
|
const filePath = path.join(app.getDataDirPath(), table.id + '.json');
|
|
1025
1184
|
const fileData = Table2D.readFromFile(filePath);
|
|
1026
1185
|
if (fileData) {
|
|
1027
|
-
const reloaded = CorrectionTable.fromJSON(fileData, options.
|
|
1186
|
+
const reloaded = CorrectionTable.fromJSON(fileData, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
1028
1187
|
reloaded.setDisplayAttributes({ label: reloaded.id });
|
|
1029
1188
|
if (!isTableEmpty(reloaded)) {
|
|
1030
1189
|
swapTable(reloaded);
|
|
@@ -1033,8 +1192,8 @@ module.exports = function (app) {
|
|
|
1033
1192
|
}
|
|
1034
1193
|
}
|
|
1035
1194
|
}
|
|
1036
|
-
// All other keys (sogFallback, estimateBoatSpeed,
|
|
1037
|
-
//
|
|
1195
|
+
// All other keys (sogFallback, estimateBoatSpeed, correctionDriftRate,
|
|
1196
|
+
// and smootherTimeSpan) are read
|
|
1038
1197
|
// directly from options.* so no extra action needed.
|
|
1039
1198
|
if (key === 'estimateBoatSpeed' && !value && correctedBoatSpeed) {
|
|
1040
1199
|
Polar.clear(app, plugin.id, [correctedBoatSpeed]);
|
|
@@ -1044,10 +1203,13 @@ module.exports = function (app) {
|
|
|
1044
1203
|
delete changedOptions[key];
|
|
1045
1204
|
}
|
|
1046
1205
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1206
|
+
if (changedKeys.includes('correctionDriftRate') && table) {
|
|
1207
|
+
table.setProcessNoiseRate(correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Hot-apply the learning window to all user-tuned smoothers.
|
|
1211
|
+
if (changedKeys.includes('smootherTimeSpan')) {
|
|
1212
|
+
options.smootherTimeSpan = getSmoothingWindowSeconds(options);
|
|
1051
1213
|
const { SmootherClass: SC, smootherOptions: so } = resolveSmootherConfig();
|
|
1052
1214
|
for (const s of [smoothedHeading, smoothedBoatSpeed, smoothedGroundSpeed]) {
|
|
1053
1215
|
if (s) { s.setSmootherClass(SC); s.setSmootherOptions(so); }
|
|
@@ -1063,13 +1225,18 @@ module.exports = function (app) {
|
|
|
1063
1225
|
module.exports._test = {
|
|
1064
1226
|
normalizeNavigationState,
|
|
1065
1227
|
getShortStabilizingMs,
|
|
1228
|
+
getSmoothingWindowSeconds,
|
|
1229
|
+
getLearningIntervalMs,
|
|
1230
|
+
correctionDriftRateToProcessNoiseRate,
|
|
1231
|
+
legacyStabilityToCorrectionDriftRate,
|
|
1232
|
+
migrateCorrectionTableData,
|
|
1233
|
+
isVesselMoving,
|
|
1066
1234
|
evaluateLearningMode,
|
|
1067
1235
|
isCogOverrideActive,
|
|
1068
1236
|
isLeewayValid,
|
|
1069
1237
|
evaluateObservationGate,
|
|
1070
1238
|
getDerivedObservationStatus,
|
|
1071
1239
|
buildNavigationStateStatus,
|
|
1072
|
-
|
|
1073
|
-
OPTIONAL_BLOCKING_NAVIGATION_STATES,
|
|
1240
|
+
NOT_MOVING_NAVIGATION_STATES,
|
|
1074
1241
|
LONG_STABILIZING_MS,
|
|
1075
1242
|
};
|