speedandcurrent 1.6.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -59
- package/correctionTable.js +1 -1
- package/index.js +392 -264
- package/package.json +5 -2
- package/public/TableRenderer.js +114 -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,202 +131,198 @@ module.exports = function (app) {
|
|
|
50
131
|
|
|
51
132
|
plugin.schema = {
|
|
52
133
|
type: "object",
|
|
53
|
-
|
|
54
|
-
|
|
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('/
|
|
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('/
|
|
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
|
-
|
|
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
|
-
|
|
294
|
+
setStatus('Starting');
|
|
237
295
|
app.debug("Starting");
|
|
238
|
-
|
|
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
|
-
|
|
302
|
+
const { SmootherClass, smootherOptions } = resolveSmootherConfig();
|
|
242
303
|
|
|
243
304
|
// heading
|
|
244
|
-
smoothedHeading = new
|
|
245
|
-
|
|
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 =
|
|
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(
|
|
257
|
-
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" });
|
|
258
337
|
rawCurrent.setAngleRange('0to2pi');
|
|
259
|
-
smoothedCurrent = new PolarSmoother(
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
326
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
356
|
-
|
|
357
|
-
if (
|
|
358
|
-
|
|
451
|
+
const minSpeed = table.step[0] / 2;
|
|
452
|
+
|
|
453
|
+
if (options.sogFallback && fallingBackToSog(minSpeed)) {
|
|
454
|
+
setStatus('Falling back to Speed Over Ground');
|
|
359
455
|
app.debug("Falling back");
|
|
360
|
-
|
|
456
|
+
return;
|
|
361
457
|
}
|
|
362
458
|
if (!wellUnderway) {
|
|
363
|
-
|
|
459
|
+
setStatus('Stabilizing');
|
|
460
|
+
} else {
|
|
461
|
+
setStatus('Running');
|
|
364
462
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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(
|
|
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(
|
|
385
|
-
stability = stability?.terminate?.(
|
|
386
|
-
smoothedAttitude = smoothedAttitude?.terminate(
|
|
387
|
-
rawCurrent = rawCurrent?.terminate(
|
|
388
|
-
smoothedCurrent = smoothedCurrent?.terminate?.(
|
|
389
|
-
smoothedBoatSpeed = smoothedBoatSpeed?.terminate(
|
|
390
|
-
correctedBoatSpeed = correctedBoatSpeed?.terminate(
|
|
391
|
-
boatSpeedRefGround = boatSpeedRefGround?.terminate(
|
|
392
|
-
smoothedGroundSpeed = smoothedGroundSpeed?.terminate(
|
|
393
|
-
speedCorrection = speedCorrection?.terminate(
|
|
394
|
-
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();
|
|
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(
|
|
509
|
-
if (!
|
|
510
|
-
|
|
511
|
-
if (
|
|
512
|
-
if (
|
|
513
|
-
if (
|
|
514
|
-
if (
|
|
515
|
-
if (
|
|
516
|
-
if (
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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:
|
|
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(
|
|
571
|
-
|
|
572
|
-
|
|
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;
|