speedandcurrent 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,780 +1,843 @@
1
- const path = require('path');
2
- const fs = require('fs');
3
-
4
- const {
5
- SmoothedAngle,
6
- SI,
7
- MessageHandler,
8
- Polar,
9
- Reporter,
10
- BaseSmoother,
11
- MovingAverageSmoother,
12
- ExponentialSmoother,
13
- KalmanSmoother,
14
- PolarSmoother,
15
- createSmoothedPolar,
16
- createSmoothedHandler,
17
- Table2D
18
- } = require('signalkutilities');
19
-
20
- const { CorrectionTable } = require('./correctionTable.js');
21
-
22
- module.exports = function (app) {
23
-
24
- const DEFAULT_DIMS = { maxSpeed: 9, speedStep: 1, maxHeel: 32, heelStep: 8 };
25
-
26
- let options = {};
27
- let changedOptions = {};
28
- let hasPendingChanges = false;
29
- const defaultOptions = {
30
- sogFallback: true,
31
- estimateBoatSpeed: false,
32
- updateCorrectionTable: true,
33
- stability: 7,
34
- assumeCurrent: false,
35
- tableName: 'correctionTable',
36
- configVersion: 2,
37
- smootherClass: 'MovingAverageSmoother',
38
- smootherTau: 3,
39
- smootherTimeSpan: 5,
40
- smootherSteadyState: 0.2,
41
- showStatistics: false,
42
- stalenessDetection: true
43
- };
44
-
45
-
46
-
47
- function readOptions() {
48
- const stored = app.readPluginOptions();
49
- const raw = stored && stored.configuration ? stored.configuration : (stored || {});
50
- // Strip embedded table — stored separately on disk
51
- const { correctionTable: _drop, ...rest } = raw;
52
- options = { ...defaultOptions, ...rest };
53
- }
54
-
55
- function saveOptions() {
56
- app.savePluginOptions({ ...options }, (err) => {
57
- if (err) app.error(`Error saving plugin options: ${err.message}`);
58
- });
59
- }
60
-
61
- function saveTableName(name) {
62
- options.tableName = name;
63
- saveOptions();
64
- }
65
-
66
- /**
67
- * Strips obsolete source-selection fields from the persisted config and
68
- * writes it back if anything changed. Called once on every start().
69
- */
70
- function migrateConfig() {
71
- const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication'];
72
- const hadObsolete = obsoleteKeys.some(k => k in options);
73
- for (const k of obsoleteKeys) delete options[k];
74
- if (hadObsolete || (options.configVersion || 0) < 2) {
75
- options.configVersion = 2;
76
- saveOptions();
77
- app.debug('Config migrated to v2: removed obsolete source-selection fields');
78
- }
79
- }
80
-
81
- /**
82
- * Derives { SmootherClass, smootherOptions } from the current options.
83
- * Enforces minimums so the smoother always has at least two observations for
84
- * variance to be meaningful:
85
- * - MovingAverageSmoother: timeSpan >= 2 s (≥ 2 samples at typical 1 Hz)
86
- * - ExponentialSmoother: tau >= 1 s
87
- * - KalmanSmoother: steadyState in [0.01, 0.99]
88
- */
89
- function resolveSmootherConfig() {
90
- const cls = options.smootherClass || 'MovingAverageSmoother';
91
- if (cls === 'ExponentialSmoother') {
92
- return {
93
- SmootherClass: ExponentialSmoother,
94
- smootherOptions: { timeConstant: Math.max(1, Number(options.smootherTau) || 3) }
95
- };
96
- }
97
- if (cls === 'KalmanSmoother') {
98
- const K = Math.min(0.99, Math.max(0.01, Number(options.smootherSteadyState) || 0.2));
99
- return {
100
- SmootherClass: KalmanSmoother,
101
- smootherOptions: { steadyState: K }
102
- };
103
- }
104
- // Default: MovingAverageSmoother
105
- return {
106
- SmootherClass: MovingAverageSmoother,
107
- smootherOptions: { timeSpan: Math.max(2, Number(options.smootherTimeSpan) || 5) }
108
- };
109
- }
110
-
111
- function swapTable(newTable) {
112
- table = newTable;
113
- minSpeed = table.step[0] / 2;
114
- if (reportFull) reportFull.setTables([table]);
115
- lastSave = Date.now(); // explicit table operations already save; defer next periodic save
116
- }
117
-
118
- let isRunning = false;
119
- let pluginStatus = 'Stopped';
120
- let smoothedHeading = null;
121
- let smoothedAttitude = null;
122
- let rawCurrent = null;
123
- let smoothedCurrent = null;
124
- let smoothedBoatSpeed = null;
125
- let correctedBoatSpeed = null;
126
- let lrnBoatSpeed = null;
127
- let boatSpeedRefGround = null;
128
- let smoothedGroundSpeed = null;
129
- let speedCorrection = null;
130
- let residual = null;
131
- let smoothedResidual = null;
132
- let reportFull = null;
133
- let table = null;
134
-
135
- let rawHeading = null;
136
- let rawAttitude = null;
137
- let noCurrent = null;
138
- let rawBoatSpeed = null;
139
- let rawGroundSpeed = null;
140
- let started = null;
141
- let minSpeed = 0;
142
- let lastSave = 0;
143
-
144
- const plugin = {};
145
- plugin.id = "SpeedAndCurrent";
146
- plugin.name = "Speed and current";
147
- plugin.description = "A plugin that uses sensor fusion to get boat speed, current and leeway.";
148
-
149
- plugin.schema = {
150
- type: "object",
151
- description: "Speed and Current is configured through its own webapp. Open it from the Signal K app list.",
152
- properties: {}
153
- };
154
-
155
-
156
- plugin.registerWithRouter = function (router) {
157
- app.debug('registerWithRouter');
158
- readOptions(); // pre-load so /api/settings works before start()
159
-
160
- router.get('/api/report', (req, res) => {
161
- if (!isRunning) {
162
- res.status(503).json({ error: "Plugin is not running" });
163
- } else {
164
- res.json(reportFull.report());
165
- }
166
- });
167
-
168
- router.get('/api/meta', (req, res) => {
169
- if (!isRunning) {
170
- res.status(503).json({ error: "Plugin is not running" });
171
- } else {
172
- res.json(reportFull.meta());
173
- }
174
- });
175
-
176
-
177
-
178
- router.get('/api/status', (req, res) => {
179
- res.json({ status: pluginStatus, isRunning });
180
- });
181
-
182
- // --- Settings API ---
183
- router.get('/api/settings', (req, res) => {
184
- res.json({ ...options, ...changedOptions });
185
- });
186
-
187
- router.put('/api/settings', (req, res) => {
188
- const body = req.body;
189
- if (!body || typeof body !== 'object') {
190
- return res.status(400).json({ error: 'JSON body required' });
191
- }
192
- // Reject keys managed by the table manager
193
- const blocked = ['correctionTable', 'tableName'];
194
- for (const k of blocked) {
195
- if (k in body) {
196
- return res.status(400).json({ error: `Key '${k}' is managed via the table manager` });
197
- }
198
- }
199
- changedOptions = { ...changedOptions, ...body };
200
- hasPendingChanges = true;
201
- res.json({ ...options, ...changedOptions });
202
- });
203
-
204
- // --- Correction Table Manager API ---
205
-
206
- // List all table files in dataDir
207
- router.get('/api/tables', (req, res) => {
208
- const dataDir = app.getDataDirPath();
209
- let files;
210
- try { files = fs.readdirSync(dataDir).filter(f => f.endsWith('.json')); }
211
- catch (e) { return res.json([]); }
212
- const activeName = options.tableName || 'correctionTable';
213
- const tables = [];
214
- for (const file of files) {
215
- try {
216
- const data = Table2D.readFromFile(path.join(dataDir, file));
217
- if (data && data.row && data.col && Array.isArray(data.table)) {
218
- const name = file.replace(/\.json$/, '');
219
- tables.push({ name, active: name === activeName });
220
- }
221
- } catch (e) { /* skip non-table files */ }
222
- }
223
- res.json(tables);
224
- });
225
-
226
- // Create a new table and hot-swap it
227
- router.post('/api/tables/create', (req, res) => {
228
- const body = req.body || {};
229
- const name = (body.name || '').trim();
230
- if (!name || !/^[\w-]+$/.test(name))
231
- return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
232
- const dims = ['maxSpeed', 'speedStep', 'maxHeel', 'heelStep'];
233
- for (const f of dims) {
234
- if (!Number.isFinite(body[f]) || body[f] <= 0)
235
- return res.status(400).json({ error: `Invalid or missing field: ${f}` });
236
- }
237
- const row = { min: 0, max: body.maxSpeed, step: body.speedStep };
238
- const col = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
239
- const newTable = new CorrectionTable(name, row, col, options.stability || 7);
240
- newTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
241
- saveTable(newTable, path.join(app.getDataDirPath(), name + '.json'));
242
- if (isRunning) swapTable(newTable);
243
- saveTableName(name);
244
- res.json({ name });
245
- });
246
-
247
- // Load a saved table and make it active
248
- router.post('/api/tables/load', (req, res) => {
249
- const body = req.body || {};
250
- const name = (body.name || '').trim();
251
- if (!name || !/^[\w-]+$/.test(name))
252
- return res.status(400).json({ error: 'Invalid table name' });
253
- const filePath = path.join(app.getDataDirPath(), name + '.json');
254
- const fileData = Table2D.readFromFile(filePath);
255
- if (!fileData) return res.status(404).json({ error: `Table '${name}' not found` });
256
- const loadedTable = CorrectionTable.fromJSON(fileData, options.stability || 7);
257
- loadedTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
258
- if (isRunning) swapTable(loadedTable);
259
- saveTableName(name);
260
- res.json({ name });
261
- });
262
-
263
- // Copy active table under a new name and hot-swap to it
264
- router.post('/api/tables/copy', (req, res) => {
265
- if (!isRunning || !table) return res.status(503).json({ error: 'Plugin is not running' });
266
- const body = req.body || {};
267
- const newName = (body.newName || '').trim();
268
- if (!newName || !/^[\w-]+$/.test(newName))
269
- return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
270
- const data = table.toJSON();
271
- data.id = newName;
272
- const copiedTable = CorrectionTable.fromJSON(data, options.stability || 7);
273
- copiedTable.setDisplayAttributes({ label: newName }); // Table2D API unchanged
274
- saveTable(copiedTable, path.join(app.getDataDirPath(), newName + '.json'));
275
- swapTable(copiedTable);
276
- saveTableName(newName);
277
- res.json({ name: newName });
278
- });
279
-
280
- // Resize the active table (resamples onto new grid, preserves name)
281
- router.post('/api/tables/resize', (req, res) => {
282
- if (!isRunning || !table) return res.status(503).json({ error: 'Plugin is not running' });
283
- const body = req.body || {};
284
- const dims = ['maxSpeed', 'speedStep', 'maxHeel', 'heelStep'];
285
- for (const f of dims) {
286
- if (!Number.isFinite(body[f]) || body[f] <= 0)
287
- return res.status(400).json({ error: `Invalid or missing field: ${f}` });
288
- }
289
- const newRow = { min: 0, max: body.maxSpeed, step: body.speedStep };
290
- const newCol = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
291
- const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, options.stability || 7, 1e-4);
292
- resized.setDisplayAttributes({ label: resized.id }); // Table2D API unchanged
293
- saveTable(resized, path.join(app.getDataDirPath(), resized.id + '.json'));
294
- swapTable(resized);
295
- res.json({ name: resized.id });
296
- });
297
-
298
- }
299
-
300
- function setStatus(msg) {
301
- pluginStatus = msg;
302
- app.setPluginStatus(msg);
303
- }
304
-
305
- plugin.start = (settings) => {
306
- setStatus('Starting');
307
- app.debug("Starting");
308
- readOptions(); // pick up any saves since registerWithRouter ran
309
- migrateConfig(); // strip obsolete fields from persisted config
310
- const tableName = options.tableName || 'correctionTable';
311
- const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
312
- table = loadTable(options, tableFilePath);
313
- minSpeed = table.step[0] / 2;
314
-
315
- //#region Handler and Polar Initialization
316
- const { SmootherClass, smootherOptions } = resolveSmootherConfig();
317
-
318
- // heading
319
- smoothedHeading = new SmoothedAngle(app, plugin.id, 'heading', 'navigation.headingTrue', {
320
- angleRange: '0to2pi',
321
- meta: { displayName: 'Heading', plane: 'Ground' },
322
- SmootherClass,
323
- smootherOptions
324
- });
325
- // The magnitude handler has a fixed value of 1 and is never subscribed, so its timestamp
326
- // stays null forever. Staleness detection on it would always force _stale=true, blocking
327
- // processChanges() from setting polar._ready=true on every heading delta.
328
- smoothedHeading.polar.magnitudeHandler.stalenessDetection = false;
329
- rawHeading = smoothedHeading.handler;
330
-
331
- //attitude
332
- smoothedAttitude = createSmoothedHandler({
333
- app, pluginId: plugin.id,
334
- id: 'attitude',
335
- path: 'navigation.attitude',
336
- subscribe: true,
337
- SmootherClass,
338
- smootherOptions
339
- });
340
- rawAttitude = smoothedAttitude.handler;
341
-
342
-
343
- // current
344
- // send metadata for current
345
- MessageHandler.setMeta(app, plugin.id, "environment.current.drift", {units: "m/s", type: "number", description: "Speed of the current"});
346
- MessageHandler.setMeta(app, plugin.id, "environment.current.setTrue", { units: "rad", type: "number", description: "Direction of the current" });
347
- rawCurrent = new Polar(app, plugin.id, "current");
348
- rawCurrent.configureMagnitude("environment.current.drift");
349
- rawCurrent.configureAngle("environment.current.setTrue");
350
- rawCurrent.setMeta({ displayName: "Current", plane: "Ground" });
351
- rawCurrent.setAngleRange('0to2pi');
352
- smoothedCurrent = new PolarSmoother(rawCurrent, KalmanSmoother, { processVariance: 0.000001, measurementVariance: 0.01 }); // id auto-derived: 'current.smoothed'
353
- smoothedCurrent.setAngleRange('0to2pi');
354
- // Current should be initialised as no current
355
- rawCurrent.setVectorValue({ x: 0, y: 0 });
356
- // Strongly assume no current at start
357
- smoothedCurrent.xSmoother.reset(0, 0.00000001);
358
- smoothedCurrent.ySmoother.reset(0, 0.00000001);
359
- // no current
360
- noCurrent = createSmoothedPolar({
361
- id: "noCurrent",
362
- pathMagnitude: "environment.current.drift",
363
- pathAngle: "environment.current.setTrue",
364
- subscribe: false,
365
- app,
366
- pluginId: plugin.id,
367
- SmootherClass: BaseSmoother,
368
- smootherOptions: smootherOptions,
369
- meta: { displayName: "NoCurrent", plane: "Ground" },
370
- });
371
- noCurrent.xSmoother.reset(0,0);
372
- noCurrent.ySmoother.reset(0,0);
373
- noCurrent.stalenessDetection = false; // noCurrent is a fixed zero-vector, never receives live data
374
- PolarSmoother.send(app, plugin.id, [noCurrent]);
375
-
376
- MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
377
- units: 'rad',
378
- description: 'Leeway Angle',
379
- displayUnits: {
380
- category: 'angle'
381
- }
382
- });
383
-
384
- // boatspeed
385
- smoothedBoatSpeed = createSmoothedHandler({
386
- app, pluginId: plugin.id,
387
- id: 'boatSpeed',
388
- path: 'navigation.speedThroughWater',
389
- subscribe: true,
390
- SmootherClass,
391
- smootherOptions
392
- });
393
- rawBoatSpeed = smoothedBoatSpeed.handler;
394
-
395
- // Learning polar — used for updating the correction table
396
- lrnBoatSpeed = new Polar(app, plugin.id, "lrnBoatSpeed");
397
- lrnBoatSpeed.configureMagnitude("navigation.speedThroughWater");
398
- lrnBoatSpeed.configureAngle("navigation.leewayAngle");
399
- lrnBoatSpeed.setMeta({ displayName: "Learning boat speed", plane: "Boat" });
400
- lrnBoatSpeed.setAngleRange('-piToPi');
401
-
402
-
403
- // corrected boatspeed holds both corrected boatspeed and estimated leeway
404
- correctedBoatSpeed = new Polar(app, plugin.id, "correctedBoatSpeed");
405
- correctedBoatSpeed.configureMagnitude("navigation.speedThroughWater");
406
- correctedBoatSpeed.configureAngle("navigation.leewayAngle");
407
- correctedBoatSpeed.setMeta({ displayName: "Corrected boatspeed / Leeway", plane: "Boat" });
408
-
409
- // boatspeed vector in ground frame, used for current estimation and residual calculation
410
- boatSpeedRefGround = new Polar(app, plugin.id, "boatSpeedRefGround");
411
- boatSpeedRefGround.setMeta({ displayName: "Boat speed over ground", plane: "Ground" });
412
-
413
- // ground speed
414
- smoothedGroundSpeed = createSmoothedPolar({
415
- app, pluginId: plugin.id,
416
- id: 'groundSpeed',
417
- pathMagnitude: 'navigation.speedOverGround',
418
- pathAngle: 'navigation.courseOverGroundTrue',
419
- angleRange: '0to2pi',
420
- meta: { displayName: 'Groundspeed', plane: 'Ground' },
421
- SmootherClass,
422
- smootherOptions
423
- });
424
- rawGroundSpeed = smoothedGroundSpeed.polar;
425
-
426
- // correction vector
427
- speedCorrection = new Polar(app, plugin.id, "speedCorrection");
428
- speedCorrection.setMeta({ displayName: "Speed correction", plane: "Boat" });
429
-
430
- // residual
431
- residual = new Polar(app, plugin.id, "residual");
432
- residual.setMeta({ displayName: "Residual", plane: "Ground" });
433
- smoothedResidual = new PolarSmoother(residual, ExponentialSmoother, { tau: 30, timeSpan: 30 }); // id auto-derived: 'residual.smoothed'
434
- smoothedResidual.setAngleRange('0to2pi');
435
-
436
- //#endregion
437
-
438
- //#region Reporting
439
- reportFull = new Reporter();
440
-
441
- if (options.estimateBoatSpeed) {
442
- reportFull.addDelta(rawHeading);
443
- reportFull.addAttitude(rawAttitude);
444
- reportFull.addDelta(rawBoatSpeed);
445
- reportFull.addPolar(speedCorrection);
446
- reportFull.addPolar(boatSpeedRefGround);
447
- reportFull.addPolar(correctedBoatSpeed);
448
- reportFull.addPolar(rawGroundSpeed);
449
- reportFull.addPolar(smoothedCurrent);
450
- reportFull.addPolar(residual);
451
- reportFull.addPolar(smoothedResidual);
452
- }
453
- // Smoothed learning inputs are always subscribed regardless of updateCorrectionTable,
454
- // so always add them to the report — otherwise warnings never clear when
455
- // learning was disabled at startup and later toggled on.
456
- reportFull.addDelta(smoothedHeading);
457
- reportFull.addAttitude(smoothedAttitude);
458
- reportFull.addDelta(smoothedBoatSpeed);
459
- reportFull.addPolar(smoothedGroundSpeed);
460
- // smoothedCurrent is already added by the estimateBoatSpeed block when that is on;
461
- // only add it here when estimateBoatSpeed is off, to avoid a duplicate entry.
462
- if (options.assumeCurrent && !options.estimateBoatSpeed) {
463
- reportFull.addPolar(smoothedCurrent);
464
- }
465
- reportFull.addTable(table);
466
-
467
- //#endregion
468
-
469
- // Apply staleness detection to all subscribed smoother instances.
470
- // noCurrent is intentionally excluded — it is a fixed zero-vector and never receives live data.
471
- // Only apply when sd = false: all smoothers already default to stalenessDetection = true.
472
- // Explicitly calling the setter with true at startup forces _stale = true on handlers that have
473
- // never received data, causing the first sample to be dropped (library side-effect).
474
- const sd = options.stalenessDetection ?? true;
475
- if (!sd) {
476
- for (const s of [smoothedHeading, smoothedAttitude, smoothedBoatSpeed, smoothedGroundSpeed, smoothedCurrent, smoothedResidual]) {
477
- if (s) s.stalenessDetection = false;
478
- }
479
- }
480
-
481
- isRunning = true;
482
- started = Date.now();
483
- lastSave = 0;
484
- setStatus('Running');
485
- app.debug("Running");
486
-
487
- smoothedBoatSpeed.onChange = () => {
488
- // Drain any pending option changes before calculating
489
- if (Object.keys(changedOptions).length) applyOptionChanges();
490
-
491
- const wellUnderway = started < new Date() - 60 * 1000;
492
-
493
- setStatus(wellUnderway ? 'Running' : 'Stabilizing');
494
- if (options.estimateBoatSpeed) correct(wellUnderway);
495
- if (options.updateCorrectionTable) {
496
- if (wellUnderway) {
497
- updateTable();
498
- // Save correction table periodically (skip if table is empty to avoid overwriting good data on disk)
499
- const now = Date.now();
500
- if (now - lastSave > 60_000) {
501
- if (!isTableEmpty(table)) {
502
- saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
503
- }
504
- lastSave = now;
505
- }
506
- } else {
507
- table.lastUpdateResult = 'stabilizing';
508
- }
509
- } else {
510
- table.lastUpdateResult = null;
511
- }
512
- };
513
- }
514
-
515
- plugin.stop = () => {
516
- return new Promise((resolve, reject) => {
517
- try {
518
- if (table && !isTableEmpty(table)) {
519
- saveTableSync(table, path.join(app.getDataDirPath(), table.id + '.json'));
520
- }
521
- // Clear all active output paths from the SK bus before teardown.
522
- if (smoothedCurrent) PolarSmoother.clear(app, plugin.id, [smoothedCurrent]);
523
- if (options.estimateBoatSpeed && correctedBoatSpeed) Polar.clear(app, plugin.id, [correctedBoatSpeed]);
524
- smoothedHeading = smoothedHeading?.terminate();
525
- smoothedAttitude = smoothedAttitude?.terminate();
526
- rawCurrent = rawCurrent?.terminate();
527
- smoothedCurrent = smoothedCurrent?.terminate?.();
528
- smoothedBoatSpeed = smoothedBoatSpeed?.terminate();
529
- correctedBoatSpeed = correctedBoatSpeed?.terminate();
530
- lrnBoatSpeed = lrnBoatSpeed?.terminate();
531
- boatSpeedRefGround = boatSpeedRefGround?.terminate();
532
- smoothedGroundSpeed = smoothedGroundSpeed?.terminate();
533
- speedCorrection = speedCorrection?.terminate();
534
- residual = residual?.terminate();
535
- smoothedResidual = smoothedResidual?.terminate?.();
536
- reportFull = null;
537
- table = null;
538
- rawHeading = null;
539
- rawAttitude = null;
540
- noCurrent = null;
541
- rawBoatSpeed = null;
542
- rawGroundSpeed = null;
543
- started = null;
544
- app.setPluginStatus("Stopped");
545
- app.debug("Stopped");
546
-
547
- pluginStatus = 'Stopped';
548
- isRunning = false;
549
- resolve();
550
- } catch (error) {
551
- reject(error);
552
- }
553
- });
554
- };
555
-
556
- /**
557
- * Corrects and publishes boat speed. Handles SOG fallback and missing-input
558
- * silent fallback so that navigation.speedThroughWater is always written
559
- * when estimateBoatSpeed is on, as long as any usable speed source exists.
560
- *
561
- * Priority order:
562
- * 1. STW zero + sogFallback enabled + SOG available → publish SOG magnitude
563
- * 2. attitude/heading not ready → publish raw STW unchanged (silent fallback)
564
- * 3. All inputs ready → apply table correction; estimate current if wellUnderway
565
- *
566
- * @param {boolean} wellUnderway - Gates current estimation (requires 60 s settling).
567
- */
568
- function correct(wellUnderway) {
569
- // copy the boatspeed delta to polar
570
- correctedBoatSpeed.setVectorValue({ x: rawBoatSpeed.value, y: 0 });
571
-
572
- speedCorrection.setVectorValue({ x: 0, y: 0 });
573
-
574
- // Priority 1: SOG fallback when STW is stuck at zero
575
- // (onChange only fires when STW is ready, so a missing STW cannot reach here)
576
- if (options.sogFallback && rawGroundSpeed.ready && correctedBoatSpeed.magnitude === 0 && rawGroundSpeed.magnitude >= minSpeed) {
577
- correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
578
- }
579
- else if (rawAttitude.ready) {
580
- if (correctedBoatSpeed.magnitude > 0) {
581
- const { correction, variance } = table.getCorrection(correctedBoatSpeed.magnitude, rawAttitude.value?.roll);
582
- speedCorrection.setVectorValue(correction, variance);
583
- correctedBoatSpeed.add(speedCorrection);
584
- }
585
- // Current estimation and residual also require heading (to rotate into ground frame).
586
- // Also handle near-zero SOG where COG is unavailable: treat groundspeed as zero vector.
587
- const sogHandler = rawGroundSpeed.magnitudeHandler;
588
- const nearZeroGroundSpeed = !rawGroundSpeed.ready &&
589
- sogHandler.ready &&
590
- Number.isFinite(sogHandler.value) &&
591
- sogHandler.value < 0.3;
592
- if (rawHeading.ready && (rawGroundSpeed.ready || nearZeroGroundSpeed)) {
593
- boatSpeedRefGround.copyFrom(correctedBoatSpeed);
594
- boatSpeedRefGround.rotate(rawHeading.value);
595
- // Current estimation gated by wellUnderway (smoothers need to settle first)
596
- if (wellUnderway) {
597
- if (rawGroundSpeed.ready) {
598
- rawCurrent.copyFrom(rawGroundSpeed);
599
- } else {
600
- // COG unavailable but SOG is near-zero: boat is stationary, groundspeed treated as zero
601
- rawCurrent.setVectorValue({ x: 0, y: 0 });
602
- }
603
- rawCurrent.substract(boatSpeedRefGround);
604
- smoothedCurrent.sample();
605
- }
606
- // Residual requires full groundspeed (both SOG and COG)
607
- if (rawGroundSpeed.ready) {
608
- residual.copyFrom(rawGroundSpeed);
609
- residual.substract(boatSpeedRefGround);
610
- residual.substract(smoothedCurrent);
611
- smoothedResidual.sample();
612
- }
613
- }
614
- }
615
- // Implicit fallback: attitude not ready — correctedBoatSpeed = raw STW, no correction
616
-
617
- PolarSmoother.send(app, plugin.id, [smoothedCurrent, smoothedResidual]);
618
-
619
- Polar.send(app, plugin.id, [correctedBoatSpeed]);
620
- }
621
-
622
- /**
623
- * Updates the correction table from the current smoothed inputs.
624
- * Reads assumeCurrent and minSpeed from module-level options/state;
625
- * silently returns if any required input is not yet ready.
626
- */
627
- function updateTable() {
628
- lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
629
- if (!smoothedAttitude.ready || !smoothedBoatSpeed.ready || !smoothedHeading.ready || !smoothedGroundSpeed.ready || (options.assumeCurrent && !smoothedCurrent.ready)) {
630
- table.lastUpdateResult = 'invalid';
631
- return;
632
- }
633
-
634
- // update correction table
635
- if (smoothedBoatSpeed.value > minSpeed) {
636
- table.update(smoothedBoatSpeed.value, smoothedAttitude.value?.roll, smoothedGroundSpeed, options.assumeCurrent ? smoothedCurrent : noCurrent, lrnBoatSpeed, smoothedHeading.value);
637
- } else {
638
- table.lastUpdateResult = 'waiting';
639
- }
640
-
641
- }
642
-
643
- /**
644
- * Loads or creates a correction table from disk.
645
- * If the file exists it is deserialized; otherwise a new table is created
646
- * with default dimensions and saved to disk.
647
- *
648
- * @param {Object} options - Plugin options (stability, tableName, and dimension defaults).
649
- * @param {string} filePath - Absolute path of the JSON file to read.
650
- * @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
651
- */
652
- function loadTable(options, filePath) {
653
- const stability = (options.stability !== undefined) ? options.stability : 6;
654
- let fileData = Table2D.readFromFile(filePath);
655
- let table;
656
- if (fileData) {
657
- table = CorrectionTable.fromJSON(fileData, stability);
658
- app.debug("Correction table loaded: " + (fileData.id || filePath));
659
- } else if (fs.existsSync(filePath)) {
660
- // File is present but could not be read — transient lock or parse error. Retry once.
661
- app.error(`Correction table file exists but could not be read, retrying: ${filePath}`);
662
- fileData = Table2D.readFromFile(filePath);
663
- if (fileData) {
664
- table = CorrectionTable.fromJSON(fileData, stability);
665
- app.debug("Correction table loaded on retry: " + (fileData.id || filePath));
666
- } else {
667
- app.error(`Correction table retry failed — starting with empty table. Disk file preserved: ${filePath}`);
668
- const name = options.tableName || 'correctionTable';
669
- const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
670
- const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
671
- table = new CorrectionTable(name, row, col, stability);
672
- }
673
- } else {
674
- const name = options.tableName || 'correctionTable';
675
- const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
676
- const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
677
- table = new CorrectionTable(name, row, col, stability);
678
- app.debug("Correction table created: " + name);
679
- }
680
- table.setDisplayAttributes({ label: table.id }); // Table2D API unchanged
681
- return table;
682
- }
683
-
684
- /**
685
- * Saves the correction table to disk as JSON (async, via library).
686
- *
687
- * @param {CorrectionTable} correctionTable - The correction table instance to save.
688
- * @param {string} filePath - Absolute path of the target JSON file.
689
- */
690
- function saveTable(correctionTable, filePath) {
691
- correctionTable.saveToFile(filePath);
692
- }
693
-
694
- /**
695
- * Saves the correction table synchronously. Used in stop() and when learning
696
- * is toggled off, to ensure the write completes before the process can exit
697
- * or the plugin be restarted.
698
- */
699
- function saveTableSync(correctionTable, filePath) {
700
- try {
701
- const data = JSON.stringify(correctionTable.toJSON(), null, 2);
702
- fs.writeFileSync(filePath, data);
703
- } catch (err) {
704
- app.error(`Error saving correction table: ${err.message}`);
705
- }
706
- }
707
-
708
- /**
709
- * Returns true if every cell in the table has N === 0 (no learned data).
710
- * Used to guard against overwriting a good on-disk file with an empty table.
711
- */
712
- function isTableEmpty(correctionTable) {
713
- return correctionTable.table.every(row => row.every(cell => cell.N === 0));
714
- }
715
-
716
- /**
717
- * Drains changedOptions into options and hot-applies each change where possible.
718
- * Flag changes take effect immediately since onChange reads from options.* directly.
719
- */
720
- function applyOptionChanges() {
721
- const changedKeys = Object.keys(changedOptions);
722
- for (const key of changedKeys) {
723
- const value = changedOptions[key];
724
- options[key] = value;
725
-
726
- if (key === 'stalenessDetection') {
727
- const sdVal = Boolean(value);
728
- for (const s of [smoothedHeading, smoothedAttitude, smoothedBoatSpeed, smoothedGroundSpeed, smoothedCurrent, smoothedResidual]) {
729
- if (s) s.stalenessDetection = sdVal;
730
- }
731
- // noCurrent is always excluded from staleness detection
732
- } else if (key === 'updateCorrectionTable') {
733
- if (!value && table && !isTableEmpty(table)) {
734
- // Learning switched off — persist current state before periodic saves stop
735
- saveTableSync(table, path.join(app.getDataDirPath(), table.id + '.json'));
736
- app.debug('Correction table saved: learning switched off');
737
- } else if (value && table && isTableEmpty(table)) {
738
- // Learning switched on and in-memory table is empty — reload from disk
739
- // to recover from a failed load at startup
740
- const filePath = path.join(app.getDataDirPath(), table.id + '.json');
741
- const fileData = Table2D.readFromFile(filePath);
742
- if (fileData) {
743
- const reloaded = CorrectionTable.fromJSON(fileData, options.stability || 7);
744
- reloaded.setDisplayAttributes({ label: reloaded.id });
745
- if (!isTableEmpty(reloaded)) {
746
- swapTable(reloaded);
747
- app.debug('Correction table reloaded from disk: learning switched on (was empty in memory)');
748
- }
749
- }
750
- }
751
- }
752
-
753
- // All other keys (sogFallback, estimateBoatSpeed, assumeCurrent,
754
- // stability, smootherClass, etc.) are read
755
- // directly from options.* so no extra action needed.
756
- if (key === 'estimateBoatSpeed' && !value && correctedBoatSpeed) {
757
- Polar.clear(app, plugin.id, [correctedBoatSpeed]);
758
- PolarSmoother.clear(app, plugin.id, [smoothedCurrent]);
759
- }
760
-
761
- delete changedOptions[key];
762
- }
763
-
764
- // Hot-apply smoother class / parameter changes to all user-tuned smoothers.
765
- // (noCurrent and smoothedCurrent keep their own fixed Kalman settings.)
766
- const SMOOTHER_KEYS = ['smootherClass', 'smootherTau', 'smootherTimeSpan', 'smootherSteadyState'];
767
- if (changedKeys.some(k => SMOOTHER_KEYS.includes(k))) {
768
- const { SmootherClass: SC, smootherOptions: so } = resolveSmootherConfig();
769
- for (const s of [smoothedHeading, smoothedBoatSpeed, smoothedGroundSpeed]) {
770
- if (s) { s.setSmootherClass(SC); s.setSmootherOptions(so); }
771
- }
772
- if (smoothedAttitude) { smoothedAttitude.setSmootherClass(SC); smoothedAttitude.setSmootherOptions(so); }
773
- }
774
-
775
- hasPendingChanges = false;
776
- saveOptions();
777
- }
778
-
779
- return plugin;
780
- };
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+
4
+ const {
5
+ SmoothedAngle,
6
+ SI,
7
+ MessageHandler,
8
+ Polar,
9
+ Reporter,
10
+ BaseSmoother,
11
+ MovingAverageSmoother,
12
+ ExponentialSmoother,
13
+ KalmanSmoother,
14
+ PolarSmoother,
15
+ createSmoothedPolar,
16
+ createSmoothedHandler,
17
+ Table2D
18
+ } = require('signalkutilities');
19
+
20
+ const { CorrectionTable } = require('./correctionTable.js');
21
+
22
+ module.exports = function (app) {
23
+
24
+ const DEFAULT_DIMS = { maxSpeed: 9, speedStep: 1, maxHeel: 32, heelStep: 8 };
25
+
26
+ let options = {};
27
+ let changedOptions = {};
28
+ let hasPendingChanges = false;
29
+ const defaultOptions = {
30
+ sogFallback: true,
31
+ estimateBoatSpeed: false,
32
+ updateCorrectionTable: true,
33
+ stability: 7,
34
+ assumeCurrent: false,
35
+ tableName: 'correctionTable',
36
+ configVersion: 2,
37
+ smootherClass: 'MovingAverageSmoother',
38
+ smootherTau: 3,
39
+ smootherTimeSpan: 5,
40
+ smootherSteadyState: 0.2,
41
+ showStatistics: false
42
+ };
43
+
44
+
45
+
46
+ function readOptions() {
47
+ const stored = app.readPluginOptions();
48
+ const raw = stored && stored.configuration ? stored.configuration : (stored || {});
49
+ // Strip embedded table — stored separately on disk
50
+ const { correctionTable: _drop, ...rest } = raw;
51
+ options = { ...defaultOptions, ...rest };
52
+ }
53
+
54
+ function saveOptions() {
55
+ app.savePluginOptions({ ...options }, (err) => {
56
+ if (err) app.error(`Error saving plugin options: ${err.message}`);
57
+ });
58
+ }
59
+
60
+ function saveTableName(name) {
61
+ options.tableName = name;
62
+ saveOptions();
63
+ }
64
+
65
+ /**
66
+ * Strips obsolete source-selection fields from the persisted config and
67
+ * writes it back if anything changed. Called once on every start().
68
+ */
69
+ function migrateConfig() {
70
+ const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication'];
71
+ const hadObsolete = obsoleteKeys.some(k => k in options);
72
+ for (const k of obsoleteKeys) delete options[k];
73
+ if (hadObsolete || (options.configVersion || 0) < 2) {
74
+ options.configVersion = 2;
75
+ saveOptions();
76
+ app.debug('Config migrated to v2: removed obsolete source-selection fields');
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Derives { SmootherClass, smootherOptions } from the current options.
82
+ * Enforces minimums so the smoother always has at least two observations for
83
+ * variance to be meaningful:
84
+ * - MovingAverageSmoother: timeSpan >= 2 s (≥ 2 samples at typical 1 Hz)
85
+ * - ExponentialSmoother: tau >= 1 s
86
+ * - KalmanSmoother: steadyState in [0.01, 0.99]
87
+ */
88
+ function resolveSmootherConfig() {
89
+ const cls = options.smootherClass || 'MovingAverageSmoother';
90
+ if (cls === 'ExponentialSmoother') {
91
+ return {
92
+ SmootherClass: ExponentialSmoother,
93
+ smootherOptions: { timeConstant: Math.max(1, Number(options.smootherTau) || 3) }
94
+ };
95
+ }
96
+ if (cls === 'KalmanSmoother') {
97
+ const K = Math.min(0.99, Math.max(0.01, Number(options.smootherSteadyState) || 0.2));
98
+ return {
99
+ SmootherClass: KalmanSmoother,
100
+ smootherOptions: { steadyState: K }
101
+ };
102
+ }
103
+ // Default: MovingAverageSmoother
104
+ return {
105
+ SmootherClass: MovingAverageSmoother,
106
+ smootherOptions: { timeSpan: Math.max(2, Number(options.smootherTimeSpan) || 5) }
107
+ };
108
+ }
109
+
110
+ function swapTable(newTable) {
111
+ table = newTable;
112
+ minSpeed = table.step[0] / 2;
113
+ if (reportFull) reportFull.setTables([table]);
114
+ lastSave = Date.now(); // explicit table operations already save; defer next periodic save
115
+ }
116
+
117
+ let isRunning = false;
118
+ let pluginStatus = 'Stopped';
119
+ let smoothedHeading = null;
120
+ let smoothedAttitude = null;
121
+ let rawCurrent = null;
122
+ let smoothedCurrent = null;
123
+ let smoothedBoatSpeed = null;
124
+ let correctedBoatSpeed = null;
125
+ let lrnBoatSpeed = null;
126
+ let boatSpeedRefGround = null;
127
+ let smoothedGroundSpeed = null;
128
+ let speedCorrection = null;
129
+ let residual = null;
130
+ let smoothedResidual = null;
131
+ let reportFull = null;
132
+ let table = null;
133
+
134
+ let rawHeading = null;
135
+ let rawAttitude = null;
136
+ let noCurrent = null;
137
+ let rawBoatSpeed = null;
138
+ let rawGroundSpeed = null;
139
+ let started = null;
140
+ let minSpeed = 0;
141
+ let lastSave = 0;
142
+ let lifecycleWarningMap = new Map();
143
+ let lifecycleWarnings = [];
144
+
145
+ function setLifecycleWarning(id, status, path) {
146
+ const safePath = path || 'unknown path';
147
+ const message = status === 'idle'
148
+ ? `Input ${id} is idle on ${safePath}; resubscribing`
149
+ : `Input ${id} is stale on ${safePath}`;
150
+ lifecycleWarningMap.set(id, {
151
+ id,
152
+ status,
153
+ path: safePath,
154
+ message,
155
+ updatedAt: Date.now()
156
+ });
157
+ lifecycleWarnings = Array.from(lifecycleWarningMap.values())
158
+ .sort((a, b) => b.updatedAt - a.updatedAt);
159
+ }
160
+
161
+ function clearLifecycleWarning(id) {
162
+ if (!lifecycleWarningMap.has(id)) return;
163
+ lifecycleWarningMap.delete(id);
164
+ lifecycleWarnings = Array.from(lifecycleWarningMap.values())
165
+ .sort((a, b) => b.updatedAt - a.updatedAt);
166
+ }
167
+
168
+ function buildLifecycleCallbacks(id, getPath, resubscribe) {
169
+ return {
170
+ onDelta: () => {
171
+ clearLifecycleWarning(id);
172
+ },
173
+ onStale: () => {
174
+ if (!isRunning) return;
175
+ const path = getPath();
176
+ app.debug(`[${plugin.id}] stale input ${id} on ${path}`);
177
+ setLifecycleWarning(id, 'stale', path);
178
+ },
179
+ onIdle: () => {
180
+ if (!isRunning) return;
181
+ const path = getPath();
182
+ app.debug(`[${plugin.id}] idle input ${id} on ${path}; resubscribing`);
183
+ setLifecycleWarning(id, 'idle', path);
184
+ try {
185
+ resubscribe();
186
+ } catch (e) {
187
+ app.debug(`[${plugin.id}] resubscribe failed for ${id}: ${e.message}`);
188
+ }
189
+ }
190
+ };
191
+ }
192
+
193
+ const plugin = {};
194
+ plugin.id = "SpeedAndCurrent";
195
+ plugin.name = "Speed and current";
196
+ plugin.description = "A plugin that uses sensor fusion to get boat speed, current and leeway.";
197
+
198
+ plugin.schema = {
199
+ type: "object",
200
+ description: "Speed and Current is configured through its own webapp. Open it from the Signal K app list.",
201
+ properties: {}
202
+ };
203
+
204
+
205
+ plugin.registerWithRouter = function (router) {
206
+ app.debug('registerWithRouter');
207
+ readOptions(); // pre-load so /api/settings works before start()
208
+
209
+ router.get('/api/report', (req, res) => {
210
+ if (!isRunning) {
211
+ res.status(503).json({ error: "Plugin is not running" });
212
+ } else {
213
+ const payload = reportFull.report();
214
+ payload.lifecycleWarnings = lifecycleWarnings;
215
+ res.json(payload);
216
+ }
217
+ });
218
+
219
+ router.get('/api/meta', (req, res) => {
220
+ if (!isRunning) {
221
+ res.status(503).json({ error: "Plugin is not running" });
222
+ } else {
223
+ res.json(reportFull.meta());
224
+ }
225
+ });
226
+
227
+
228
+
229
+ router.get('/api/status', (req, res) => {
230
+ res.json({ status: pluginStatus, isRunning, lifecycleWarnings });
231
+ });
232
+
233
+ // --- Settings API ---
234
+ router.get('/api/settings', (req, res) => {
235
+ res.json({ ...options, ...changedOptions });
236
+ });
237
+
238
+ router.put('/api/settings', (req, res) => {
239
+ const body = req.body;
240
+ if (!body || typeof body !== 'object') {
241
+ return res.status(400).json({ error: 'JSON body required' });
242
+ }
243
+ // Reject keys managed by the table manager
244
+ const blocked = ['correctionTable', 'tableName'];
245
+ for (const k of blocked) {
246
+ if (k in body) {
247
+ return res.status(400).json({ error: `Key '${k}' is managed via the table manager` });
248
+ }
249
+ }
250
+ changedOptions = { ...changedOptions, ...body };
251
+ hasPendingChanges = true;
252
+ res.json({ ...options, ...changedOptions });
253
+ });
254
+
255
+ // --- Correction Table Manager API ---
256
+
257
+ // List all table files in dataDir
258
+ router.get('/api/tables', (req, res) => {
259
+ const dataDir = app.getDataDirPath();
260
+ let files;
261
+ try { files = fs.readdirSync(dataDir).filter(f => f.endsWith('.json')); }
262
+ catch (e) { return res.json([]); }
263
+ const activeName = options.tableName || 'correctionTable';
264
+ const tables = [];
265
+ for (const file of files) {
266
+ try {
267
+ const data = Table2D.readFromFile(path.join(dataDir, file));
268
+ if (data && data.row && data.col && Array.isArray(data.table)) {
269
+ const name = file.replace(/\.json$/, '');
270
+ tables.push({ name, active: name === activeName });
271
+ }
272
+ } catch (e) { /* skip non-table files */ }
273
+ }
274
+ res.json(tables);
275
+ });
276
+
277
+ // Create a new table and hot-swap it
278
+ router.post('/api/tables/create', (req, res) => {
279
+ const body = req.body || {};
280
+ const name = (body.name || '').trim();
281
+ if (!name || !/^[\w-]+$/.test(name))
282
+ return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
283
+ const dims = ['maxSpeed', 'speedStep', 'maxHeel', 'heelStep'];
284
+ for (const f of dims) {
285
+ if (!Number.isFinite(body[f]) || body[f] <= 0)
286
+ return res.status(400).json({ error: `Invalid or missing field: ${f}` });
287
+ }
288
+ const row = { min: 0, max: body.maxSpeed, step: body.speedStep };
289
+ const col = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
290
+ const newTable = new CorrectionTable(name, row, col, options.stability || 7);
291
+ newTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
292
+ saveTable(newTable, path.join(app.getDataDirPath(), name + '.json'));
293
+ if (isRunning) swapTable(newTable);
294
+ saveTableName(name);
295
+ res.json({ name });
296
+ });
297
+
298
+ // Load a saved table and make it active
299
+ router.post('/api/tables/load', (req, res) => {
300
+ const body = req.body || {};
301
+ const name = (body.name || '').trim();
302
+ if (!name || !/^[\w-]+$/.test(name))
303
+ return res.status(400).json({ error: 'Invalid table name' });
304
+ const filePath = path.join(app.getDataDirPath(), name + '.json');
305
+ const fileData = Table2D.readFromFile(filePath);
306
+ if (!fileData) return res.status(404).json({ error: `Table '${name}' not found` });
307
+ const loadedTable = CorrectionTable.fromJSON(fileData, options.stability || 7);
308
+ loadedTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
309
+ if (isRunning) swapTable(loadedTable);
310
+ saveTableName(name);
311
+ res.json({ name });
312
+ });
313
+
314
+ // Copy active table under a new name and hot-swap to it
315
+ router.post('/api/tables/copy', (req, res) => {
316
+ if (!isRunning || !table) return res.status(503).json({ error: 'Plugin is not running' });
317
+ const body = req.body || {};
318
+ const newName = (body.newName || '').trim();
319
+ if (!newName || !/^[\w-]+$/.test(newName))
320
+ return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
321
+ const data = table.toJSON();
322
+ data.id = newName;
323
+ const copiedTable = CorrectionTable.fromJSON(data, options.stability || 7);
324
+ copiedTable.setDisplayAttributes({ label: newName }); // Table2D API unchanged
325
+ saveTable(copiedTable, path.join(app.getDataDirPath(), newName + '.json'));
326
+ swapTable(copiedTable);
327
+ saveTableName(newName);
328
+ res.json({ name: newName });
329
+ });
330
+
331
+ // Resize the active table (resamples onto new grid, preserves name)
332
+ router.post('/api/tables/resize', (req, res) => {
333
+ if (!isRunning || !table) return res.status(503).json({ error: 'Plugin is not running' });
334
+ const body = req.body || {};
335
+ const dims = ['maxSpeed', 'speedStep', 'maxHeel', 'heelStep'];
336
+ for (const f of dims) {
337
+ if (!Number.isFinite(body[f]) || body[f] <= 0)
338
+ return res.status(400).json({ error: `Invalid or missing field: ${f}` });
339
+ }
340
+ const newRow = { min: 0, max: body.maxSpeed, step: body.speedStep };
341
+ const newCol = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
342
+ const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, options.stability || 7, 1e-4);
343
+ resized.setDisplayAttributes({ label: resized.id }); // Table2D API unchanged
344
+ saveTable(resized, path.join(app.getDataDirPath(), resized.id + '.json'));
345
+ swapTable(resized);
346
+ res.json({ name: resized.id });
347
+ });
348
+
349
+ }
350
+
351
+ function setStatus(msg) {
352
+ pluginStatus = msg;
353
+ app.setPluginStatus(msg);
354
+ }
355
+
356
+ plugin.start = (settings) => {
357
+ setStatus('Starting');
358
+ app.debug("Starting");
359
+ lifecycleWarningMap = new Map();
360
+ lifecycleWarnings = [];
361
+ readOptions(); // pick up any saves since registerWithRouter ran
362
+ migrateConfig(); // strip obsolete fields from persisted config
363
+ const tableName = options.tableName || 'correctionTable';
364
+ const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
365
+ table = loadTable(options, tableFilePath);
366
+ minSpeed = table.step[0] / 2;
367
+
368
+ //#region Handler and Polar Initialization
369
+ const { SmootherClass, smootherOptions } = resolveSmootherConfig();
370
+
371
+ // heading
372
+ smoothedHeading = new SmoothedAngle(app, plugin.id, 'heading', 'navigation.headingTrue', {
373
+ angleRange: '0to2pi',
374
+ meta: { displayName: 'Heading', plane: 'Ground' },
375
+ SmootherClass,
376
+ smootherOptions,
377
+ ...buildLifecycleCallbacks(
378
+ 'heading.angle',
379
+ () => smoothedHeading?.handler?.path || 'navigation.headingTrue',
380
+ () => { smoothedHeading?.unsubscribe(); smoothedHeading?.subscribe(false, true); }
381
+ )
382
+ });
383
+ rawHeading = smoothedHeading.handler;
384
+
385
+ //attitude
386
+ smoothedAttitude = createSmoothedHandler({
387
+ app, pluginId: plugin.id,
388
+ id: 'attitude',
389
+ path: 'navigation.attitude',
390
+ subscribe: true,
391
+ SmootherClass,
392
+ smootherOptions,
393
+ ...buildLifecycleCallbacks(
394
+ 'attitude.smoothed',
395
+ () => smoothedAttitude?.handler?.path || 'navigation.attitude',
396
+ () => { smoothedAttitude?.unsubscribe(); smoothedAttitude?.subscribe(); }
397
+ )
398
+ });
399
+ rawAttitude = smoothedAttitude.handler;
400
+
401
+
402
+ // current
403
+ // send metadata for current
404
+ MessageHandler.setMeta(app, plugin.id, "environment.current.drift", {units: "m/s", type: "number", description: "Speed of the current"});
405
+ MessageHandler.setMeta(app, plugin.id, "environment.current.setTrue", { units: "rad", type: "number", description: "Direction of the current" });
406
+ rawCurrent = new Polar(app, plugin.id, "current");
407
+ rawCurrent.configureMagnitude("environment.current.drift");
408
+ rawCurrent.configureAngle("environment.current.setTrue");
409
+ rawCurrent.setMeta({ displayName: "Current", plane: "Ground" });
410
+ rawCurrent.setAngleRange('0to2pi');
411
+ smoothedCurrent = new PolarSmoother(rawCurrent, KalmanSmoother, { processVariance: 0.000001, measurementVariance: 0.01 }); // id auto-derived: 'current.smoothed'
412
+ smoothedCurrent.setAngleRange('0to2pi');
413
+ // Current should be initialised as no current
414
+ rawCurrent.setVectorValue({ x: 0, y: 0 });
415
+ // Strongly assume no current at start
416
+ smoothedCurrent.xSmoother.reset(0, 0.00000001);
417
+ smoothedCurrent.ySmoother.reset(0, 0.00000001);
418
+ // no current
419
+ noCurrent = createSmoothedPolar({
420
+ id: "noCurrent",
421
+ pathMagnitude: "environment.current.drift",
422
+ pathAngle: "environment.current.setTrue",
423
+ subscribe: false,
424
+ app,
425
+ pluginId: plugin.id,
426
+ SmootherClass: BaseSmoother,
427
+ smootherOptions: smootherOptions,
428
+ meta: { displayName: "NoCurrent", plane: "Ground" },
429
+ });
430
+ noCurrent.xSmoother.reset(0,0);
431
+ noCurrent.ySmoother.reset(0,0);
432
+ PolarSmoother.send(app, plugin.id, [noCurrent]);
433
+
434
+ MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
435
+ units: 'rad',
436
+ description: 'Leeway Angle',
437
+ displayUnits: {
438
+ category: 'angle'
439
+ }
440
+ });
441
+
442
+ // boatspeed
443
+ smoothedBoatSpeed = createSmoothedHandler({
444
+ app, pluginId: plugin.id,
445
+ id: 'boatSpeed',
446
+ path: 'navigation.speedThroughWater',
447
+ subscribe: true,
448
+ SmootherClass,
449
+ smootherOptions,
450
+ onDelta: () => {
451
+ clearLifecycleWarning('boatSpeed.smoothed');
452
+ // Drain any pending option changes before calculating
453
+ if (Object.keys(changedOptions).length) applyOptionChanges();
454
+
455
+ const wellUnderway = started < new Date() - 60 * 1000;
456
+
457
+ setStatus(wellUnderway ? 'Running' : 'Stabilizing');
458
+ if (options.estimateBoatSpeed) correct(wellUnderway);
459
+ if (options.updateCorrectionTable) {
460
+ if (wellUnderway) {
461
+ updateTable();
462
+ // Save correction table periodically (skip if table is empty to avoid overwriting good data on disk)
463
+ const now = Date.now();
464
+ if (now - lastSave > 60_000) {
465
+ if (!isTableEmpty(table)) {
466
+ saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
467
+ }
468
+ lastSave = now;
469
+ }
470
+ } else {
471
+ table.lastUpdateResult = 'stabilizing';
472
+ }
473
+ } else {
474
+ table.lastUpdateResult = null;
475
+ }
476
+ }
477
+ ,
478
+ onIdle: () => {
479
+ if (!isRunning) return;
480
+ const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
481
+ app.debug(`[${plugin.id}] idle input boatSpeed.smoothed on ${path}; resubscribing`);
482
+ setLifecycleWarning('boatSpeed.smoothed', 'idle', path);
483
+ smoothedBoatSpeed?.unsubscribe();
484
+ smoothedBoatSpeed?.subscribe();
485
+ },
486
+ onStale: () => {
487
+ if (!isRunning) return;
488
+ const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
489
+ app.debug(`[${plugin.id}] stale input boatSpeed.smoothed on ${path}`);
490
+ setLifecycleWarning('boatSpeed.smoothed', 'stale', path);
491
+ }
492
+ });
493
+ rawBoatSpeed = smoothedBoatSpeed.handler;
494
+
495
+ // Learning polar — used for updating the correction table
496
+ lrnBoatSpeed = new Polar(app, plugin.id, "lrnBoatSpeed");
497
+ lrnBoatSpeed.configureMagnitude("navigation.speedThroughWater");
498
+ lrnBoatSpeed.configureAngle("navigation.leewayAngle");
499
+ lrnBoatSpeed.setMeta({ displayName: "Learning boat speed", plane: "Boat" });
500
+ lrnBoatSpeed.setAngleRange('-piToPi');
501
+
502
+
503
+ // corrected boatspeed holds both corrected boatspeed and estimated leeway
504
+ correctedBoatSpeed = new Polar(app, plugin.id, "correctedBoatSpeed");
505
+ correctedBoatSpeed.configureMagnitude("navigation.speedThroughWater");
506
+ correctedBoatSpeed.configureAngle("navigation.leewayAngle");
507
+ correctedBoatSpeed.setMeta({ displayName: "Corrected boatspeed / Leeway", plane: "Boat" });
508
+
509
+ // boatspeed vector in ground frame, used for current estimation and residual calculation
510
+ boatSpeedRefGround = new Polar(app, plugin.id, "boatSpeedRefGround");
511
+ boatSpeedRefGround.setMeta({ displayName: "Boat speed over ground", plane: "Ground" });
512
+
513
+ // ground speed
514
+ smoothedGroundSpeed = createSmoothedPolar({
515
+ app, pluginId: plugin.id,
516
+ id: 'groundSpeed',
517
+ pathMagnitude: 'navigation.speedOverGround',
518
+ pathAngle: 'navigation.courseOverGroundTrue',
519
+ angleRange: '0to2pi',
520
+ meta: { displayName: 'Groundspeed', plane: 'Ground' },
521
+ SmootherClass,
522
+ smootherOptions,
523
+ ...buildLifecycleCallbacks(
524
+ 'groundSpeed.smoothed',
525
+ () => `${smoothedGroundSpeed?.polar?.pathMagnitude || 'navigation.speedOverGround'}, ${smoothedGroundSpeed?.polar?.pathAngle || 'navigation.courseOverGroundTrue'}`,
526
+ () => { smoothedGroundSpeed?.unsubscribe(); smoothedGroundSpeed?.subscribe(true, true); }
527
+ )
528
+ });
529
+ rawGroundSpeed = smoothedGroundSpeed.polar;
530
+
531
+ // correction vector
532
+ speedCorrection = new Polar(app, plugin.id, "speedCorrection");
533
+ speedCorrection.setMeta({ displayName: "Speed correction", plane: "Boat" });
534
+
535
+ // residual
536
+ residual = new Polar(app, plugin.id, "residual");
537
+ residual.setMeta({ displayName: "Residual", plane: "Ground" });
538
+ smoothedResidual = new PolarSmoother(residual, ExponentialSmoother, { tau: 30, timeSpan: 30 }); // id auto-derived: 'residual.smoothed'
539
+ smoothedResidual.setAngleRange('0to2pi');
540
+
541
+ //#endregion
542
+
543
+ //#region Reporting
544
+ reportFull = new Reporter();
545
+
546
+ if (options.estimateBoatSpeed) {
547
+ reportFull.addDelta(rawHeading);
548
+ reportFull.addAttitude(rawAttitude);
549
+ reportFull.addDelta(rawBoatSpeed);
550
+ reportFull.addPolar(speedCorrection);
551
+ reportFull.addPolar(boatSpeedRefGround);
552
+ reportFull.addPolar(correctedBoatSpeed);
553
+ reportFull.addPolar(rawGroundSpeed);
554
+ reportFull.addPolar(smoothedCurrent);
555
+ reportFull.addPolar(residual);
556
+ reportFull.addPolar(smoothedResidual);
557
+ }
558
+ // Smoothed learning inputs are always subscribed regardless of updateCorrectionTable,
559
+ // so always add them to the report — otherwise warnings never clear when
560
+ // learning was disabled at startup and later toggled on.
561
+ reportFull.addDelta(smoothedHeading);
562
+ reportFull.addAttitude(smoothedAttitude);
563
+ reportFull.addDelta(smoothedBoatSpeed);
564
+ reportFull.addPolar(smoothedGroundSpeed);
565
+ // smoothedCurrent is already added by the estimateBoatSpeed block when that is on;
566
+ // only add it here when estimateBoatSpeed is off, to avoid a duplicate entry.
567
+ if (options.assumeCurrent && !options.estimateBoatSpeed) {
568
+ reportFull.addPolar(smoothedCurrent);
569
+ }
570
+ reportFull.addTable(table);
571
+
572
+ //#endregion
573
+
574
+ isRunning = true;
575
+ started = Date.now();
576
+ lastSave = 0;
577
+ setStatus('Running');
578
+ app.debug("Running");
579
+
580
+ }
581
+
582
+ plugin.stop = () => {
583
+ return new Promise((resolve, reject) => {
584
+ try {
585
+ if (table && !isTableEmpty(table)) {
586
+ saveTableSync(table, path.join(app.getDataDirPath(), table.id + '.json'));
587
+ }
588
+ // Clear all active output paths from the SK bus before teardown.
589
+ if (smoothedCurrent) PolarSmoother.clear(app, plugin.id, [smoothedCurrent]);
590
+ if (options.estimateBoatSpeed && correctedBoatSpeed) Polar.clear(app, plugin.id, [correctedBoatSpeed]);
591
+ smoothedHeading = smoothedHeading?.terminate();
592
+ smoothedAttitude = smoothedAttitude?.terminate();
593
+ rawCurrent = rawCurrent?.terminate();
594
+ smoothedCurrent = smoothedCurrent?.terminate?.();
595
+ smoothedBoatSpeed = smoothedBoatSpeed?.terminate();
596
+ correctedBoatSpeed = correctedBoatSpeed?.terminate();
597
+ lrnBoatSpeed = lrnBoatSpeed?.terminate();
598
+ boatSpeedRefGround = boatSpeedRefGround?.terminate();
599
+ smoothedGroundSpeed = smoothedGroundSpeed?.terminate();
600
+ speedCorrection = speedCorrection?.terminate();
601
+ residual = residual?.terminate();
602
+ smoothedResidual = smoothedResidual?.terminate?.();
603
+ reportFull = null;
604
+ table = null;
605
+ rawHeading = null;
606
+ rawAttitude = null;
607
+ noCurrent = null;
608
+ rawBoatSpeed = null;
609
+ rawGroundSpeed = null;
610
+ started = null;
611
+ app.setPluginStatus("Stopped");
612
+ app.debug("Stopped");
613
+
614
+ pluginStatus = 'Stopped';
615
+ isRunning = false;
616
+ lifecycleWarningMap = new Map();
617
+ lifecycleWarnings = [];
618
+ resolve();
619
+ } catch (error) {
620
+ reject(error);
621
+ }
622
+ });
623
+ };
624
+
625
+ /**
626
+ * Corrects and publishes boat speed. Handles SOG fallback and missing-input
627
+ * silent fallback so that navigation.speedThroughWater is always written
628
+ * when estimateBoatSpeed is on, as long as any usable speed source exists.
629
+ *
630
+ * Priority order:
631
+ * 1. STW zero + sogFallback enabled + SOG available → publish SOG magnitude
632
+ * 2. attitude/heading not ready → publish raw STW unchanged (silent fallback)
633
+ * 3. All inputs ready → apply table correction; estimate current if wellUnderway
634
+ *
635
+ * @param {boolean} wellUnderway - Gates current estimation (requires 60 s settling).
636
+ */
637
+ function correct(wellUnderway) {
638
+ // copy the boatspeed delta to polar
639
+ correctedBoatSpeed.setVectorValue({ x: rawBoatSpeed.value, y: 0 });
640
+
641
+ speedCorrection.setVectorValue({ x: 0, y: 0 });
642
+
643
+ // Priority 1: SOG fallback when STW is stuck at zero
644
+ // (onChange only fires when STW is ready, so a missing STW cannot reach here)
645
+ if (options.sogFallback && rawGroundSpeed.ready && correctedBoatSpeed.magnitude === 0 && rawGroundSpeed.magnitude >= minSpeed) {
646
+ correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
647
+ }
648
+ else if (rawAttitude.ready) {
649
+ if (correctedBoatSpeed.magnitude > 0) {
650
+ const { correction, variance } = table.getCorrection(correctedBoatSpeed.magnitude, rawAttitude.value?.roll);
651
+ speedCorrection.setVectorValue(correction, variance);
652
+ correctedBoatSpeed.add(speedCorrection);
653
+ }
654
+ // Current estimation and residual also require heading (to rotate into ground frame).
655
+ // Also handle near-zero SOG where COG is unavailable: treat groundspeed as zero vector.
656
+ const sogHandler = rawGroundSpeed.magnitudeHandler;
657
+ const nearZeroGroundSpeed = !rawGroundSpeed.ready &&
658
+ sogHandler.ready &&
659
+ Number.isFinite(sogHandler.value) &&
660
+ sogHandler.value < 0.3;
661
+ if (rawHeading.ready && (rawGroundSpeed.ready || nearZeroGroundSpeed)) {
662
+ boatSpeedRefGround.copyFrom(correctedBoatSpeed);
663
+ boatSpeedRefGround.rotate(rawHeading.value);
664
+ // Current estimation gated by wellUnderway (smoothers need to settle first)
665
+ if (wellUnderway) {
666
+ if (rawGroundSpeed.ready) {
667
+ rawCurrent.copyFrom(rawGroundSpeed);
668
+ } else {
669
+ // COG unavailable but SOG is near-zero: boat is stationary, groundspeed treated as zero
670
+ rawCurrent.setVectorValue({ x: 0, y: 0 });
671
+ }
672
+ rawCurrent.substract(boatSpeedRefGround);
673
+ smoothedCurrent.sample();
674
+ }
675
+ // Residual requires full groundspeed (both SOG and COG)
676
+ if (rawGroundSpeed.ready) {
677
+ residual.copyFrom(rawGroundSpeed);
678
+ residual.substract(boatSpeedRefGround);
679
+ residual.substract(smoothedCurrent);
680
+ smoothedResidual.sample();
681
+ }
682
+ }
683
+ }
684
+ // Implicit fallback: attitude not ready — correctedBoatSpeed = raw STW, no correction
685
+
686
+ PolarSmoother.send(app, plugin.id, [smoothedCurrent, smoothedResidual]);
687
+
688
+ Polar.send(app, plugin.id, [correctedBoatSpeed]);
689
+ }
690
+
691
+ /**
692
+ * Updates the correction table from the current smoothed inputs.
693
+ * Reads assumeCurrent and minSpeed from module-level options/state;
694
+ * silently returns if any required input is not yet ready.
695
+ */
696
+ function updateTable() {
697
+ lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
698
+ if (!smoothedAttitude.ready || !smoothedBoatSpeed.ready || !smoothedHeading.ready || !smoothedGroundSpeed.ready || (options.assumeCurrent && !smoothedCurrent.ready)) {
699
+ table.lastUpdateResult = 'invalid';
700
+ return;
701
+ }
702
+
703
+ // update correction table
704
+ if (smoothedBoatSpeed.value > minSpeed) {
705
+ table.update(smoothedBoatSpeed.value, smoothedAttitude.value?.roll, smoothedGroundSpeed, options.assumeCurrent ? smoothedCurrent : noCurrent, lrnBoatSpeed, smoothedHeading.value);
706
+ } else {
707
+ table.lastUpdateResult = 'waiting';
708
+ }
709
+
710
+ }
711
+
712
+ /**
713
+ * Loads or creates a correction table from disk.
714
+ * If the file exists it is deserialized; otherwise a new table is created
715
+ * with default dimensions and saved to disk.
716
+ *
717
+ * @param {Object} options - Plugin options (stability, tableName, and dimension defaults).
718
+ * @param {string} filePath - Absolute path of the JSON file to read.
719
+ * @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
720
+ */
721
+ function loadTable(options, filePath) {
722
+ const stability = (options.stability !== undefined) ? options.stability : 6;
723
+ let fileData = Table2D.readFromFile(filePath);
724
+ let table;
725
+ if (fileData) {
726
+ table = CorrectionTable.fromJSON(fileData, stability);
727
+ app.debug("Correction table loaded: " + (fileData.id || filePath));
728
+ } else if (fs.existsSync(filePath)) {
729
+ // File is present but could not be read — transient lock or parse error. Retry once.
730
+ app.error(`Correction table file exists but could not be read, retrying: ${filePath}`);
731
+ fileData = Table2D.readFromFile(filePath);
732
+ if (fileData) {
733
+ table = CorrectionTable.fromJSON(fileData, stability);
734
+ app.debug("Correction table loaded on retry: " + (fileData.id || filePath));
735
+ } else {
736
+ app.error(`Correction table retry failed — starting with empty table. Disk file preserved: ${filePath}`);
737
+ const name = options.tableName || 'correctionTable';
738
+ const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
739
+ const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
740
+ table = new CorrectionTable(name, row, col, stability);
741
+ }
742
+ } else {
743
+ const name = options.tableName || 'correctionTable';
744
+ const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
745
+ const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
746
+ table = new CorrectionTable(name, row, col, stability);
747
+ app.debug("Correction table created: " + name);
748
+ }
749
+ table.setDisplayAttributes({ label: table.id }); // Table2D API unchanged
750
+ return table;
751
+ }
752
+
753
+ /**
754
+ * Saves the correction table to disk as JSON (async, via library).
755
+ *
756
+ * @param {CorrectionTable} correctionTable - The correction table instance to save.
757
+ * @param {string} filePath - Absolute path of the target JSON file.
758
+ */
759
+ function saveTable(correctionTable, filePath) {
760
+ correctionTable.saveToFile(filePath);
761
+ }
762
+
763
+ /**
764
+ * Saves the correction table synchronously. Used in stop() and when learning
765
+ * is toggled off, to ensure the write completes before the process can exit
766
+ * or the plugin be restarted.
767
+ */
768
+ function saveTableSync(correctionTable, filePath) {
769
+ try {
770
+ const data = JSON.stringify(correctionTable.toJSON(), null, 2);
771
+ fs.writeFileSync(filePath, data);
772
+ } catch (err) {
773
+ app.error(`Error saving correction table: ${err.message}`);
774
+ }
775
+ }
776
+
777
+ /**
778
+ * Returns true if every cell in the table has N === 0 (no learned data).
779
+ * Used to guard against overwriting a good on-disk file with an empty table.
780
+ */
781
+ function isTableEmpty(correctionTable) {
782
+ return correctionTable.table.every(row => row.every(cell => cell.N === 0));
783
+ }
784
+
785
+ /**
786
+ * Drains changedOptions into options and hot-applies each change where possible.
787
+ * Flag changes take effect immediately since onChange reads from options.* directly.
788
+ */
789
+ function applyOptionChanges() {
790
+ const changedKeys = Object.keys(changedOptions);
791
+ for (const key of changedKeys) {
792
+ const value = changedOptions[key];
793
+ options[key] = value;
794
+
795
+ if (key === 'updateCorrectionTable') {
796
+ if (!value && table && !isTableEmpty(table)) {
797
+ // Learning switched off — persist current state before periodic saves stop
798
+ saveTableSync(table, path.join(app.getDataDirPath(), table.id + '.json'));
799
+ app.debug('Correction table saved: learning switched off');
800
+ } else if (value && table && isTableEmpty(table)) {
801
+ // Learning switched on and in-memory table is empty — reload from disk
802
+ // to recover from a failed load at startup
803
+ const filePath = path.join(app.getDataDirPath(), table.id + '.json');
804
+ const fileData = Table2D.readFromFile(filePath);
805
+ if (fileData) {
806
+ const reloaded = CorrectionTable.fromJSON(fileData, options.stability || 7);
807
+ reloaded.setDisplayAttributes({ label: reloaded.id });
808
+ if (!isTableEmpty(reloaded)) {
809
+ swapTable(reloaded);
810
+ app.debug('Correction table reloaded from disk: learning switched on (was empty in memory)');
811
+ }
812
+ }
813
+ }
814
+ }
815
+
816
+ // All other keys (sogFallback, estimateBoatSpeed, assumeCurrent,
817
+ // stability, smootherClass, etc.) are read
818
+ // directly from options.* so no extra action needed.
819
+ if (key === 'estimateBoatSpeed' && !value && correctedBoatSpeed) {
820
+ Polar.clear(app, plugin.id, [correctedBoatSpeed]);
821
+ PolarSmoother.clear(app, plugin.id, [smoothedCurrent]);
822
+ }
823
+
824
+ delete changedOptions[key];
825
+ }
826
+
827
+ // Hot-apply smoother class / parameter changes to all user-tuned smoothers.
828
+ // (noCurrent and smoothedCurrent keep their own fixed Kalman settings.)
829
+ const SMOOTHER_KEYS = ['smootherClass', 'smootherTau', 'smootherTimeSpan', 'smootherSteadyState'];
830
+ if (changedKeys.some(k => SMOOTHER_KEYS.includes(k))) {
831
+ const { SmootherClass: SC, smootherOptions: so } = resolveSmootherConfig();
832
+ for (const s of [smoothedHeading, smoothedBoatSpeed, smoothedGroundSpeed]) {
833
+ if (s) { s.setSmootherClass(SC); s.setSmootherOptions(so); }
834
+ }
835
+ if (smoothedAttitude) { smoothedAttitude.setSmootherClass(SC); smoothedAttitude.setSmootherOptions(so); }
836
+ }
837
+
838
+ hasPendingChanges = false;
839
+ saveOptions();
840
+ }
841
+
842
+ return plugin;
843
+ };