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/correctionTable.js +1 -1
- package/index.js +405 -246
- package/package.json +2 -2
- package/public/TableRenderer.js +104 -210
- package/public/app.js +804 -447
- package/public/index.html +257 -96
- package/public/main.css +79 -158
- package/public/vectors.html +0 -30
- package/public/vectors.js +0 -145
package/index.js
CHANGED
|
@@ -1,25 +1,106 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
1
4
|
const {
|
|
2
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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('/
|
|
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('/
|
|
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
|
-
|
|
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
|
-
|
|
294
|
+
setStatus('Starting');
|
|
227
295
|
app.debug("Starting");
|
|
228
|
-
|
|
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
|
-
|
|
302
|
+
const { SmootherClass, smootherOptions } = resolveSmootherConfig();
|
|
232
303
|
|
|
233
304
|
// heading
|
|
234
|
-
smoothedHeading = new
|
|
235
|
-
|
|
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 =
|
|
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(
|
|
247
|
-
rawCurrent.
|
|
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(
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
316
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
346
|
-
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
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(
|
|
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(
|
|
363
|
-
stability = stability?.terminate?.(
|
|
364
|
-
smoothedAttitude = smoothedAttitude?.terminate(
|
|
365
|
-
rawCurrent = rawCurrent?.terminate(
|
|
366
|
-
smoothedCurrent = smoothedCurrent?.terminate?.(
|
|
367
|
-
smoothedBoatSpeed = smoothedBoatSpeed?.terminate(
|
|
368
|
-
correctedBoatSpeed = correctedBoatSpeed?.terminate(
|
|
369
|
-
boatSpeedRefGround = boatSpeedRefGround?.terminate(
|
|
370
|
-
smoothedGroundSpeed = smoothedGroundSpeed?.terminate(
|
|
371
|
-
speedCorrection = speedCorrection?.terminate(
|
|
372
|
-
residual = residual?.terminate(
|
|
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(
|
|
478
|
-
if (!
|
|
479
|
-
|
|
480
|
-
if (
|
|
481
|
-
if (
|
|
482
|
-
if (
|
|
483
|
-
if (
|
|
484
|
-
if (
|
|
485
|
-
if (
|
|
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
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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:
|
|
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(
|
|
540
|
-
|
|
541
|
-
|
|
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;
|