speedandcurrent 2.3.4 → 2.5.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/CHANGELOG.md +204 -0
- package/README.md +83 -46
- 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 +295 -147
- package/package.json +4 -3
- package/public/TableRenderer.js +89 -14
- package/public/app.js +126 -78
- 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
|
}
|
|
@@ -326,7 +385,7 @@ module.exports = function (app) {
|
|
|
326
385
|
function setLifecycleWarning(id, status, path) {
|
|
327
386
|
const safePath = path || 'unknown path';
|
|
328
387
|
const message = status === 'idle'
|
|
329
|
-
? `Input ${id} is idle on ${safePath}
|
|
388
|
+
? `Input ${id} is idle on ${safePath}`
|
|
330
389
|
: status === 'incomplete'
|
|
331
390
|
? `Input ${id} is missing ${safePath}`
|
|
332
391
|
: `Input ${id} is stale on ${safePath}`;
|
|
@@ -348,7 +407,7 @@ module.exports = function (app) {
|
|
|
348
407
|
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
349
408
|
}
|
|
350
409
|
|
|
351
|
-
function buildLifecycleCallbacks(id, getPath
|
|
410
|
+
function buildLifecycleCallbacks(id, getPath) {
|
|
352
411
|
return {
|
|
353
412
|
onDelta: () => {
|
|
354
413
|
clearLifecycleWarning(id);
|
|
@@ -362,13 +421,8 @@ module.exports = function (app) {
|
|
|
362
421
|
onIdle: () => {
|
|
363
422
|
if (!isRunning) return;
|
|
364
423
|
const path = getPath();
|
|
365
|
-
app.debug(`[${plugin.id}] idle input ${id} on ${path}
|
|
424
|
+
app.debug(`[${plugin.id}] idle input ${id} on ${path}`);
|
|
366
425
|
setLifecycleWarning(id, 'idle', path);
|
|
367
|
-
try {
|
|
368
|
-
resubscribe();
|
|
369
|
-
} catch (e) {
|
|
370
|
-
app.debug(`[${plugin.id}] resubscribe failed for ${id}: ${e.message}`);
|
|
371
|
-
}
|
|
372
426
|
}
|
|
373
427
|
};
|
|
374
428
|
}
|
|
@@ -389,18 +443,21 @@ module.exports = function (app) {
|
|
|
389
443
|
app.debug('registerWithRouter');
|
|
390
444
|
readOptions(); // pre-load so /api/settings works before start()
|
|
391
445
|
|
|
392
|
-
router.get('/api/report', (req, res) => {
|
|
446
|
+
router.access('readonly').get('/api/report', (req, res) => {
|
|
393
447
|
if (!isRunning) {
|
|
394
448
|
res.status(503).json({ error: "Plugin is not running" });
|
|
395
449
|
} else {
|
|
396
450
|
const payload = reportFull.report();
|
|
397
451
|
payload.lifecycleWarnings = lifecycleWarnings;
|
|
398
452
|
payload.learningState = getLearningStatePayload();
|
|
453
|
+
payload.pollIntervalMs = calculationIntervalSmoother?.estimate
|
|
454
|
+
? calculationIntervalSmoother.estimate * 1000
|
|
455
|
+
: 1000;
|
|
399
456
|
res.json(payload);
|
|
400
457
|
}
|
|
401
458
|
});
|
|
402
459
|
|
|
403
|
-
router.get('/api/meta', (req, res) => {
|
|
460
|
+
router.access('readonly').get('/api/meta', (req, res) => {
|
|
404
461
|
if (!isRunning) {
|
|
405
462
|
res.status(503).json({ error: "Plugin is not running" });
|
|
406
463
|
} else {
|
|
@@ -410,13 +467,15 @@ module.exports = function (app) {
|
|
|
410
467
|
|
|
411
468
|
|
|
412
469
|
|
|
413
|
-
router.get('/api/status', (req, res) => {
|
|
470
|
+
router.access('readonly').get('/api/status', (req, res) => {
|
|
414
471
|
res.json({ status: pluginStatus, isRunning, lifecycleWarnings, learningState: getLearningStatePayload() });
|
|
415
472
|
});
|
|
416
473
|
|
|
417
474
|
// --- Settings API ---
|
|
418
475
|
router.get('/api/settings', (req, res) => {
|
|
419
|
-
|
|
476
|
+
const active = { ...options, ...changedOptions };
|
|
477
|
+
for (const key of OBSOLETE_SMOOTHER_KEYS) delete active[key];
|
|
478
|
+
res.json(active);
|
|
420
479
|
});
|
|
421
480
|
|
|
422
481
|
router.put('/api/settings', (req, res) => {
|
|
@@ -431,8 +490,22 @@ module.exports = function (app) {
|
|
|
431
490
|
return res.status(400).json({ error: `Key '${k}' is managed via the table manager` });
|
|
432
491
|
}
|
|
433
492
|
}
|
|
434
|
-
|
|
435
|
-
|
|
493
|
+
const accepted = Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'stability' && !OBSOLETE_SMOOTHER_KEYS.has(key)));
|
|
494
|
+
if ('correctionDriftRate' in accepted) {
|
|
495
|
+
const driftRate = Number(accepted.correctionDriftRate);
|
|
496
|
+
if (Number.isFinite(driftRate)) {
|
|
497
|
+
accepted.correctionDriftRate = Math.min(MAX_CORRECTION_DRIFT_RATE, Math.max(0, driftRate));
|
|
498
|
+
} else {
|
|
499
|
+
delete accepted.correctionDriftRate;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if ('smootherTimeSpan' in accepted) {
|
|
503
|
+
accepted.smootherTimeSpan = getSmoothingWindowSeconds(accepted);
|
|
504
|
+
}
|
|
505
|
+
changedOptions = { ...changedOptions, ...accepted };
|
|
506
|
+
const active = { ...options, ...changedOptions };
|
|
507
|
+
for (const key of OBSOLETE_SMOOTHER_KEYS) delete active[key];
|
|
508
|
+
res.json(active);
|
|
436
509
|
});
|
|
437
510
|
|
|
438
511
|
// --- Correction Table Manager API ---
|
|
@@ -470,7 +543,7 @@ module.exports = function (app) {
|
|
|
470
543
|
}
|
|
471
544
|
const row = { min: 0, max: body.maxSpeed, step: body.speedStep };
|
|
472
545
|
const col = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
|
|
473
|
-
const newTable = new CorrectionTable(name, row, col, options.
|
|
546
|
+
const newTable = new CorrectionTable(name, row, col, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
474
547
|
newTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
|
|
475
548
|
saveTable(newTable, path.join(app.getDataDirPath(), name + '.json'));
|
|
476
549
|
if (isRunning) swapTable(newTable);
|
|
@@ -486,12 +559,25 @@ module.exports = function (app) {
|
|
|
486
559
|
return res.status(400).json({ error: 'Invalid table name' });
|
|
487
560
|
const filePath = path.join(app.getDataDirPath(), name + '.json');
|
|
488
561
|
const fileData = Table2D.readFromFile(filePath);
|
|
489
|
-
if (!fileData)
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
562
|
+
if (!fileData) {
|
|
563
|
+
if (!fs.existsSync(filePath)) return res.status(404).json({ error: `Table '${name}' not found` });
|
|
564
|
+
const message = `Correction table load rejected for ${name}: file could not be read`;
|
|
565
|
+
app.error(message);
|
|
566
|
+
app.debug(message);
|
|
567
|
+
return res.status(422).json({ error: message });
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
const loadedTable = CorrectionTable.fromJSON(fileData, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
571
|
+
loadedTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
|
|
572
|
+
if (isRunning) swapTable(loadedTable);
|
|
573
|
+
saveTableName(name);
|
|
574
|
+
res.json({ name });
|
|
575
|
+
} catch (err) {
|
|
576
|
+
const message = `Correction table load rejected for ${name}: ${err.message}`;
|
|
577
|
+
app.error(message);
|
|
578
|
+
app.debug(message);
|
|
579
|
+
res.status(422).json({ error: message });
|
|
580
|
+
}
|
|
495
581
|
});
|
|
496
582
|
|
|
497
583
|
// Copy active table under a new name and hot-swap to it
|
|
@@ -503,7 +589,7 @@ module.exports = function (app) {
|
|
|
503
589
|
return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
|
|
504
590
|
const data = table.toJSON();
|
|
505
591
|
data.id = newName;
|
|
506
|
-
const copiedTable = CorrectionTable.fromJSON(data, options.
|
|
592
|
+
const copiedTable = CorrectionTable.fromJSON(data, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
507
593
|
copiedTable.setDisplayAttributes({ label: newName }); // Table2D API unchanged
|
|
508
594
|
saveTable(copiedTable, path.join(app.getDataDirPath(), newName + '.json'));
|
|
509
595
|
swapTable(copiedTable);
|
|
@@ -522,7 +608,7 @@ module.exports = function (app) {
|
|
|
522
608
|
}
|
|
523
609
|
const newRow = { min: 0, max: body.maxSpeed, step: body.speedStep };
|
|
524
610
|
const newCol = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
|
|
525
|
-
const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, options.
|
|
611
|
+
const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate), 1e-4);
|
|
526
612
|
resized.setDisplayAttributes({ label: resized.id }); // Table2D API unchanged
|
|
527
613
|
saveTable(resized, path.join(app.getDataDirPath(), resized.id + '.json'));
|
|
528
614
|
swapTable(resized);
|
|
@@ -547,6 +633,9 @@ module.exports = function (app) {
|
|
|
547
633
|
const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
|
|
548
634
|
table = loadTable(options, tableFilePath);
|
|
549
635
|
minSpeed = table.step[0];
|
|
636
|
+
calculationIntervalSmoother = new ExponentialSmoother({ tau: 10 });
|
|
637
|
+
lastCalculationTime = null;
|
|
638
|
+
lastLearningAttemptTime = null;
|
|
550
639
|
|
|
551
640
|
//#region Handler and Polar Initialization
|
|
552
641
|
const { SmootherClass, smootherOptions } = resolveSmootherConfig();
|
|
@@ -559,8 +648,7 @@ module.exports = function (app) {
|
|
|
559
648
|
smootherOptions,
|
|
560
649
|
...buildLifecycleCallbacks(
|
|
561
650
|
'heading.angle',
|
|
562
|
-
() => smoothedHeading?.handler?.path || 'navigation.headingTrue'
|
|
563
|
-
() => { smoothedHeading?.unsubscribe(); smoothedHeading?.subscribe(false, true); }
|
|
651
|
+
() => smoothedHeading?.handler?.path || 'navigation.headingTrue'
|
|
564
652
|
)
|
|
565
653
|
});
|
|
566
654
|
rawHeading = smoothedHeading.handler;
|
|
@@ -575,14 +663,17 @@ module.exports = function (app) {
|
|
|
575
663
|
smootherOptions,
|
|
576
664
|
...buildLifecycleCallbacks(
|
|
577
665
|
'attitude.smoothed',
|
|
578
|
-
() => smoothedAttitude?.handler?.path || 'navigation.attitude'
|
|
579
|
-
() => { smoothedAttitude?.unsubscribe(); smoothedAttitude?.subscribe(); }
|
|
666
|
+
() => smoothedAttitude?.handler?.path || 'navigation.attitude'
|
|
580
667
|
)
|
|
581
668
|
});
|
|
582
669
|
rawAttitude = smoothedAttitude.handler;
|
|
583
670
|
|
|
584
671
|
navigationStateHandler = new MessageHandler(app, plugin.id, 'navigationState');
|
|
585
672
|
navigationStateHandler.configure('navigation.state');
|
|
673
|
+
// navigation.state is an event path — it is only sent on transition, not on a regular
|
|
674
|
+
// period — so the generic telemetry staleness timer (which would otherwise mark it
|
|
675
|
+
// stale, and therefore not `ready`, within a few seconds of silence) does not apply.
|
|
676
|
+
navigationStateHandler.stalePeriod = 0;
|
|
586
677
|
navigationStateHandler.onDelta = () => {
|
|
587
678
|
handleNavigationStateDelta();
|
|
588
679
|
};
|
|
@@ -619,7 +710,6 @@ module.exports = function (app) {
|
|
|
619
710
|
});
|
|
620
711
|
noCurrent.xSmoother.reset(0,0);
|
|
621
712
|
noCurrent.ySmoother.reset(0,0);
|
|
622
|
-
PolarSmoother.send(app, plugin.id, [noCurrent]);
|
|
623
713
|
|
|
624
714
|
MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
|
|
625
715
|
units: 'rad',
|
|
@@ -639,12 +729,23 @@ module.exports = function (app) {
|
|
|
639
729
|
smootherOptions,
|
|
640
730
|
onDelta: () => {
|
|
641
731
|
clearLifecycleWarning('boatSpeed.smoothed');
|
|
732
|
+
const now = Date.now();
|
|
733
|
+
if (lastCalculationTime !== null) {
|
|
734
|
+
const interval = (now - lastCalculationTime) / 1000;
|
|
735
|
+
if (interval > 0) calculationIntervalSmoother.add(interval);
|
|
736
|
+
}
|
|
737
|
+
lastCalculationTime = now;
|
|
642
738
|
// Drain any pending option changes before calculating
|
|
643
739
|
if (Object.keys(changedOptions).length) applyOptionChanges();
|
|
644
740
|
|
|
645
741
|
const learningMode = evaluateLearningMode({
|
|
646
742
|
options,
|
|
647
|
-
|
|
743
|
+
vesselMoving: isVesselMoving({
|
|
744
|
+
sogMagnitude: smoothedGroundSpeed?.magnitude,
|
|
745
|
+
speedThreshold: minSpeed,
|
|
746
|
+
navigationStateHandler,
|
|
747
|
+
gateEnabled: options.suspendLearningOnNavigationState
|
|
748
|
+
}),
|
|
648
749
|
stabilizingUntil: learningStabilizingUntil,
|
|
649
750
|
stabilizingReason: learningStabilizingReason,
|
|
650
751
|
now: Date.now()
|
|
@@ -668,10 +769,8 @@ module.exports = function (app) {
|
|
|
668
769
|
onIdle: () => {
|
|
669
770
|
if (!isRunning) return;
|
|
670
771
|
const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
|
|
671
|
-
app.debug(`[${plugin.id}] idle input boatSpeed.smoothed on ${path}
|
|
772
|
+
app.debug(`[${plugin.id}] idle input boatSpeed.smoothed on ${path}`);
|
|
672
773
|
setLifecycleWarning('boatSpeed.smoothed', 'idle', path);
|
|
673
|
-
smoothedBoatSpeed?.unsubscribe();
|
|
674
|
-
smoothedBoatSpeed?.subscribe();
|
|
675
774
|
},
|
|
676
775
|
onStale: () => {
|
|
677
776
|
if (!isRunning) return;
|
|
@@ -712,8 +811,7 @@ module.exports = function (app) {
|
|
|
712
811
|
smootherOptions,
|
|
713
812
|
...buildLifecycleCallbacks(
|
|
714
813
|
'groundSpeed.smoothed',
|
|
715
|
-
() => `${smoothedGroundSpeed?.polar?.pathMagnitude || 'navigation.speedOverGround'}, ${smoothedGroundSpeed?.polar?.pathAngle || 'navigation.courseOverGroundTrue'}
|
|
716
|
-
() => { smoothedGroundSpeed?.unsubscribe(); smoothedGroundSpeed?.subscribe(true, true); }
|
|
814
|
+
() => `${smoothedGroundSpeed?.polar?.pathMagnitude || 'navigation.speedOverGround'}, ${smoothedGroundSpeed?.polar?.pathAngle || 'navigation.courseOverGroundTrue'}`
|
|
717
815
|
)
|
|
718
816
|
});
|
|
719
817
|
rawGroundSpeed = smoothedGroundSpeed.polar;
|
|
@@ -752,11 +850,6 @@ module.exports = function (app) {
|
|
|
752
850
|
reportFull.addAttitude(smoothedAttitude);
|
|
753
851
|
reportFull.addDelta(smoothedBoatSpeed);
|
|
754
852
|
reportFull.addPolar(smoothedGroundSpeed);
|
|
755
|
-
// smoothedCurrent is already added by the estimateBoatSpeed block when that is on;
|
|
756
|
-
// only add it here when estimateBoatSpeed is off, to avoid a duplicate entry.
|
|
757
|
-
if (options.assumeCurrent && !options.estimateBoatSpeed) {
|
|
758
|
-
reportFull.addPolar(smoothedCurrent);
|
|
759
|
-
}
|
|
760
853
|
reportFull.addTable(table);
|
|
761
854
|
|
|
762
855
|
//#endregion
|
|
@@ -848,7 +941,13 @@ module.exports = function (app) {
|
|
|
848
941
|
clearLifecycleWarning('attitude.roll');
|
|
849
942
|
if (correctedBoatSpeed.magnitude > 0) {
|
|
850
943
|
const { correction, variance } = table.getCorrection(correctedBoatSpeed.magnitude, rawAttitude.value?.roll);
|
|
851
|
-
const
|
|
944
|
+
const vesselMoving = isVesselMoving({
|
|
945
|
+
sogMagnitude: rawGroundSpeed?.magnitude,
|
|
946
|
+
speedThreshold: minSpeed,
|
|
947
|
+
navigationStateHandler,
|
|
948
|
+
gateEnabled: options.suspendLearningOnNavigationState
|
|
949
|
+
});
|
|
950
|
+
const leewayValid = isLeewayValid(correctedBoatSpeed.magnitude, minSpeed, vesselMoving);
|
|
852
951
|
speedCorrection.setVectorValue(
|
|
853
952
|
{ x: correction.x, y: leewayValid ? correction.y : 0 },
|
|
854
953
|
{ x: variance.x, y: leewayValid ? variance.y : 0 }
|
|
@@ -886,21 +985,28 @@ module.exports = function (app) {
|
|
|
886
985
|
setLifecycleWarning('attitude.roll', 'incomplete', 'navigation.attitude.roll');
|
|
887
986
|
}
|
|
888
987
|
|
|
889
|
-
PolarSmoother.send(app, plugin.id, [smoothedCurrent
|
|
988
|
+
PolarSmoother.send(app, plugin.id, [smoothedCurrent]);
|
|
890
989
|
|
|
891
990
|
Polar.send(app, plugin.id, [correctedBoatSpeed]);
|
|
892
991
|
}
|
|
893
992
|
|
|
894
993
|
/**
|
|
895
994
|
* Updates the correction table from the current smoothed inputs.
|
|
896
|
-
|
|
995
|
+
* Reads minSpeed from module-level state;
|
|
897
996
|
* silently returns if any required input is not yet ready.
|
|
898
997
|
*/
|
|
899
998
|
function updateTable() {
|
|
999
|
+
const now = Date.now();
|
|
1000
|
+
if (lastLearningAttemptTime !== null && now - lastLearningAttemptTime < getLearningIntervalMs(options)) return;
|
|
900
1001
|
lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
|
|
901
1002
|
const learningMode = evaluateLearningMode({
|
|
902
1003
|
options,
|
|
903
|
-
|
|
1004
|
+
vesselMoving: isVesselMoving({
|
|
1005
|
+
sogMagnitude: smoothedGroundSpeed.magnitude,
|
|
1006
|
+
speedThreshold: minSpeed,
|
|
1007
|
+
navigationStateHandler,
|
|
1008
|
+
gateEnabled: options.suspendLearningOnNavigationState
|
|
1009
|
+
}),
|
|
904
1010
|
stabilizingUntil: learningStabilizingUntil,
|
|
905
1011
|
stabilizingReason: learningStabilizingReason,
|
|
906
1012
|
now: Date.now()
|
|
@@ -911,12 +1017,9 @@ module.exports = function (app) {
|
|
|
911
1017
|
}
|
|
912
1018
|
const inputsReady = smoothedAttitude.ready && Number.isFinite(smoothedAttitude.value?.roll)
|
|
913
1019
|
&& smoothedBoatSpeed.ready && smoothedHeading.ready && smoothedGroundSpeed.ready;
|
|
914
|
-
const currentReady = !options.assumeCurrent || smoothedCurrent.ready;
|
|
915
1020
|
const observationGate = evaluateObservationGate({
|
|
916
1021
|
learningMode,
|
|
917
1022
|
inputsReady,
|
|
918
|
-
assumeCurrent: options.assumeCurrent,
|
|
919
|
-
currentReady,
|
|
920
1023
|
stw: smoothedBoatSpeed.value,
|
|
921
1024
|
sog: smoothedGroundSpeed.magnitude,
|
|
922
1025
|
speedThreshold: minSpeed
|
|
@@ -930,7 +1033,16 @@ module.exports = function (app) {
|
|
|
930
1033
|
return;
|
|
931
1034
|
}
|
|
932
1035
|
|
|
933
|
-
|
|
1036
|
+
lastLearningAttemptTime = now;
|
|
1037
|
+
table.update(
|
|
1038
|
+
smoothedBoatSpeed.value,
|
|
1039
|
+
smoothedAttitude.value?.roll,
|
|
1040
|
+
smoothedGroundSpeed,
|
|
1041
|
+
noCurrent,
|
|
1042
|
+
lrnBoatSpeed,
|
|
1043
|
+
smoothedHeading.value,
|
|
1044
|
+
smoothedHeading.variance
|
|
1045
|
+
);
|
|
934
1046
|
if (table.lastUpdateResult === 'accepted') {
|
|
935
1047
|
setObservationStatus('accepted', 'accepted');
|
|
936
1048
|
} else if (table.lastUpdateResult === 'rejected') {
|
|
@@ -944,39 +1056,55 @@ module.exports = function (app) {
|
|
|
944
1056
|
* If the file exists it is deserialized; otherwise a new table is created
|
|
945
1057
|
* with default dimensions and saved to disk.
|
|
946
1058
|
*
|
|
947
|
-
|
|
1059
|
+
* @param {Object} options - Plugin options (correctionDriftRate, tableName, and dimension defaults).
|
|
948
1060
|
* @param {string} filePath - Absolute path of the JSON file to read.
|
|
949
1061
|
* @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
|
|
950
1062
|
*/
|
|
951
1063
|
function loadTable(options, filePath) {
|
|
952
|
-
const
|
|
1064
|
+
const processNoiseRate = correctionDriftRateToProcessNoiseRate(options.correctionDriftRate);
|
|
953
1065
|
let fileData = Table2D.readFromFile(filePath);
|
|
954
|
-
let table;
|
|
955
1066
|
if (fileData) {
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
app.debug("Correction table loaded
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
|
|
969
|
-
const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
|
|
970
|
-
table = new CorrectionTable(name, row, col, stability);
|
|
1067
|
+
try {
|
|
1068
|
+
const migration = migrateCorrectionTableData(fileData, {
|
|
1069
|
+
filePath,
|
|
1070
|
+
debug: message => app.debug(message)
|
|
1071
|
+
});
|
|
1072
|
+
const table = CorrectionTable.fromJSON(migration.data, processNoiseRate);
|
|
1073
|
+
table.setDisplayAttributes({ label: table.id }); // Table2D API unchanged
|
|
1074
|
+
if (migration.migrated) saveMigratedTable(table, filePath);
|
|
1075
|
+
app.debug("Correction table loaded: " + (fileData.id || filePath));
|
|
1076
|
+
return table;
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
return recoverTable(`could not deserialize ${filePath}: ${err.message}`);
|
|
971
1079
|
}
|
|
972
|
-
} else {
|
|
973
|
-
const name = options.tableName || 'correctionTable';
|
|
974
|
-
const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
|
|
975
|
-
const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
|
|
976
|
-
table = new CorrectionTable(name, row, col, stability);
|
|
977
|
-
app.debug("Correction table created: " + name);
|
|
978
1080
|
}
|
|
979
|
-
|
|
1081
|
+
|
|
1082
|
+
if (fs.existsSync(filePath)) {
|
|
1083
|
+
return recoverTable(`could not read ${filePath}`);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const table = createDefaultTable(options.tableName || 'correctionTable', processNoiseRate);
|
|
1087
|
+
app.debug("Correction table created: " + table.id);
|
|
1088
|
+
return table;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function createDefaultTable(name, processNoiseRate = DEFAULT_PROCESS_NOISE_RATE) {
|
|
1092
|
+
const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
|
|
1093
|
+
const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
|
|
1094
|
+
const table = new CorrectionTable(name, row, col, processNoiseRate);
|
|
1095
|
+
table.setDisplayAttributes({ label: name }); // Table2D API unchanged
|
|
1096
|
+
return table;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function recoverTable(reason) {
|
|
1100
|
+
const recoveryName = `correctionTable-${new Date().toISOString().slice(0, 10)}`;
|
|
1101
|
+
const recoveryPath = path.join(app.getDataDirPath(), recoveryName + '.json');
|
|
1102
|
+
const table = createDefaultTable(recoveryName, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
1103
|
+
const message = `Correction table recovery: ${reason}; loaded empty table ${recoveryName}`;
|
|
1104
|
+
app.error(message);
|
|
1105
|
+
app.debug(message);
|
|
1106
|
+
saveTableSync(table, recoveryPath);
|
|
1107
|
+
saveTableName(recoveryName);
|
|
980
1108
|
return table;
|
|
981
1109
|
}
|
|
982
1110
|
|
|
@@ -1004,6 +1132,18 @@ module.exports = function (app) {
|
|
|
1004
1132
|
}
|
|
1005
1133
|
}
|
|
1006
1134
|
|
|
1135
|
+
function saveMigratedTable(correctionTable, filePath) {
|
|
1136
|
+
const temporaryPath = `${filePath}.migration-${process.pid}-${Date.now()}.tmp`;
|
|
1137
|
+
try {
|
|
1138
|
+
fs.writeFileSync(temporaryPath, JSON.stringify(correctionTable.toJSON(), null, 2));
|
|
1139
|
+
fs.renameSync(temporaryPath, filePath);
|
|
1140
|
+
app.debug(`Correction table migrated to schema v2: ${filePath}`);
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
try { fs.unlinkSync(temporaryPath); } catch {}
|
|
1143
|
+
app.debug(`Correction table migration could not be persisted for ${filePath}: ${err.message}`);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1007
1147
|
/**
|
|
1008
1148
|
* Returns true if every cell in the table has N === 0 (no learned data).
|
|
1009
1149
|
* Used to guard against overwriting a good on-disk file with an empty table.
|
|
@@ -1033,7 +1173,7 @@ module.exports = function (app) {
|
|
|
1033
1173
|
const filePath = path.join(app.getDataDirPath(), table.id + '.json');
|
|
1034
1174
|
const fileData = Table2D.readFromFile(filePath);
|
|
1035
1175
|
if (fileData) {
|
|
1036
|
-
const reloaded = CorrectionTable.fromJSON(fileData, options.
|
|
1176
|
+
const reloaded = CorrectionTable.fromJSON(fileData, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
1037
1177
|
reloaded.setDisplayAttributes({ label: reloaded.id });
|
|
1038
1178
|
if (!isTableEmpty(reloaded)) {
|
|
1039
1179
|
swapTable(reloaded);
|
|
@@ -1042,8 +1182,8 @@ module.exports = function (app) {
|
|
|
1042
1182
|
}
|
|
1043
1183
|
}
|
|
1044
1184
|
}
|
|
1045
|
-
// All other keys (sogFallback, estimateBoatSpeed,
|
|
1046
|
-
//
|
|
1185
|
+
// All other keys (sogFallback, estimateBoatSpeed, correctionDriftRate,
|
|
1186
|
+
// and smootherTimeSpan) are read
|
|
1047
1187
|
// directly from options.* so no extra action needed.
|
|
1048
1188
|
if (key === 'estimateBoatSpeed' && !value && correctedBoatSpeed) {
|
|
1049
1189
|
Polar.clear(app, plugin.id, [correctedBoatSpeed]);
|
|
@@ -1053,10 +1193,13 @@ module.exports = function (app) {
|
|
|
1053
1193
|
delete changedOptions[key];
|
|
1054
1194
|
}
|
|
1055
1195
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1196
|
+
if (changedKeys.includes('correctionDriftRate') && table) {
|
|
1197
|
+
table.setProcessNoiseRate(correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Hot-apply the learning window to all user-tuned smoothers.
|
|
1201
|
+
if (changedKeys.includes('smootherTimeSpan')) {
|
|
1202
|
+
options.smootherTimeSpan = getSmoothingWindowSeconds(options);
|
|
1060
1203
|
const { SmootherClass: SC, smootherOptions: so } = resolveSmootherConfig();
|
|
1061
1204
|
for (const s of [smoothedHeading, smoothedBoatSpeed, smoothedGroundSpeed]) {
|
|
1062
1205
|
if (s) { s.setSmootherClass(SC); s.setSmootherOptions(so); }
|
|
@@ -1072,13 +1215,18 @@ module.exports = function (app) {
|
|
|
1072
1215
|
module.exports._test = {
|
|
1073
1216
|
normalizeNavigationState,
|
|
1074
1217
|
getShortStabilizingMs,
|
|
1218
|
+
getSmoothingWindowSeconds,
|
|
1219
|
+
getLearningIntervalMs,
|
|
1220
|
+
correctionDriftRateToProcessNoiseRate,
|
|
1221
|
+
legacyStabilityToCorrectionDriftRate,
|
|
1222
|
+
migrateCorrectionTableData,
|
|
1223
|
+
isVesselMoving,
|
|
1075
1224
|
evaluateLearningMode,
|
|
1076
1225
|
isCogOverrideActive,
|
|
1077
1226
|
isLeewayValid,
|
|
1078
1227
|
evaluateObservationGate,
|
|
1079
1228
|
getDerivedObservationStatus,
|
|
1080
1229
|
buildNavigationStateStatus,
|
|
1081
|
-
|
|
1082
|
-
OPTIONAL_BLOCKING_NAVIGATION_STATES,
|
|
1230
|
+
NOT_MOVING_NAVIGATION_STATES,
|
|
1083
1231
|
LONG_STABILIZING_MS,
|
|
1084
1232
|
};
|