speedandcurrent 1.6.0 → 1.7.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/index.js CHANGED
@@ -1,25 +1,106 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+
1
4
  const {
2
- SmoothedHeading,
3
- SmoothedAttitude,
4
- SmoothedSpeedThroughWater,
5
- SmoothedGroundSpeed,
5
+ SmoothedAngle,
6
6
  SI,
7
7
  MessageHandler,
8
8
  Polar,
9
9
  Reporter,
10
10
  BaseSmoother,
11
11
  MovingAverageSmoother,
12
+ ExponentialSmoother,
12
13
  KalmanSmoother,
13
14
  PolarSmoother,
14
15
  createSmoothedPolar,
15
- createSmoothedHandler
16
+ createSmoothedHandler,
17
+ Table2D
16
18
  } = require('signalkutilities');
17
19
 
18
20
  const { CorrectionTable } = require('./correctionTable.js');
19
21
 
20
22
  module.exports = function (app) {
21
23
 
24
+ const DEFAULT_DIMS = { maxSpeed: 9, speedStep: 1, maxHeel: 32, heelStep: 8 };
25
+
26
+ let options = {};
27
+ let changedOptions = {};
28
+ const defaultOptions = {
29
+ sogFallback: true,
30
+ estimateBoatSpeed: false,
31
+ updateCorrectionTable: true,
32
+ stability: 7,
33
+ assumeCurrent: false,
34
+ headingSource: ' ',
35
+ boatSpeedSource: ' ',
36
+ COGSource: ' ',
37
+ SOGSource: ' ',
38
+ attitudeSource: ' ',
39
+ preventDuplication: true,
40
+ tableName: 'correctionTable',
41
+ smootherClass: 'MovingAverageSmoother',
42
+ smootherTau: 3,
43
+ smootherTimeSpan: 5,
44
+ smootherSteadyState: 0.2,
45
+ showStatistics: false
46
+ };
47
+
48
+ function readOptions() {
49
+ const stored = app.readPluginOptions();
50
+ const raw = stored && stored.configuration ? stored.configuration : (stored || {});
51
+ // Strip embedded table — stored separately on disk
52
+ const { correctionTable: _drop, ...rest } = raw;
53
+ options = { ...defaultOptions, ...rest };
54
+ }
55
+
56
+ function saveOptions() {
57
+ app.savePluginOptions({ ...options }, (err) => {
58
+ if (err) app.error(`Error saving plugin options: ${err.message}`);
59
+ });
60
+ }
61
+
62
+ function saveTableName(name) {
63
+ options.tableName = name;
64
+ saveOptions();
65
+ }
66
+
67
+ /**
68
+ * Derives { SmootherClass, smootherOptions } from the current options.
69
+ * Enforces minimums so the smoother always has at least two observations for
70
+ * variance to be meaningful:
71
+ * - MovingAverageSmoother: timeSpan >= 2 s (≥ 2 samples at typical 1 Hz)
72
+ * - ExponentialSmoother: tau >= 1 s
73
+ * - KalmanSmoother: steadyState in [0.01, 0.99]
74
+ */
75
+ function resolveSmootherConfig() {
76
+ const cls = options.smootherClass || 'MovingAverageSmoother';
77
+ if (cls === 'ExponentialSmoother') {
78
+ return {
79
+ SmootherClass: ExponentialSmoother,
80
+ smootherOptions: { timeConstant: Math.max(1, Number(options.smootherTau) || 3) }
81
+ };
82
+ }
83
+ if (cls === 'KalmanSmoother') {
84
+ const K = Math.min(0.99, Math.max(0.01, Number(options.smootherSteadyState) || 0.2));
85
+ return {
86
+ SmootherClass: KalmanSmoother,
87
+ smootherOptions: { steadyState: K }
88
+ };
89
+ }
90
+ // Default: MovingAverageSmoother
91
+ return {
92
+ SmootherClass: MovingAverageSmoother,
93
+ smootherOptions: { timeSpan: Math.max(2, Number(options.smootherTimeSpan) || 5) }
94
+ };
95
+ }
96
+
97
+ function swapTable(newTable) {
98
+ table = newTable;
99
+ if (reportFull) reportFull.setTables([table]);
100
+ }
101
+
22
102
  let isRunning = false;
103
+ let pluginStatus = 'Stopped';
23
104
  let smoothedHeading = null;
24
105
  let stability = null;
25
106
  let smoothedAttitude = null;
@@ -50,202 +131,198 @@ module.exports = function (app) {
50
131
 
51
132
  plugin.schema = {
52
133
  type: "object",
53
- properties: {
54
- sogFallback: {
55
- type: "boolean",
56
- title: "SOG fallback",
57
- description: "Allow fallback to SOG when paddlewheel is blocked.",
58
- default: true
59
- },
60
- startWithNewTable: {
61
- type: "boolean",
62
- title: "Start with new table",
63
- description: "Start with a new correction table (overrides existing table).",
64
- default: false
65
- },
66
- estimateBoatSpeed: {
67
- type: "boolean",
68
- title: "Estimate Boat Speed",
69
- description: "Enable estimation of boat speed.",
70
- default: false
71
- },
72
- updateCorrectionTable: {
73
- type: "boolean",
74
- title: "Update Correction Table",
75
- description: "Enable updating of the correction table.",
76
- default: true
77
- },
78
- stability: {
79
- type: "number",
80
- title: "Correction Table Stability",
81
- description: "Stability of the corrections (higher means that corrections are changed at a slower rate).",
82
- default: 7,
83
- minimum: 1,
84
- maximum: 20
85
- },
86
- assumeCurrent: {
87
- type: "boolean",
88
- title: "Assume Current",
89
- description: "Assume there is a current present when updating the correction table.",
90
- default: false
91
- },
92
- heelStep: {
93
- type: "number",
94
- title: "Step size for heel",
95
- description: "Correction table stepsize for heel.",
96
- default: 8
97
- },
98
- maxHeel: {
99
- type: "number",
100
- title: "Maximum heel in correction table",
101
- description: "Correction table maximum heel.",
102
- default: 32
103
- },
104
- speedStep: {
105
- type: "number",
106
- title: "Step size for speed",
107
- description: "Correction table stepsize for speed.",
108
- default: 1
109
- },
110
- maxSpeed: {
111
- type: "number",
112
- title: "Maximum speed in correction table",
113
- description: "Correction table maximum speed.",
114
- default: 9
115
- },
116
- headingSource: {
117
- type: "string",
118
- title: "Heading Source",
119
- description: "Source to use for navigation.heading (optional).",
120
- default: " "
121
- },
122
- boatSpeedSource: {
123
- type: "string",
124
- title: "Boat Speed Source",
125
- description: "Source to use for navigation.speedThroughWater (optional).",
126
- default: " "
127
- },
128
- COGSource: {
129
- type: "string",
130
- title: "Course over ground",
131
- description: "Source to use for navigation.courseOverGroundTrue (optional)",
132
- default: " "
133
- },
134
- SOGSource: {
135
- type: "string",
136
- title: "Ground Speed Source",
137
- description: "Source to use for navigation.speedOverGround (optional)",
138
- default: " "
139
- },
140
- attitudeSource: {
141
- type: "string",
142
- title: "Attitude Source",
143
- description: "Source to use for navigation.attitude (optional)",
144
- default: " "
145
- },
146
- preventDuplication: {
147
- type: "boolean",
148
- title: "Prevent duplication of boat speed",
149
- description: "Overwrite boat speed from sensor with corrected boat speed.",
150
- default: true
151
- }
152
- }
134
+ description: "Speed and Current is configured through its own webapp. Open it from the Signal K app list.",
135
+ properties: {}
153
136
  };
154
137
 
155
- plugin.uiSchema = {
156
- 'ui:order': [
157
- "sogFallback",
158
- "estimateBoatSpeed",
159
- "updateCorrectionTable",
160
- "assumeCurrent",
161
- "startWithNewTable",
162
- "stability",
163
- "maxSpeed",
164
- "speedStep",
165
- "maxHeel",
166
- "heelStep",
167
- "headingSource",
168
- "boatSpeedSource",
169
- "COGSource",
170
- "SOGSource",
171
- "attitudeSource",
172
- "preventDuplication"
173
- ],
174
- sogFallback: {
175
- "ui:widget": "checkbox"
176
- },
177
- startWithNewTable: {
178
- "ui:widget": "checkbox"
179
- },
180
- estimateBoatSpeed: {
181
- "ui:widget": "checkbox"
182
- },
183
- updateCorrectionTable: {
184
- "ui:widget": "checkbox"
185
- },
186
- stability: {
187
- "ui:widget": "updown"
188
- },
189
- assumeCurrent: {
190
- "ui:widget": "checkbox"
191
- },
192
- heelStep: {
193
- "ui:widget": "updown"
194
- },
195
- maxHeel: {
196
- "ui:widget": "updown"
197
- },
198
- speedStep: {
199
- "ui:widget": "updown"
200
- },
201
- maxSpeed: {
202
- "ui:widget": "updown"
203
- },
204
- mode: {
205
- "ui:widget": "select"
206
- },
207
- preventDuplication: {
208
- "ui:widget": "checkbox"
209
- }
210
- };
211
138
 
212
139
  plugin.registerWithRouter = function (router) {
213
140
  app.debug('registerWithRouter');
141
+ readOptions(); // pre-load so /api/settings works before start()
214
142
 
215
- router.get('/getResults', (req, res) => {
143
+ router.get('/api/report', (req, res) => {
216
144
  if (!isRunning) {
217
145
  res.status(503).json({ error: "Plugin is not running" });
218
- }
219
- else {
146
+ } else {
220
147
  res.json(reportFull.report());
221
148
  }
222
149
  });
223
150
 
224
- router.get('/getVectors', (req, res) => {
151
+ router.get('/api/meta', (req, res) => {
225
152
  if (!isRunning) {
226
153
  res.status(503).json({ error: "Plugin is not running" });
154
+ } else {
155
+ res.json(reportFull.meta());
227
156
  }
228
- else {
157
+ });
158
+
159
+ router.get('/getVectors', (req, res) => {
160
+ if (!isRunning) {
161
+ res.status(503).json({ error: "Plugin is not running" });
162
+ } else {
229
163
  res.json(reportVector.report());
230
164
  }
231
165
  });
232
166
 
167
+ router.get('/api/status', (req, res) => {
168
+ res.json({ status: pluginStatus, isRunning });
169
+ });
170
+
171
+ // --- Settings API ---
172
+ router.get('/api/settings', (req, res) => {
173
+ res.json({ ...options, ...changedOptions });
174
+ });
175
+
176
+ router.put('/api/settings', (req, res) => {
177
+ const body = req.body;
178
+ if (!body || typeof body !== 'object') {
179
+ return res.status(400).json({ error: 'JSON body required' });
180
+ }
181
+ // Reject keys managed by the table manager
182
+ const blocked = ['correctionTable', 'tableName'];
183
+ for (const k of blocked) {
184
+ if (k in body) {
185
+ return res.status(400).json({ error: `Key '${k}' is managed via the table manager` });
186
+ }
187
+ }
188
+ changedOptions = { ...changedOptions, ...body };
189
+ res.json({ ...options, ...changedOptions });
190
+ });
191
+
192
+ // --- Correction Table Manager API ---
193
+
194
+ // List all table files in dataDir
195
+ router.get('/api/tables', (req, res) => {
196
+ const dataDir = app.getDataDirPath();
197
+ let files;
198
+ try { files = fs.readdirSync(dataDir).filter(f => f.endsWith('.json')); }
199
+ catch (e) { return res.json([]); }
200
+ const activeName = options.tableName || 'correctionTable';
201
+ const tables = [];
202
+ for (const file of files) {
203
+ try {
204
+ const data = Table2D.readFromFile(path.join(dataDir, file));
205
+ if (data && data.row && data.col && Array.isArray(data.table)) {
206
+ const name = file.replace(/\.json$/, '');
207
+ tables.push({ name, active: name === activeName });
208
+ }
209
+ } catch (e) { /* skip non-table files */ }
210
+ }
211
+ res.json(tables);
212
+ });
213
+
214
+ // Create a new table and hot-swap it
215
+ router.post('/api/tables/create', (req, res) => {
216
+ const body = req.body || {};
217
+ const name = (body.name || '').trim();
218
+ if (!name || !/^[\w-]+$/.test(name))
219
+ return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
220
+ const dims = ['maxSpeed', 'speedStep', 'maxHeel', 'heelStep'];
221
+ for (const f of dims) {
222
+ if (!Number.isFinite(body[f]) || body[f] <= 0)
223
+ return res.status(400).json({ error: `Invalid or missing field: ${f}` });
224
+ }
225
+ const row = { min: 0, max: body.maxSpeed, step: body.speedStep };
226
+ const col = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
227
+ const newTable = new CorrectionTable(name, row, col, options.stability || 7);
228
+ newTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
229
+ saveTable(newTable, path.join(app.getDataDirPath(), name + '.json'));
230
+ if (isRunning) swapTable(newTable);
231
+ saveTableName(name);
232
+ res.json({ name });
233
+ });
234
+
235
+ // Load a saved table and make it active
236
+ router.post('/api/tables/load', (req, res) => {
237
+ const body = req.body || {};
238
+ const name = (body.name || '').trim();
239
+ if (!name || !/^[\w-]+$/.test(name))
240
+ return res.status(400).json({ error: 'Invalid table name' });
241
+ const filePath = path.join(app.getDataDirPath(), name + '.json');
242
+ const fileData = Table2D.readFromFile(filePath);
243
+ if (!fileData) return res.status(404).json({ error: `Table '${name}' not found` });
244
+ const loadedTable = CorrectionTable.fromJSON(fileData, options.stability || 7);
245
+ loadedTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
246
+ if (isRunning) swapTable(loadedTable);
247
+ saveTableName(name);
248
+ res.json({ name });
249
+ });
250
+
251
+ // Copy active table under a new name and hot-swap to it
252
+ router.post('/api/tables/copy', (req, res) => {
253
+ if (!isRunning || !table) return res.status(503).json({ error: 'Plugin is not running' });
254
+ const body = req.body || {};
255
+ const newName = (body.newName || '').trim();
256
+ if (!newName || !/^[\w-]+$/.test(newName))
257
+ return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
258
+ const data = table.toJSON();
259
+ data.id = newName;
260
+ const copiedTable = CorrectionTable.fromJSON(data, options.stability || 7);
261
+ copiedTable.setDisplayAttributes({ label: newName }); // Table2D API unchanged
262
+ saveTable(copiedTable, path.join(app.getDataDirPath(), newName + '.json'));
263
+ swapTable(copiedTable);
264
+ saveTableName(newName);
265
+ res.json({ name: newName });
266
+ });
267
+
268
+ // Resize the active table (resamples onto new grid, preserves name)
269
+ router.post('/api/tables/resize', (req, res) => {
270
+ if (!isRunning || !table) return res.status(503).json({ error: 'Plugin is not running' });
271
+ const body = req.body || {};
272
+ const dims = ['maxSpeed', 'speedStep', 'maxHeel', 'heelStep'];
273
+ for (const f of dims) {
274
+ if (!Number.isFinite(body[f]) || body[f] <= 0)
275
+ return res.status(400).json({ error: `Invalid or missing field: ${f}` });
276
+ }
277
+ const newRow = { min: 0, max: body.maxSpeed, step: body.speedStep };
278
+ const newCol = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
279
+ const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, options.stability || 7, 1e-4);
280
+ resized.setDisplayAttributes({ label: resized.id }); // Table2D API unchanged
281
+ saveTable(resized, path.join(app.getDataDirPath(), resized.id + '.json'));
282
+ swapTable(resized);
283
+ res.json({ name: resized.id });
284
+ });
285
+
286
+ }
287
+
288
+ function setStatus(msg) {
289
+ pluginStatus = msg;
290
+ app.setPluginStatus(msg);
233
291
  }
234
292
 
235
293
  plugin.start = (settings) => {
236
- app.setPluginStatus("Starting");
294
+ setStatus('Starting');
237
295
  app.debug("Starting");
238
- table = loadTable(settings);
296
+ readOptions(); // pick up any saves since registerWithRouter ran
297
+ const tableName = options.tableName || 'correctionTable';
298
+ const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
299
+ table = loadTable(options, tableFilePath);
239
300
 
240
301
  //#region Handler and Polar Initialization
241
- let smootherOptions = { timeConstant: 1, processVariance: 1, measurementVariance: 20, timeSpan: 5 };
302
+ const { SmootherClass, smootherOptions } = resolveSmootherConfig();
242
303
 
243
304
  // heading
244
- smoothedHeading = new SmoothedHeading(app, plugin.id, settings.headingSource, true, MovingAverageSmoother, smootherOptions);
245
- rawHeading = smoothedHeading.polar.angleHandler;
305
+ smoothedHeading = new SmoothedAngle(app, plugin.id, 'heading', 'navigation.headingTrue', {
306
+ source: options.headingSource,
307
+ passOn: true,
308
+ angleRange: '0to2pi',
309
+ meta: { displayName: 'Heading', plane: 'Ground' },
310
+ SmootherClass,
311
+ smootherOptions
312
+ });
313
+ rawHeading = smoothedHeading.handler;
246
314
 
247
315
  //attitude
248
- smoothedAttitude = new SmoothedAttitude(app, plugin.id, settings.attitudeSource, true, MovingAverageSmoother, smootherOptions);
316
+ smoothedAttitude = createSmoothedHandler({
317
+ app, pluginId: plugin.id,
318
+ id: 'attitude',
319
+ path: 'navigation.attitude',
320
+ source: options.attitudeSource,
321
+ passOn: true,
322
+ subscribe: true,
323
+ SmootherClass,
324
+ smootherOptions
325
+ });
249
326
  rawAttitude = smoothedAttitude.handler;
250
327
 
251
328
 
@@ -253,12 +330,13 @@ module.exports = function (app) {
253
330
  // send metadata for current
254
331
  MessageHandler.setMeta(app, plugin.id, "environment.current.drift", {units: "m/s", type: "number", description: "Speed of the current"});
255
332
  MessageHandler.setMeta(app, plugin.id, "environment.current.setTrue", { units: "rad", type: "number", description: "Direction of the current" });
256
- rawCurrent = new Polar("current", "self.environment.current.drift", "self.environment.current.setTrue");
257
- rawCurrent.setDisplayAttributes({ label: "current", plane: "Ground" });
333
+ rawCurrent = new Polar(app, plugin.id, "current");
334
+ rawCurrent.configureMagnitude("self.environment.current.drift");
335
+ rawCurrent.configureAngle("self.environment.current.setTrue");
336
+ rawCurrent.setMeta({ displayName: "Current", plane: "Ground" });
258
337
  rawCurrent.setAngleRange('0to2pi');
259
- smoothedCurrent = new PolarSmoother("currentDamped", rawCurrent, KalmanSmoother, { processVariance: 0.000001, measurementVariance: 0.01 });
338
+ smoothedCurrent = new PolarSmoother(rawCurrent, KalmanSmoother, { processVariance: 0.000001, measurementVariance: 0.01 }); // id auto-derived: 'current.smoothed'
260
339
  smoothedCurrent.setAngleRange('0to2pi');
261
- smoothedCurrent.setDisplayAttributes({ label: "Current", plane: "Ground" });
262
340
  // Current should be initialised as no current
263
341
  rawCurrent.setVectorValue({ x: 0, y: 0 });
264
342
  // Strongly assume no current at start
@@ -266,7 +344,7 @@ module.exports = function (app) {
266
344
  smoothedCurrent.ySmoother.reset(0, 0.00000001);
267
345
  // no current
268
346
  noCurrent = createSmoothedPolar({
269
- id: "boatSpeed",
347
+ id: "noCurrent",
270
348
  pathMagnitude: "self.environment.current.drift",
271
349
  pathAngle: "self.environment.current.setTrue",
272
350
  subscribe: false,
@@ -274,71 +352,86 @@ module.exports = function (app) {
274
352
  pluginId: plugin.id,
275
353
  SmootherClass: BaseSmoother,
276
354
  smootherOptions: smootherOptions,
277
- displayAttributes: { label: "NoCurrent", plane: "Ground" },
355
+ meta: { displayName: "NoCurrent", plane: "Ground" },
278
356
  });
279
357
  noCurrent.xSmoother.reset(0,0);
280
358
  noCurrent.ySmoother.reset(0,0);
281
359
  PolarSmoother.send(app, plugin.id, [noCurrent]);
282
360
 
283
361
 
284
- // boat speed
285
- smoothedBoatSpeed = new SmoothedSpeedThroughWater(app, plugin.id, settings.boatSpeedSource, true, MovingAverageSmoother, smootherOptions, !settings.preventDuplication);
286
- rawBoatSpeed = smoothedBoatSpeed.polar;
287
- correctedBoatSpeed = new Polar("correctedBoatSpeed", "navigation.speedThroughWater", "navigation.leewayAngle");
288
- correctedBoatSpeed.setDisplayAttributes({ label: "corrected boat speed", plane: "Boat" });
289
- boatSpeedRefGround = new Polar("boatSpeedRefGround");
290
- boatSpeedRefGround.setDisplayAttributes("Boat speed over ground", "Ground");
291
-
362
+ // boat speed — STW magnitude is optionally forwarded; leeway angle passOn is always false
363
+ const rawBoatSpeedPolar = new Polar(app, plugin.id, 'boatSpeed');
364
+ rawBoatSpeedPolar.configureMagnitude('navigation.speedThroughWater', options.boatSpeedSource, !options.preventDuplication);
365
+ rawBoatSpeedPolar.configureAngle('navigation.leewayAngle', options.boatSpeedSource, false);
366
+ rawBoatSpeedPolar.setMeta({ displayName: 'Boat speed', plane: 'Boat' });
367
+ rawBoatSpeedPolar.setAngleRange('-piToPi');
368
+ rawBoatSpeedPolar.subscribe(true, false);
369
+ smoothedBoatSpeed = new PolarSmoother(rawBoatSpeedPolar, SmootherClass, smootherOptions);
370
+ rawBoatSpeedPolar.onChange = () => { smoothedBoatSpeed.sample(); };
371
+ rawBoatSpeed = rawBoatSpeedPolar;
372
+ correctedBoatSpeed = new Polar(app, plugin.id, "correctedBoatSpeed");
373
+ correctedBoatSpeed.configureMagnitude("navigation.speedThroughWater");
374
+ correctedBoatSpeed.configureAngle("navigation.leewayAngle");
375
+ correctedBoatSpeed.setMeta({ displayName: "Corrected boatspeed / Leeway", plane: "Boat" });
376
+ boatSpeedRefGround = new Polar(app, plugin.id, "boatSpeedRefGround");
377
+ boatSpeedRefGround.setMeta({ displayName: "Boat speed over ground", plane: "Ground" });
378
+
292
379
  // ground speed
293
- smoothedGroundSpeed = new SmoothedGroundSpeed(app, plugin.id, settings.SOGSource, true, MovingAverageSmoother, smootherOptions, !settings.preventDuplication);
380
+ smoothedGroundSpeed = createSmoothedPolar({
381
+ app, pluginId: plugin.id,
382
+ id: 'groundSpeed',
383
+ pathMagnitude: 'navigation.speedOverGround',
384
+ pathAngle: 'navigation.courseOverGroundTrue',
385
+ source: options.SOGSource,
386
+ passOn: true,
387
+ angleRange: '0to2pi',
388
+ meta: { displayName: 'Groundspeed', plane: 'Ground' },
389
+ SmootherClass,
390
+ smootherOptions
391
+ });
294
392
  rawGroundSpeed = smoothedGroundSpeed.polar;
295
393
 
296
394
  // correction vector
297
- speedCorrection = new Polar("speedCorrection");
298
- speedCorrection.setDisplayAttributes("speed correction", "Boat");
299
- speedCorrection.setDisplayAttributes({ label: "speed correction", plane: "Boat" });
395
+ speedCorrection = new Polar(app, plugin.id, "speedCorrection");
396
+ speedCorrection.setMeta({ displayName: "Speed correction", plane: "Boat" });
300
397
 
301
398
  // residual
302
- residual = new Polar("residual");
303
- residual.setDisplayAttributes({ label: "residual", plane: "Ground" });
399
+ residual = new Polar(app, plugin.id, "residual");
400
+ residual.setMeta({ displayName: "Residual", plane: "Ground" });
304
401
 
305
402
  //#endregion
306
403
 
307
404
  //#region Reporting
308
405
  reportFull = new Reporter();
309
406
 
310
- if (settings.estimateBoatSpeed) {
407
+ if (options.estimateBoatSpeed) {
311
408
  reportFull.addDelta(rawHeading);
312
409
  reportFull.addAttitude(rawAttitude);
313
410
  reportFull.addPolar(rawBoatSpeed);
314
411
  reportFull.addPolar(speedCorrection);
412
+ reportFull.addPolar(boatSpeedRefGround);
315
413
  reportFull.addPolar(correctedBoatSpeed);
316
414
  reportFull.addPolar(rawGroundSpeed);
317
415
  reportFull.addPolar(smoothedCurrent);
318
416
  }
319
- if (settings.updateCorrectionTable) {
417
+ if (options.updateCorrectionTable) {
320
418
  reportFull.addDelta(smoothedHeading);
321
419
  reportFull.addAttitude(smoothedAttitude);
322
420
  reportFull.addPolar(smoothedBoatSpeed);
323
421
  reportFull.addPolar(smoothedGroundSpeed);
324
-
325
- if (settings.assumeCurrent) {
326
- reportFull.addPolar(boatSpeedRefGround);
327
- reportFull.addPolar(smoothedCurrent);
328
- }
329
- reportFull.addPolar(speedCorrection);
422
+ if (options.assumeCurrent) {
423
+ reportFull.addPolar(smoothedCurrent);
424
+ }
330
425
  reportFull.addPolar(residual);
331
426
  }
332
427
  reportFull.addTable(table);
333
428
 
334
429
  // Make reporting object for webApp
335
-
336
-
337
430
  reportVector = new Reporter();
338
431
  reportVector.addDelta(rawHeading);
339
432
  reportVector.addPolar(residual);
340
433
  reportVector.addPolar(speedCorrection);
341
- if (settings.assumeCurrent) reportVector.addPolar(smoothedCurrent);
434
+ if (options.assumeCurrent) reportVector.addPolar(smoothedCurrent);
342
435
  reportVector.addPolar(correctedBoatSpeed);
343
436
  reportVector.addPolar(rawBoatSpeed);
344
437
  reportVector.addPolar(rawGroundSpeed);
@@ -346,32 +439,34 @@ module.exports = function (app) {
346
439
 
347
440
  isRunning = true;
348
441
  started = new Date();
349
- app.setPluginStatus("Running");
442
+ setStatus('Running');
350
443
  app.debug("Running");
351
444
 
352
445
  let lastSave = 0;
353
446
  smoothedBoatSpeed.onChange = () => {
447
+ // Drain any pending option changes before calculating
448
+ if (Object.keys(changedOptions).length) applyOptionChanges();
449
+
354
450
  const wellUnderway = started < new Date() - 60 * 1000;
355
- const minSpeed = SI.fromKnots(settings.speedStep / 2);
356
-
357
- if (settings.sogFallback && fallingBackToSog( minSpeed) ){
358
- app.setPluginStatus("Falling back to Speed Over Ground");
451
+ const minSpeed = SI.fromKnots(options.speedStep / 2);
452
+
453
+ if (options.sogFallback && fallingBackToSog(minSpeed)) {
454
+ setStatus('Falling back to Speed Over Ground');
359
455
  app.debug("Falling back");
360
- return;
456
+ return;
361
457
  }
362
458
  if (!wellUnderway) {
363
- app.setPluginStatus("Stabilizing");
459
+ setStatus('Stabilizing');
460
+ } else {
461
+ setStatus('Running');
364
462
  }
365
- else {
366
- app.setPluginStatus("Running");
367
- }
368
- if (settings.estimateBoatSpeed) correct(wellUnderway);
369
- if (settings.updateCorrectionTable && wellUnderway) {
370
- updateTable(settings.assumeCurrent, minSpeed);
371
- // Save correction table periodically
463
+ if (options.estimateBoatSpeed) correct(wellUnderway);
464
+ if (options.updateCorrectionTable && wellUnderway) {
465
+ updateTable(options.assumeCurrent, minSpeed);
466
+ // Save correction table periodically
372
467
  const now = new Date();
373
468
  if (now - lastSave > 5 * 1000) {
374
- saveTable(settings, table);
469
+ saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
375
470
  lastSave = now;
376
471
  }
377
472
  }
@@ -381,17 +476,17 @@ module.exports = function (app) {
381
476
  plugin.stop = () => {
382
477
  return new Promise((resolve, reject) => {
383
478
  try {
384
- smoothedHeading = smoothedHeading?.terminate(app);
385
- stability = stability?.terminate?.(app);
386
- smoothedAttitude = smoothedAttitude?.terminate(app);
387
- rawCurrent = rawCurrent?.terminate(app);
388
- smoothedCurrent = smoothedCurrent?.terminate?.(app);
389
- smoothedBoatSpeed = smoothedBoatSpeed?.terminate(app);
390
- correctedBoatSpeed = correctedBoatSpeed?.terminate(app);
391
- boatSpeedRefGround = boatSpeedRefGround?.terminate(app);
392
- smoothedGroundSpeed = smoothedGroundSpeed?.terminate(app);
393
- speedCorrection = speedCorrection?.terminate(app);
394
- residual = residual?.terminate(app);
479
+ smoothedHeading = smoothedHeading?.terminate();
480
+ stability = stability?.terminate?.();
481
+ smoothedAttitude = smoothedAttitude?.terminate();
482
+ rawCurrent = rawCurrent?.terminate();
483
+ smoothedCurrent = smoothedCurrent?.terminate?.();
484
+ smoothedBoatSpeed = smoothedBoatSpeed?.terminate();
485
+ correctedBoatSpeed = correctedBoatSpeed?.terminate();
486
+ boatSpeedRefGround = boatSpeedRefGround?.terminate();
487
+ smoothedGroundSpeed = smoothedGroundSpeed?.terminate();
488
+ speedCorrection = speedCorrection?.terminate();
489
+ residual = residual?.terminate();
395
490
  reportFull = null;
396
491
  reportVector = null;
397
492
  table = null;
@@ -404,6 +499,7 @@ module.exports = function (app) {
404
499
  app.setPluginStatus("Stopped");
405
500
  app.debug("Stopped");
406
501
 
502
+ pluginStatus = 'Stopped';
407
503
  isRunning = false;
408
504
  resolve();
409
505
  } catch (error) {
@@ -505,17 +601,15 @@ module.exports = function (app) {
505
601
  * @param {Object} col - The expected column configuration ({ min, max, step }).
506
602
  * @returns {boolean} True if the table matches the configuration, false otherwise.
507
603
  */
508
- function enforceConsistancy(options, row, col) {
509
- if (!options?.correctionTable) return false;
510
- table = options.correctionTable;
511
- if (!table) return false;
512
- if (!table.row) return false;
513
- if (table.row.min != row.min) return false;
514
- if (table.row.max != row.max) return false;
515
- if (table.row.step != row.step) return false;
516
- if (table.col.min != col.min) return false;
517
- if (table.col.max != col.max) return false;
518
- if (table.col.step != col.step) return false;
604
+ function enforceConsistancy(data, row, col) {
605
+ if (!data) return false;
606
+ if (!data.row) return false;
607
+ if (data.row.min != row.min) return false;
608
+ if (data.row.max != row.max) return false;
609
+ if (data.row.step != row.step) return false;
610
+ if (data.col.min != col.min) return false;
611
+ if (data.col.max != col.max) return false;
612
+ if (data.col.step != col.step) return false;
519
613
  return true;
520
614
  }
521
615
 
@@ -529,30 +623,21 @@ module.exports = function (app) {
529
623
  * @param {Object} options - The plugin options containing correction table settings and parameters.
530
624
  * @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
531
625
  */
532
- function loadTable(options) {
533
- const row = { min: 0, max: SI.fromKnots(options.maxSpeed), step: SI.fromKnots(options.speedStep) };
534
- const col = { min: -SI.fromDegrees(options.maxHeel), max: SI.fromDegrees(options.maxHeel), step: SI.fromDegrees(options.heelStep) };
535
- let table;
626
+ function loadTable(options, filePath) {
536
627
  const stability = (options.stability !== undefined) ? options.stability : 6;
537
- const VAR_FLOOR = 1e-4;
538
- if (enforceConsistancy(options, row, col) && !options.startWithNewTable) {
539
- table = CorrectionTable.fromJSON(options.correctionTable, stability);
540
- app.debug("Correction table loaded");
541
- }
542
- else if (options.correctionTable && !options.startWithNewTable) {
543
- // Resample existing table to new dimensions conservatively (N=0, diag covariance with floor)
544
- table = CorrectionTable.resampleFromJSON(options.correctionTable, row, col, stability, VAR_FLOOR);
545
- app.debug("Correction table migrated to new dimensions");
546
- options.startWithNewTable = false;
547
- saveTable(options, table);
628
+ const fileData = Table2D.readFromFile(filePath);
629
+ let table;
630
+ if (fileData) {
631
+ table = CorrectionTable.fromJSON(fileData, stability);
632
+ app.debug("Correction table loaded: " + (fileData.id || filePath));
548
633
  } else {
549
- table = new CorrectionTable("correctionTable", row, col, stability);
550
- app.debug("Correction table created");
551
- // doStartFresh is not user-settable, so no need to reset it here
552
- options.startWithNewTable = false;
553
- saveTable(options, table);
634
+ const name = options.tableName || 'correctionTable';
635
+ const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
636
+ const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
637
+ table = new CorrectionTable(name, row, col, stability);
638
+ app.debug("Correction table created: " + name);
554
639
  }
555
- table.setDisplayAttributes({ label: "correction table" });
640
+ table.setDisplayAttributes({ label: table.id }); // Table2D API unchanged
556
641
  return table;
557
642
  }
558
643
 
@@ -567,9 +652,52 @@ module.exports = function (app) {
567
652
  * @param {CorrectionTable} correctionTable - The correction table instance to save.
568
653
  * @returns {Date} The date and time when the table was saved.
569
654
  */
570
- function saveTable(options, correctionTable) {
571
- options.correctionTable = correctionTable.toJSON();
572
- app.savePluginOptions(options, () => { /*app.debug("Correction table saved")*/; });
655
+ function saveTable(correctionTable, filePath) {
656
+ correctionTable.saveToFile(filePath);
657
+ }
658
+
659
+ /**
660
+ * Drains changedOptions into options and hot-applies each change where possible.
661
+ * Source changes are applied in-place on existing handlers; flag changes take
662
+ * effect immediately since onChange reads from options.* directly.
663
+ */
664
+ function applyOptionChanges() {
665
+ const changedKeys = Object.keys(changedOptions);
666
+ for (const key of changedKeys) {
667
+ const value = changedOptions[key];
668
+ options[key] = value;
669
+
670
+ // Hot-apply source changes by mutating handler references in-place
671
+ if (smoothedHeading && key === 'headingSource') {
672
+ smoothedHeading.handler.source = value;
673
+ } else if (smoothedAttitude && key === 'attitudeSource') {
674
+ smoothedAttitude.handler.source = value;
675
+ } else if (smoothedBoatSpeed && key === 'boatSpeedSource') {
676
+ smoothedBoatSpeed.polar.magnitudeHandler.source = value;
677
+ } else if (smoothedGroundSpeed && key === 'SOGSource') {
678
+ smoothedGroundSpeed.polar.magnitudeHandler.source = value;
679
+ } else if (key === 'preventDuplication') {
680
+ if (smoothedBoatSpeed) smoothedBoatSpeed.polar.magnitudeHandler.passOn = !value;
681
+ }
682
+ // All other keys (sogFallback, estimateBoatSpeed, updateCorrectionTable,
683
+ // assumeCurrent, stability, startWithNewTable, COGSource) are read
684
+ // directly from options.* so no extra action needed.
685
+
686
+ delete changedOptions[key];
687
+ }
688
+
689
+ // Hot-apply smoother class / parameter changes to all user-tuned smoothers.
690
+ // (noCurrent and smoothedCurrent keep their own fixed Kalman settings.)
691
+ const SMOOTHER_KEYS = ['smootherClass', 'smootherTau', 'smootherTimeSpan', 'smootherSteadyState'];
692
+ if (changedKeys.some(k => SMOOTHER_KEYS.includes(k))) {
693
+ const { SmootherClass: SC, smootherOptions: so } = resolveSmootherConfig();
694
+ for (const s of [smoothedHeading, smoothedBoatSpeed, smoothedGroundSpeed]) {
695
+ if (s) { s.setSmootherClass(SC); s.setSmootherOptions(so); }
696
+ }
697
+ if (smoothedAttitude) { smoothedAttitude.setSmootherClass(SC); smoothedAttitude.setSmootherOptions(so); }
698
+ }
699
+
700
+ saveOptions();
573
701
  }
574
702
 
575
703
  return plugin;