speedandcurrent 2.3.4 → 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 +285 -127
- package/package.json +2 -2
- 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
|
}
|
|
@@ -396,6 +455,9 @@ module.exports = function (app) {
|
|
|
396
455
|
const payload = reportFull.report();
|
|
397
456
|
payload.lifecycleWarnings = lifecycleWarnings;
|
|
398
457
|
payload.learningState = getLearningStatePayload();
|
|
458
|
+
payload.pollIntervalMs = calculationIntervalSmoother?.estimate
|
|
459
|
+
? calculationIntervalSmoother.estimate * 1000
|
|
460
|
+
: 1000;
|
|
399
461
|
res.json(payload);
|
|
400
462
|
}
|
|
401
463
|
});
|
|
@@ -416,7 +478,9 @@ module.exports = function (app) {
|
|
|
416
478
|
|
|
417
479
|
// --- Settings API ---
|
|
418
480
|
router.get('/api/settings', (req, res) => {
|
|
419
|
-
|
|
481
|
+
const active = { ...options, ...changedOptions };
|
|
482
|
+
for (const key of OBSOLETE_SMOOTHER_KEYS) delete active[key];
|
|
483
|
+
res.json(active);
|
|
420
484
|
});
|
|
421
485
|
|
|
422
486
|
router.put('/api/settings', (req, res) => {
|
|
@@ -431,8 +495,22 @@ module.exports = function (app) {
|
|
|
431
495
|
return res.status(400).json({ error: `Key '${k}' is managed via the table manager` });
|
|
432
496
|
}
|
|
433
497
|
}
|
|
434
|
-
|
|
435
|
-
|
|
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);
|
|
436
514
|
});
|
|
437
515
|
|
|
438
516
|
// --- Correction Table Manager API ---
|
|
@@ -470,7 +548,7 @@ module.exports = function (app) {
|
|
|
470
548
|
}
|
|
471
549
|
const row = { min: 0, max: body.maxSpeed, step: body.speedStep };
|
|
472
550
|
const col = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
|
|
473
|
-
const newTable = new CorrectionTable(name, row, col, options.
|
|
551
|
+
const newTable = new CorrectionTable(name, row, col, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
474
552
|
newTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
|
|
475
553
|
saveTable(newTable, path.join(app.getDataDirPath(), name + '.json'));
|
|
476
554
|
if (isRunning) swapTable(newTable);
|
|
@@ -486,12 +564,25 @@ module.exports = function (app) {
|
|
|
486
564
|
return res.status(400).json({ error: 'Invalid table name' });
|
|
487
565
|
const filePath = path.join(app.getDataDirPath(), name + '.json');
|
|
488
566
|
const fileData = Table2D.readFromFile(filePath);
|
|
489
|
-
if (!fileData)
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
+
}
|
|
495
586
|
});
|
|
496
587
|
|
|
497
588
|
// Copy active table under a new name and hot-swap to it
|
|
@@ -503,7 +594,7 @@ module.exports = function (app) {
|
|
|
503
594
|
return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
|
|
504
595
|
const data = table.toJSON();
|
|
505
596
|
data.id = newName;
|
|
506
|
-
const copiedTable = CorrectionTable.fromJSON(data, options.
|
|
597
|
+
const copiedTable = CorrectionTable.fromJSON(data, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
507
598
|
copiedTable.setDisplayAttributes({ label: newName }); // Table2D API unchanged
|
|
508
599
|
saveTable(copiedTable, path.join(app.getDataDirPath(), newName + '.json'));
|
|
509
600
|
swapTable(copiedTable);
|
|
@@ -522,7 +613,7 @@ module.exports = function (app) {
|
|
|
522
613
|
}
|
|
523
614
|
const newRow = { min: 0, max: body.maxSpeed, step: body.speedStep };
|
|
524
615
|
const newCol = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
|
|
525
|
-
const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, options.
|
|
616
|
+
const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate), 1e-4);
|
|
526
617
|
resized.setDisplayAttributes({ label: resized.id }); // Table2D API unchanged
|
|
527
618
|
saveTable(resized, path.join(app.getDataDirPath(), resized.id + '.json'));
|
|
528
619
|
swapTable(resized);
|
|
@@ -547,6 +638,9 @@ module.exports = function (app) {
|
|
|
547
638
|
const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
|
|
548
639
|
table = loadTable(options, tableFilePath);
|
|
549
640
|
minSpeed = table.step[0];
|
|
641
|
+
calculationIntervalSmoother = new ExponentialSmoother({ tau: 10 });
|
|
642
|
+
lastCalculationTime = null;
|
|
643
|
+
lastLearningAttemptTime = null;
|
|
550
644
|
|
|
551
645
|
//#region Handler and Polar Initialization
|
|
552
646
|
const { SmootherClass, smootherOptions } = resolveSmootherConfig();
|
|
@@ -583,6 +677,10 @@ module.exports = function (app) {
|
|
|
583
677
|
|
|
584
678
|
navigationStateHandler = new MessageHandler(app, plugin.id, 'navigationState');
|
|
585
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;
|
|
586
684
|
navigationStateHandler.onDelta = () => {
|
|
587
685
|
handleNavigationStateDelta();
|
|
588
686
|
};
|
|
@@ -619,7 +717,6 @@ module.exports = function (app) {
|
|
|
619
717
|
});
|
|
620
718
|
noCurrent.xSmoother.reset(0,0);
|
|
621
719
|
noCurrent.ySmoother.reset(0,0);
|
|
622
|
-
PolarSmoother.send(app, plugin.id, [noCurrent]);
|
|
623
720
|
|
|
624
721
|
MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
|
|
625
722
|
units: 'rad',
|
|
@@ -639,12 +736,23 @@ module.exports = function (app) {
|
|
|
639
736
|
smootherOptions,
|
|
640
737
|
onDelta: () => {
|
|
641
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;
|
|
642
745
|
// Drain any pending option changes before calculating
|
|
643
746
|
if (Object.keys(changedOptions).length) applyOptionChanges();
|
|
644
747
|
|
|
645
748
|
const learningMode = evaluateLearningMode({
|
|
646
749
|
options,
|
|
647
|
-
|
|
750
|
+
vesselMoving: isVesselMoving({
|
|
751
|
+
sogMagnitude: smoothedGroundSpeed?.magnitude,
|
|
752
|
+
speedThreshold: minSpeed,
|
|
753
|
+
navigationStateHandler,
|
|
754
|
+
gateEnabled: options.suspendLearningOnNavigationState
|
|
755
|
+
}),
|
|
648
756
|
stabilizingUntil: learningStabilizingUntil,
|
|
649
757
|
stabilizingReason: learningStabilizingReason,
|
|
650
758
|
now: Date.now()
|
|
@@ -752,11 +860,6 @@ module.exports = function (app) {
|
|
|
752
860
|
reportFull.addAttitude(smoothedAttitude);
|
|
753
861
|
reportFull.addDelta(smoothedBoatSpeed);
|
|
754
862
|
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
863
|
reportFull.addTable(table);
|
|
761
864
|
|
|
762
865
|
//#endregion
|
|
@@ -848,7 +951,13 @@ module.exports = function (app) {
|
|
|
848
951
|
clearLifecycleWarning('attitude.roll');
|
|
849
952
|
if (correctedBoatSpeed.magnitude > 0) {
|
|
850
953
|
const { correction, variance } = table.getCorrection(correctedBoatSpeed.magnitude, rawAttitude.value?.roll);
|
|
851
|
-
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);
|
|
852
961
|
speedCorrection.setVectorValue(
|
|
853
962
|
{ x: correction.x, y: leewayValid ? correction.y : 0 },
|
|
854
963
|
{ x: variance.x, y: leewayValid ? variance.y : 0 }
|
|
@@ -886,21 +995,28 @@ module.exports = function (app) {
|
|
|
886
995
|
setLifecycleWarning('attitude.roll', 'incomplete', 'navigation.attitude.roll');
|
|
887
996
|
}
|
|
888
997
|
|
|
889
|
-
PolarSmoother.send(app, plugin.id, [smoothedCurrent
|
|
998
|
+
PolarSmoother.send(app, plugin.id, [smoothedCurrent]);
|
|
890
999
|
|
|
891
1000
|
Polar.send(app, plugin.id, [correctedBoatSpeed]);
|
|
892
1001
|
}
|
|
893
1002
|
|
|
894
1003
|
/**
|
|
895
1004
|
* Updates the correction table from the current smoothed inputs.
|
|
896
|
-
|
|
1005
|
+
* Reads minSpeed from module-level state;
|
|
897
1006
|
* silently returns if any required input is not yet ready.
|
|
898
1007
|
*/
|
|
899
1008
|
function updateTable() {
|
|
1009
|
+
const now = Date.now();
|
|
1010
|
+
if (lastLearningAttemptTime !== null && now - lastLearningAttemptTime < getLearningIntervalMs(options)) return;
|
|
900
1011
|
lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
|
|
901
1012
|
const learningMode = evaluateLearningMode({
|
|
902
1013
|
options,
|
|
903
|
-
|
|
1014
|
+
vesselMoving: isVesselMoving({
|
|
1015
|
+
sogMagnitude: smoothedGroundSpeed.magnitude,
|
|
1016
|
+
speedThreshold: minSpeed,
|
|
1017
|
+
navigationStateHandler,
|
|
1018
|
+
gateEnabled: options.suspendLearningOnNavigationState
|
|
1019
|
+
}),
|
|
904
1020
|
stabilizingUntil: learningStabilizingUntil,
|
|
905
1021
|
stabilizingReason: learningStabilizingReason,
|
|
906
1022
|
now: Date.now()
|
|
@@ -911,12 +1027,9 @@ module.exports = function (app) {
|
|
|
911
1027
|
}
|
|
912
1028
|
const inputsReady = smoothedAttitude.ready && Number.isFinite(smoothedAttitude.value?.roll)
|
|
913
1029
|
&& smoothedBoatSpeed.ready && smoothedHeading.ready && smoothedGroundSpeed.ready;
|
|
914
|
-
const currentReady = !options.assumeCurrent || smoothedCurrent.ready;
|
|
915
1030
|
const observationGate = evaluateObservationGate({
|
|
916
1031
|
learningMode,
|
|
917
1032
|
inputsReady,
|
|
918
|
-
assumeCurrent: options.assumeCurrent,
|
|
919
|
-
currentReady,
|
|
920
1033
|
stw: smoothedBoatSpeed.value,
|
|
921
1034
|
sog: smoothedGroundSpeed.magnitude,
|
|
922
1035
|
speedThreshold: minSpeed
|
|
@@ -930,7 +1043,16 @@ module.exports = function (app) {
|
|
|
930
1043
|
return;
|
|
931
1044
|
}
|
|
932
1045
|
|
|
933
|
-
|
|
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
|
+
);
|
|
934
1056
|
if (table.lastUpdateResult === 'accepted') {
|
|
935
1057
|
setObservationStatus('accepted', 'accepted');
|
|
936
1058
|
} else if (table.lastUpdateResult === 'rejected') {
|
|
@@ -944,39 +1066,55 @@ module.exports = function (app) {
|
|
|
944
1066
|
* If the file exists it is deserialized; otherwise a new table is created
|
|
945
1067
|
* with default dimensions and saved to disk.
|
|
946
1068
|
*
|
|
947
|
-
|
|
1069
|
+
* @param {Object} options - Plugin options (correctionDriftRate, tableName, and dimension defaults).
|
|
948
1070
|
* @param {string} filePath - Absolute path of the JSON file to read.
|
|
949
1071
|
* @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
|
|
950
1072
|
*/
|
|
951
1073
|
function loadTable(options, filePath) {
|
|
952
|
-
const
|
|
1074
|
+
const processNoiseRate = correctionDriftRateToProcessNoiseRate(options.correctionDriftRate);
|
|
953
1075
|
let fileData = Table2D.readFromFile(filePath);
|
|
954
|
-
let table;
|
|
955
1076
|
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);
|
|
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}`);
|
|
971
1089
|
}
|
|
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
1090
|
}
|
|
979
|
-
|
|
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);
|
|
980
1118
|
return table;
|
|
981
1119
|
}
|
|
982
1120
|
|
|
@@ -1004,6 +1142,18 @@ module.exports = function (app) {
|
|
|
1004
1142
|
}
|
|
1005
1143
|
}
|
|
1006
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
|
+
|
|
1007
1157
|
/**
|
|
1008
1158
|
* Returns true if every cell in the table has N === 0 (no learned data).
|
|
1009
1159
|
* Used to guard against overwriting a good on-disk file with an empty table.
|
|
@@ -1033,7 +1183,7 @@ module.exports = function (app) {
|
|
|
1033
1183
|
const filePath = path.join(app.getDataDirPath(), table.id + '.json');
|
|
1034
1184
|
const fileData = Table2D.readFromFile(filePath);
|
|
1035
1185
|
if (fileData) {
|
|
1036
|
-
const reloaded = CorrectionTable.fromJSON(fileData, options.
|
|
1186
|
+
const reloaded = CorrectionTable.fromJSON(fileData, correctionDriftRateToProcessNoiseRate(options.correctionDriftRate));
|
|
1037
1187
|
reloaded.setDisplayAttributes({ label: reloaded.id });
|
|
1038
1188
|
if (!isTableEmpty(reloaded)) {
|
|
1039
1189
|
swapTable(reloaded);
|
|
@@ -1042,8 +1192,8 @@ module.exports = function (app) {
|
|
|
1042
1192
|
}
|
|
1043
1193
|
}
|
|
1044
1194
|
}
|
|
1045
|
-
// All other keys (sogFallback, estimateBoatSpeed,
|
|
1046
|
-
//
|
|
1195
|
+
// All other keys (sogFallback, estimateBoatSpeed, correctionDriftRate,
|
|
1196
|
+
// and smootherTimeSpan) are read
|
|
1047
1197
|
// directly from options.* so no extra action needed.
|
|
1048
1198
|
if (key === 'estimateBoatSpeed' && !value && correctedBoatSpeed) {
|
|
1049
1199
|
Polar.clear(app, plugin.id, [correctedBoatSpeed]);
|
|
@@ -1053,10 +1203,13 @@ module.exports = function (app) {
|
|
|
1053
1203
|
delete changedOptions[key];
|
|
1054
1204
|
}
|
|
1055
1205
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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);
|
|
1060
1213
|
const { SmootherClass: SC, smootherOptions: so } = resolveSmootherConfig();
|
|
1061
1214
|
for (const s of [smoothedHeading, smoothedBoatSpeed, smoothedGroundSpeed]) {
|
|
1062
1215
|
if (s) { s.setSmootherClass(SC); s.setSmootherOptions(so); }
|
|
@@ -1072,13 +1225,18 @@ module.exports = function (app) {
|
|
|
1072
1225
|
module.exports._test = {
|
|
1073
1226
|
normalizeNavigationState,
|
|
1074
1227
|
getShortStabilizingMs,
|
|
1228
|
+
getSmoothingWindowSeconds,
|
|
1229
|
+
getLearningIntervalMs,
|
|
1230
|
+
correctionDriftRateToProcessNoiseRate,
|
|
1231
|
+
legacyStabilityToCorrectionDriftRate,
|
|
1232
|
+
migrateCorrectionTableData,
|
|
1233
|
+
isVesselMoving,
|
|
1075
1234
|
evaluateLearningMode,
|
|
1076
1235
|
isCogOverrideActive,
|
|
1077
1236
|
isLeewayValid,
|
|
1078
1237
|
evaluateObservationGate,
|
|
1079
1238
|
getDerivedObservationStatus,
|
|
1080
1239
|
buildNavigationStateStatus,
|
|
1081
|
-
|
|
1082
|
-
OPTIONAL_BLOCKING_NAVIGATION_STATES,
|
|
1240
|
+
NOT_MOVING_NAVIGATION_STATES,
|
|
1083
1241
|
LONG_STABILIZING_MS,
|
|
1084
1242
|
};
|