speedandcurrent 1.5.1 → 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,192 +131,198 @@ module.exports = function (app) {
50
131
 
51
132
  plugin.schema = {
52
133
  type: "object",
53
- properties: {
54
- startWithNewTable: {
55
- type: "boolean",
56
- title: "Start with new table",
57
- description: "Start with a new correction table (overrides existing table).",
58
- default: false
59
- },
60
- estimateBoatSpeed: {
61
- type: "boolean",
62
- title: "Estimate Boat Speed",
63
- description: "Enable estimation of boat speed.",
64
- default: false
65
- },
66
- updateCorrectionTable: {
67
- type: "boolean",
68
- title: "Update Correction Table",
69
- description: "Enable updating of the correction table.",
70
- default: true
71
- },
72
- stability: {
73
- type: "number",
74
- title: "Correction Table Stability",
75
- description: "Stability of the corrections (higher means that corrections are changed at a slower rate).",
76
- default: 7,
77
- minimum: 1,
78
- maximum: 20
79
- },
80
- assumeCurrent: {
81
- type: "boolean",
82
- title: "Assume Current",
83
- description: "Assume there is a current present when updating the correction table.",
84
- default: false
85
- },
86
- heelStep: {
87
- type: "number",
88
- title: "Step size for heel",
89
- description: "Correction table stepsize for heel.",
90
- default: 8
91
- },
92
- maxHeel: {
93
- type: "number",
94
- title: "Maximum heel in correction table",
95
- description: "Correction table maximum heel.",
96
- default: 32
97
- },
98
- speedStep: {
99
- type: "number",
100
- title: "Step size for speed",
101
- description: "Correction table stepsize for speed.",
102
- default: 1
103
- },
104
- maxSpeed: {
105
- type: "number",
106
- title: "Maximum speed in correction table",
107
- description: "Correction table maximum speed.",
108
- default: 9
109
- },
110
- headingSource: {
111
- type: "string",
112
- title: "Heading Source",
113
- description: "Source to use for navigation.heading (optional).",
114
- default: " "
115
- },
116
- boatSpeedSource: {
117
- type: "string",
118
- title: "Boat Speed Source",
119
- description: "Source to use for navigation.speedThroughWater (optional).",
120
- default: " "
121
- },
122
- COGSource: {
123
- type: "string",
124
- title: "Course over ground",
125
- description: "Source to use for navigation.courseOverGroundTrue (optional)",
126
- default: " "
127
- },
128
- SOGSource: {
129
- type: "string",
130
- title: "Ground Speed Source",
131
- description: "Source to use for navigation.speedOverGround (optional)",
132
- default: " "
133
- },
134
- attitudeSource: {
135
- type: "string",
136
- title: "Attitude Source",
137
- description: "Source to use for navigation.attitude (optional)",
138
- default: " "
139
- },
140
- preventDuplication: {
141
- type: "boolean",
142
- title: "Prevent duplication of boat speed",
143
- description: "Overwrite boat speed from sensor with corrected boat speed.",
144
- default: true
145
- }
146
- }
134
+ description: "Speed and Current is configured through its own webapp. Open it from the Signal K app list.",
135
+ properties: {}
147
136
  };
148
137
 
149
- plugin.uiSchema = {
150
- 'ui:order': [
151
- "estimateBoatSpeed",
152
- "updateCorrectionTable",
153
- "assumeCurrent",
154
- "startWithNewTable",
155
- "stability",
156
- "maxSpeed",
157
- "speedStep",
158
- "maxHeel",
159
- "heelStep",
160
- "headingSource",
161
- "boatSpeedSource",
162
- "COGSource",
163
- "SOGSource",
164
- "attitudeSource",
165
- "preventDuplication"
166
- ],
167
- startWithNewTable: {
168
- "ui:widget": "checkbox"
169
- },
170
- estimateBoatSpeed: {
171
- "ui:widget": "checkbox"
172
- },
173
- updateCorrectionTable: {
174
- "ui:widget": "checkbox"
175
- },
176
- stability: {
177
- "ui:widget": "updown"
178
- },
179
- assumeCurrent: {
180
- "ui:widget": "checkbox"
181
- },
182
- heelStep: {
183
- "ui:widget": "updown"
184
- },
185
- maxHeel: {
186
- "ui:widget": "updown"
187
- },
188
- speedStep: {
189
- "ui:widget": "updown"
190
- },
191
- maxSpeed: {
192
- "ui:widget": "updown"
193
- },
194
- mode: {
195
- "ui:widget": "select"
196
- },
197
- preventDuplication: {
198
- "ui:widget": "checkbox"
199
- }
200
- };
201
138
 
202
139
  plugin.registerWithRouter = function (router) {
203
140
  app.debug('registerWithRouter');
141
+ readOptions(); // pre-load so /api/settings works before start()
204
142
 
205
- router.get('/getResults', (req, res) => {
143
+ router.get('/api/report', (req, res) => {
206
144
  if (!isRunning) {
207
145
  res.status(503).json({ error: "Plugin is not running" });
208
- }
209
- else {
146
+ } else {
210
147
  res.json(reportFull.report());
211
148
  }
212
149
  });
213
150
 
214
- router.get('/getVectors', (req, res) => {
151
+ router.get('/api/meta', (req, res) => {
215
152
  if (!isRunning) {
216
153
  res.status(503).json({ error: "Plugin is not running" });
154
+ } else {
155
+ res.json(reportFull.meta());
217
156
  }
218
- else {
157
+ });
158
+
159
+ router.get('/getVectors', (req, res) => {
160
+ if (!isRunning) {
161
+ res.status(503).json({ error: "Plugin is not running" });
162
+ } else {
219
163
  res.json(reportVector.report());
220
164
  }
221
165
  });
222
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);
223
291
  }
224
292
 
225
293
  plugin.start = (settings) => {
226
- app.setPluginStatus("Starting");
294
+ setStatus('Starting');
227
295
  app.debug("Starting");
228
- 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);
229
300
 
230
301
  //#region Handler and Polar Initialization
231
- let smootherOptions = { timeConstant: 1, processVariance: 1, measurementVariance: 20, timeSpan: 5 };
302
+ const { SmootherClass, smootherOptions } = resolveSmootherConfig();
232
303
 
233
304
  // heading
234
- smoothedHeading = new SmoothedHeading(app, plugin.id, settings.headingSource, true, MovingAverageSmoother, smootherOptions);
235
- 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;
236
314
 
237
315
  //attitude
238
- 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
+ });
239
326
  rawAttitude = smoothedAttitude.handler;
240
327
 
241
328
 
@@ -243,12 +330,13 @@ module.exports = function (app) {
243
330
  // send metadata for current
244
331
  MessageHandler.setMeta(app, plugin.id, "environment.current.drift", {units: "m/s", type: "number", description: "Speed of the current"});
245
332
  MessageHandler.setMeta(app, plugin.id, "environment.current.setTrue", { units: "rad", type: "number", description: "Direction of the current" });
246
- rawCurrent = new Polar("current", "self.environment.current.drift", "self.environment.current.setTrue");
247
- 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" });
248
337
  rawCurrent.setAngleRange('0to2pi');
249
- 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'
250
339
  smoothedCurrent.setAngleRange('0to2pi');
251
- smoothedCurrent.setDisplayAttributes({ label: "Current", plane: "Ground" });
252
340
  // Current should be initialised as no current
253
341
  rawCurrent.setVectorValue({ x: 0, y: 0 });
254
342
  // Strongly assume no current at start
@@ -256,7 +344,7 @@ module.exports = function (app) {
256
344
  smoothedCurrent.ySmoother.reset(0, 0.00000001);
257
345
  // no current
258
346
  noCurrent = createSmoothedPolar({
259
- id: "boatSpeed",
347
+ id: "noCurrent",
260
348
  pathMagnitude: "self.environment.current.drift",
261
349
  pathAngle: "self.environment.current.setTrue",
262
350
  subscribe: false,
@@ -264,71 +352,86 @@ module.exports = function (app) {
264
352
  pluginId: plugin.id,
265
353
  SmootherClass: BaseSmoother,
266
354
  smootherOptions: smootherOptions,
267
- displayAttributes: { label: "NoCurrent", plane: "Ground" },
355
+ meta: { displayName: "NoCurrent", plane: "Ground" },
268
356
  });
269
357
  noCurrent.xSmoother.reset(0,0);
270
358
  noCurrent.ySmoother.reset(0,0);
271
359
  PolarSmoother.send(app, plugin.id, [noCurrent]);
272
360
 
273
361
 
274
- // boat speed
275
- smoothedBoatSpeed = new SmoothedSpeedThroughWater(app, plugin.id, settings.boatSpeedSource, true, MovingAverageSmoother, smootherOptions, !settings.preventDuplication);
276
- rawBoatSpeed = smoothedBoatSpeed.polar;
277
- correctedBoatSpeed = new Polar("correctedBoatSpeed", "navigation.speedThroughWater", "navigation.leewayAngle");
278
- correctedBoatSpeed.setDisplayAttributes({ label: "corrected boat speed", plane: "Boat" });
279
- boatSpeedRefGround = new Polar("boatSpeedRefGround");
280
- boatSpeedRefGround.setDisplayAttributes("Boat speed over ground", "Ground");
281
-
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
+
282
379
  // ground speed
283
- 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
+ });
284
392
  rawGroundSpeed = smoothedGroundSpeed.polar;
285
393
 
286
394
  // correction vector
287
- speedCorrection = new Polar("speedCorrection");
288
- speedCorrection.setDisplayAttributes("speed correction", "Boat");
289
- speedCorrection.setDisplayAttributes({ label: "speed correction", plane: "Boat" });
395
+ speedCorrection = new Polar(app, plugin.id, "speedCorrection");
396
+ speedCorrection.setMeta({ displayName: "Speed correction", plane: "Boat" });
290
397
 
291
398
  // residual
292
- residual = new Polar("residual");
293
- residual.setDisplayAttributes({ label: "residual", plane: "Ground" });
399
+ residual = new Polar(app, plugin.id, "residual");
400
+ residual.setMeta({ displayName: "Residual", plane: "Ground" });
294
401
 
295
402
  //#endregion
296
403
 
297
404
  //#region Reporting
298
405
  reportFull = new Reporter();
299
406
 
300
- if (settings.estimateBoatSpeed) {
407
+ if (options.estimateBoatSpeed) {
301
408
  reportFull.addDelta(rawHeading);
302
409
  reportFull.addAttitude(rawAttitude);
303
410
  reportFull.addPolar(rawBoatSpeed);
304
411
  reportFull.addPolar(speedCorrection);
412
+ reportFull.addPolar(boatSpeedRefGround);
305
413
  reportFull.addPolar(correctedBoatSpeed);
306
414
  reportFull.addPolar(rawGroundSpeed);
307
415
  reportFull.addPolar(smoothedCurrent);
308
416
  }
309
- if (settings.updateCorrectionTable) {
417
+ if (options.updateCorrectionTable) {
310
418
  reportFull.addDelta(smoothedHeading);
311
419
  reportFull.addAttitude(smoothedAttitude);
312
420
  reportFull.addPolar(smoothedBoatSpeed);
313
421
  reportFull.addPolar(smoothedGroundSpeed);
314
-
315
- if (settings.assumeCurrent) {
316
- reportFull.addPolar(boatSpeedRefGround);
317
- reportFull.addPolar(smoothedCurrent);
318
- }
319
- reportFull.addPolar(speedCorrection);
422
+ if (options.assumeCurrent) {
423
+ reportFull.addPolar(smoothedCurrent);
424
+ }
320
425
  reportFull.addPolar(residual);
321
426
  }
322
427
  reportFull.addTable(table);
323
428
 
324
429
  // Make reporting object for webApp
325
-
326
-
327
430
  reportVector = new Reporter();
328
431
  reportVector.addDelta(rawHeading);
329
432
  reportVector.addPolar(residual);
330
433
  reportVector.addPolar(speedCorrection);
331
- if (settings.assumeCurrent) reportVector.addPolar(smoothedCurrent);
434
+ if (options.assumeCurrent) reportVector.addPolar(smoothedCurrent);
332
435
  reportVector.addPolar(correctedBoatSpeed);
333
436
  reportVector.addPolar(rawBoatSpeed);
334
437
  reportVector.addPolar(rawGroundSpeed);
@@ -336,20 +439,34 @@ module.exports = function (app) {
336
439
 
337
440
  isRunning = true;
338
441
  started = new Date();
339
- app.setPluginStatus("Running");
442
+ setStatus('Running');
340
443
  app.debug("Running");
341
444
 
342
445
  let lastSave = 0;
343
446
  smoothedBoatSpeed.onChange = () => {
447
+ // Drain any pending option changes before calculating
448
+ if (Object.keys(changedOptions).length) applyOptionChanges();
449
+
344
450
  const wellUnderway = started < new Date() - 60 * 1000;
345
- const minSpeed = SI.fromKnots(settings.speedStep / 2);
346
- if (settings.estimateBoatSpeed) correct(wellUnderway);
347
- if (settings.updateCorrectionTable && wellUnderway) {
348
- updateTable(settings.assumeCurrent, minSpeed);
349
- // Save correction table periodically
451
+ const minSpeed = SI.fromKnots(options.speedStep / 2);
452
+
453
+ if (options.sogFallback && fallingBackToSog(minSpeed)) {
454
+ setStatus('Falling back to Speed Over Ground');
455
+ app.debug("Falling back");
456
+ return;
457
+ }
458
+ if (!wellUnderway) {
459
+ setStatus('Stabilizing');
460
+ } else {
461
+ setStatus('Running');
462
+ }
463
+ if (options.estimateBoatSpeed) correct(wellUnderway);
464
+ if (options.updateCorrectionTable && wellUnderway) {
465
+ updateTable(options.assumeCurrent, minSpeed);
466
+ // Save correction table periodically
350
467
  const now = new Date();
351
468
  if (now - lastSave > 5 * 1000) {
352
- saveTable(settings, table);
469
+ saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
353
470
  lastSave = now;
354
471
  }
355
472
  }
@@ -359,17 +476,17 @@ module.exports = function (app) {
359
476
  plugin.stop = () => {
360
477
  return new Promise((resolve, reject) => {
361
478
  try {
362
- smoothedHeading = smoothedHeading?.terminate(app);
363
- stability = stability?.terminate?.(app);
364
- smoothedAttitude = smoothedAttitude?.terminate(app);
365
- rawCurrent = rawCurrent?.terminate(app);
366
- smoothedCurrent = smoothedCurrent?.terminate?.(app);
367
- smoothedBoatSpeed = smoothedBoatSpeed?.terminate(app);
368
- correctedBoatSpeed = correctedBoatSpeed?.terminate(app);
369
- boatSpeedRefGround = boatSpeedRefGround?.terminate(app);
370
- smoothedGroundSpeed = smoothedGroundSpeed?.terminate(app);
371
- speedCorrection = speedCorrection?.terminate(app);
372
- 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();
373
490
  reportFull = null;
374
491
  reportVector = null;
375
492
  table = null;
@@ -382,6 +499,7 @@ module.exports = function (app) {
382
499
  app.setPluginStatus("Stopped");
383
500
  app.debug("Stopped");
384
501
 
502
+ pluginStatus = 'Stopped';
385
503
  isRunning = false;
386
504
  resolve();
387
505
  } catch (error) {
@@ -390,6 +508,15 @@ module.exports = function (app) {
390
508
  });
391
509
  };
392
510
 
511
+ function fallingBackToSog( minSpeed) {
512
+ if (!rawBoatSpeed.stale && rawBoatSpeed.magnitude > 0) return false;
513
+ if (rawGroundSpeed.stale || rawGroundSpeed.magnitude < minSpeed) return false;
514
+ // Fallback to SOG
515
+ correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
516
+ Polar.send(app, plugin.id, [correctedBoatSpeed]);
517
+ return true;
518
+ }
519
+
393
520
  /**
394
521
  * Estimates and applies corrections to boat speed, leeway, and current.
395
522
  *
@@ -474,17 +601,15 @@ module.exports = function (app) {
474
601
  * @param {Object} col - The expected column configuration ({ min, max, step }).
475
602
  * @returns {boolean} True if the table matches the configuration, false otherwise.
476
603
  */
477
- function enforceConsistancy(options, row, col) {
478
- if (!options?.correctionTable) return false;
479
- table = options.correctionTable;
480
- if (!table) return false;
481
- if (!table.row) return false;
482
- if (table.row.min != row.min) return false;
483
- if (table.row.max != row.max) return false;
484
- if (table.row.step != row.step) return false;
485
- if (table.col.min != col.min) return false;
486
- if (table.col.max != col.max) return false;
487
- 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;
488
613
  return true;
489
614
  }
490
615
 
@@ -498,30 +623,21 @@ module.exports = function (app) {
498
623
  * @param {Object} options - The plugin options containing correction table settings and parameters.
499
624
  * @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
500
625
  */
501
- function loadTable(options) {
502
- const row = { min: 0, max: SI.fromKnots(options.maxSpeed), step: SI.fromKnots(options.speedStep) };
503
- const col = { min: -SI.fromDegrees(options.maxHeel), max: SI.fromDegrees(options.maxHeel), step: SI.fromDegrees(options.heelStep) };
504
- let table;
626
+ function loadTable(options, filePath) {
505
627
  const stability = (options.stability !== undefined) ? options.stability : 6;
506
- const VAR_FLOOR = 1e-4;
507
- if (enforceConsistancy(options, row, col) && !options.startWithNewTable) {
508
- table = CorrectionTable.fromJSON(options.correctionTable, stability);
509
- app.debug("Correction table loaded");
510
- }
511
- else if (options.correctionTable && !options.startWithNewTable) {
512
- // Resample existing table to new dimensions conservatively (N=0, diag covariance with floor)
513
- table = CorrectionTable.resampleFromJSON(options.correctionTable, row, col, stability, VAR_FLOOR);
514
- app.debug("Correction table migrated to new dimensions");
515
- options.startWithNewTable = false;
516
- 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));
517
633
  } else {
518
- table = new CorrectionTable("correctionTable", row, col, stability);
519
- app.debug("Correction table created");
520
- // doStartFresh is not user-settable, so no need to reset it here
521
- options.startWithNewTable = false;
522
- 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);
523
639
  }
524
- table.setDisplayAttributes({ label: "correction table" });
640
+ table.setDisplayAttributes({ label: table.id }); // Table2D API unchanged
525
641
  return table;
526
642
  }
527
643
 
@@ -536,9 +652,52 @@ module.exports = function (app) {
536
652
  * @param {CorrectionTable} correctionTable - The correction table instance to save.
537
653
  * @returns {Date} The date and time when the table was saved.
538
654
  */
539
- function saveTable(options, correctionTable) {
540
- options.correctionTable = correctionTable.toJSON();
541
- 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();
542
701
  }
543
702
 
544
703
  return plugin;