speedandcurrent 1.3.0 → 1.5.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/LICENSE +1 -1
- package/README.md +179 -78
- package/correctionTable.js +149 -6
- package/index.js +371 -468
- package/leakyExtremes.js +98 -0
- package/package.json +12 -7
- package/public/TableRenderer.js +135 -83
- package/public/app.js +214 -55
- package/public/icon.png +0 -0
- package/public/index.html +20 -5
- package/public/main.css +2 -2
- package/CHANGELOG.md +0 -28
- package/correctionModel.png +0 -0
- package/currentModel.png +0 -0
- package/models.md +0 -26
- package/public/surfaceplot.html +0 -78
package/index.js
CHANGED
|
@@ -1,55 +1,87 @@
|
|
|
1
|
-
const {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
const {
|
|
2
|
+
SmoothedHeading,
|
|
3
|
+
SmoothedAttitude,
|
|
4
|
+
SmoothedSpeedThroughWater,
|
|
5
|
+
SmoothedGroundSpeed,
|
|
6
|
+
SI,
|
|
7
|
+
MessageHandler,
|
|
8
|
+
Polar,
|
|
9
|
+
Reporter,
|
|
10
|
+
BaseSmoother,
|
|
11
|
+
MovingAverageSmoother,
|
|
12
|
+
KalmanSmoother,
|
|
13
|
+
PolarSmoother,
|
|
14
|
+
createSmoothedPolar,
|
|
15
|
+
createSmoothedHandler
|
|
16
|
+
} = require('signalkutilities');
|
|
6
17
|
|
|
18
|
+
const { CorrectionTable } = require('./correctionTable.js');
|
|
7
19
|
|
|
8
20
|
module.exports = function (app) {
|
|
9
21
|
|
|
10
|
-
let options = {};
|
|
11
22
|
let isRunning = false;
|
|
12
|
-
let
|
|
13
|
-
let
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
let smoothedHeading = null;
|
|
24
|
+
let stability = null;
|
|
25
|
+
let smoothedAttitude = null;
|
|
26
|
+
let rawCurrent = null;
|
|
27
|
+
let smoothedCurrent = null;
|
|
28
|
+
let smoothedBoatSpeed = null;
|
|
29
|
+
let correctedBoatSpeed = null;
|
|
30
|
+
let boatSpeedRefGround = null;
|
|
31
|
+
let smoothedGroundSpeed = null;
|
|
32
|
+
let speedCorrection = null;
|
|
33
|
+
let residual = null;
|
|
34
|
+
let reportFull = null;
|
|
35
|
+
let reportVector = null;
|
|
36
|
+
let table = null;
|
|
37
|
+
|
|
38
|
+
// add missing declarations
|
|
39
|
+
let rawHeading = null;
|
|
40
|
+
let rawAttitude = null;
|
|
41
|
+
let noCurrent = null;
|
|
42
|
+
let rawBoatSpeed = null;
|
|
43
|
+
let rawGroundSpeed = null;
|
|
44
|
+
let started = null;
|
|
29
45
|
|
|
30
46
|
const plugin = {};
|
|
31
47
|
plugin.id = "SpeedAndCurrent";
|
|
32
48
|
plugin.name = "Speed and current";
|
|
33
|
-
plugin.description = "
|
|
34
|
-
|
|
35
|
-
|
|
49
|
+
plugin.description = "A plugin that uses sensor fusion to get boat speed, current and leeway.";
|
|
36
50
|
|
|
37
51
|
plugin.schema = {
|
|
38
52
|
type: "object",
|
|
39
53
|
properties: {
|
|
40
|
-
|
|
41
|
-
type: "
|
|
42
|
-
title: "
|
|
43
|
-
description: "
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
53
85
|
},
|
|
54
86
|
heelStep: {
|
|
55
87
|
type: "number",
|
|
@@ -75,144 +107,98 @@ module.exports = function (app) {
|
|
|
75
107
|
description: "Correction table maximum speed.",
|
|
76
108
|
default: 9
|
|
77
109
|
},
|
|
78
|
-
|
|
79
110
|
headingSource: {
|
|
80
111
|
type: "string",
|
|
81
112
|
title: "Heading Source",
|
|
82
113
|
description: "Source to use for navigation.heading (optional).",
|
|
83
114
|
default: " "
|
|
84
115
|
},
|
|
85
|
-
|
|
86
116
|
boatSpeedSource: {
|
|
87
117
|
type: "string",
|
|
88
118
|
title: "Boat Speed Source",
|
|
89
119
|
description: "Source to use for navigation.speedThroughWater (optional).",
|
|
90
120
|
default: " "
|
|
91
121
|
},
|
|
92
|
-
|
|
93
122
|
COGSource: {
|
|
94
123
|
type: "string",
|
|
95
124
|
title: "Course over ground",
|
|
96
125
|
description: "Source to use for navigation.courseOverGroundTrue (optional)",
|
|
97
126
|
default: " "
|
|
98
127
|
},
|
|
99
|
-
|
|
100
128
|
SOGSource: {
|
|
101
129
|
type: "string",
|
|
102
130
|
title: "Ground Speed Source",
|
|
103
131
|
description: "Source to use for navigation.speedOverGround (optional)",
|
|
104
132
|
default: " "
|
|
105
133
|
},
|
|
106
|
-
|
|
107
134
|
attitudeSource: {
|
|
108
135
|
type: "string",
|
|
109
136
|
title: "Attitude Source",
|
|
110
137
|
description: "Source to use for navigation.attitude (optional)",
|
|
111
138
|
default: " "
|
|
112
139
|
},
|
|
113
|
-
|
|
114
140
|
preventDuplication: {
|
|
115
141
|
type: "boolean",
|
|
116
142
|
title: "Prevent duplication of boat speed",
|
|
117
143
|
description: "Overwrite boat speed from sensor with corrected boat speed.",
|
|
118
|
-
default: true
|
|
119
|
-
},
|
|
120
|
-
|
|
121
|
-
updateCorrectionTable: {
|
|
122
|
-
type: "boolean",
|
|
123
|
-
title: "Update correction table",
|
|
124
|
-
description: "Update estimations in the correction table.",
|
|
125
|
-
default: true,
|
|
126
|
-
},
|
|
127
|
-
estimateBoatSpeed: {
|
|
128
|
-
type: "boolean",
|
|
129
|
-
title: "Correct boat speed",
|
|
130
|
-
description: "Correct boat speed using correction table. This includes adding leeway.",
|
|
131
|
-
default: true,
|
|
132
|
-
},
|
|
133
|
-
correctionStability: {
|
|
134
|
-
type: "number",
|
|
135
|
-
title: "Stability of correction estimation",
|
|
136
|
-
description: `The stability of the correction estimation indicates how much the estimation is trusted
|
|
137
|
-
opposed to the calculation of . Biggeboat speed. Bigger values give more trust to the estimation and more stable estimations`,
|
|
138
|
-
default: 7,
|
|
139
|
-
minimum: 5,
|
|
140
|
-
maximum: 12
|
|
141
|
-
},
|
|
142
|
-
doStartFresh: {
|
|
143
|
-
type: "boolean",
|
|
144
|
-
title: "Empty correction table",
|
|
145
|
-
description: "Restart with a fresh correction table. WARNING: all current corrections will be lost permanently!",
|
|
146
|
-
default: false
|
|
147
|
-
},
|
|
148
|
-
assumeCurrent: {
|
|
149
|
-
type: "boolean",
|
|
150
|
-
title: "assume current",
|
|
151
|
-
description: "The estimator assumes that there is current.",
|
|
152
|
-
default: false,
|
|
153
|
-
},
|
|
154
|
-
estimateCurrent: {
|
|
155
|
-
type: "boolean",
|
|
156
|
-
title: "Estimate current",
|
|
157
|
-
description: "Estimate water current.",
|
|
158
|
-
default: true,
|
|
159
|
-
},
|
|
160
|
-
currentStability: {
|
|
161
|
-
type: "number",
|
|
162
|
-
title: "Stability of current estimation ",
|
|
163
|
-
description: `The stability of the current estimation indicates how much the estimation is trusted
|
|
164
|
-
opposed to the calculation of current. Bigger values give more trust to the estimation and more sable estimations`,
|
|
165
|
-
default: 5,
|
|
166
|
-
minimum: 1,
|
|
167
|
-
maximum: 8
|
|
144
|
+
default: true
|
|
168
145
|
}
|
|
169
146
|
}
|
|
170
147
|
};
|
|
171
148
|
|
|
172
149
|
plugin.uiSchema = {
|
|
173
|
-
'ui:order': [
|
|
174
|
-
|
|
175
|
-
"
|
|
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"
|
|
176
181
|
},
|
|
177
182
|
heelStep: {
|
|
178
|
-
"ui:widget": "updown"
|
|
183
|
+
"ui:widget": "updown"
|
|
179
184
|
},
|
|
180
185
|
maxHeel: {
|
|
181
|
-
"ui:widget": "updown"
|
|
186
|
+
"ui:widget": "updown"
|
|
182
187
|
},
|
|
183
188
|
speedStep: {
|
|
184
|
-
"ui:widget": "updown"
|
|
189
|
+
"ui:widget": "updown"
|
|
185
190
|
},
|
|
186
191
|
maxSpeed: {
|
|
187
|
-
"ui:widget": "updown"
|
|
192
|
+
"ui:widget": "updown"
|
|
188
193
|
},
|
|
189
194
|
mode: {
|
|
190
|
-
"ui:widget": "select"
|
|
191
|
-
},
|
|
192
|
-
updateCorrectionTable: {
|
|
193
|
-
"ui:widget": "hidden",
|
|
194
|
-
},
|
|
195
|
-
estimateBoatSpeed: {
|
|
196
|
-
"ui:widget": "hidden",
|
|
197
|
-
},
|
|
198
|
-
correctionStability: {
|
|
199
|
-
"ui:widget": "hidden",
|
|
195
|
+
"ui:widget": "select"
|
|
200
196
|
},
|
|
201
197
|
preventDuplication: {
|
|
202
|
-
"ui:widget": "checkbox"
|
|
203
|
-
},
|
|
204
|
-
assumeCurrent: {
|
|
205
|
-
"ui:widget": "hidden",
|
|
206
|
-
},
|
|
207
|
-
estimateCurrent: {
|
|
208
|
-
"ui:widget": "hidden",
|
|
209
|
-
},
|
|
210
|
-
currentStability: {
|
|
211
|
-
"ui:widget": "hidden",
|
|
198
|
+
"ui:widget": "checkbox"
|
|
212
199
|
}
|
|
213
200
|
};
|
|
214
201
|
|
|
215
|
-
|
|
216
202
|
plugin.registerWithRouter = function (router) {
|
|
217
203
|
app.debug('registerWithRouter');
|
|
218
204
|
|
|
@@ -236,73 +222,62 @@ module.exports = function (app) {
|
|
|
236
222
|
|
|
237
223
|
}
|
|
238
224
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
app.savePluginOptions(options, () => { app.debug('Correction table saved') });
|
|
244
|
-
return new Date();
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
plugin.start = (opts) => {
|
|
249
|
-
app.setPluginStatus("starting");
|
|
250
|
-
app.debug("plugin starting");
|
|
251
|
-
|
|
252
|
-
// make options survive to the stop function
|
|
253
|
-
options = opts;
|
|
254
|
-
loadPresets();
|
|
255
|
-
|
|
256
|
-
// correction table
|
|
257
|
-
table = loadTable(options);
|
|
225
|
+
plugin.start = (settings) => {
|
|
226
|
+
app.setPluginStatus("Starting");
|
|
227
|
+
app.debug("Starting");
|
|
228
|
+
table = loadTable(settings);
|
|
258
229
|
|
|
230
|
+
//#region Handler and Polar Initialization
|
|
231
|
+
let smootherOptions = { timeConstant: 1, processVariance: 1, measurementVariance: 20, timeSpan: 5 };
|
|
259
232
|
|
|
260
233
|
// heading
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
234
|
+
smoothedHeading = new SmoothedHeading(app, plugin.id, settings.headingSource, true, MovingAverageSmoother, smootherOptions);
|
|
235
|
+
rawHeading = smoothedHeading.polar.angleHandler;
|
|
236
|
+
|
|
237
|
+
//attitude
|
|
238
|
+
smoothedAttitude = new SmoothedAttitude(app, plugin.id, settings.attitudeSource, true, MovingAverageSmoother, smootherOptions);
|
|
239
|
+
rawAttitude = smoothedAttitude.handler;
|
|
264
240
|
|
|
265
|
-
// attitude
|
|
266
|
-
attitude = new MessageHandler("attitude", "navigation.attitude", options.attitudeSource);
|
|
267
|
-
attitude.value = { pitch: 0, roll: 0, yaw: 0 }; //prevents errors when there is no attitude sensor
|
|
268
|
-
attitude.setDisplayAttributes({ label: "attitude" });
|
|
269
|
-
attitude.subscribe(app, plugin.id, true);
|
|
270
241
|
|
|
271
242
|
// current
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
243
|
+
rawCurrent = new Polar("current", "self.environment.current.drift", "self.environment.current.setTrue");
|
|
244
|
+
rawCurrent.setDisplayAttributes({ label: "current", plane: "Ground" });
|
|
245
|
+
rawCurrent.setAngleRange('0to2pi');
|
|
246
|
+
smoothedCurrent = new PolarSmoother("currentDamped", rawCurrent, KalmanSmoother, { processVariance: 0.000001, measurementVariance: 0.01 });
|
|
247
|
+
smoothedCurrent.setAngleRange('0to2pi');
|
|
248
|
+
smoothedCurrent.setDisplayAttributes({ label: "Current", plane: "Ground" });
|
|
278
249
|
// Current should be initialised as no current
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
250
|
+
rawCurrent.setVectorValue({ x: 0, y: 0 });
|
|
251
|
+
// Strongly assume no current at start
|
|
252
|
+
smoothedCurrent.xSmoother.reset(0, 0.00000001);
|
|
253
|
+
smoothedCurrent.ySmoother.reset(0, 0.00000001);
|
|
254
|
+
// no current
|
|
255
|
+
noCurrent = createSmoothedPolar({
|
|
256
|
+
id: "boatSpeed",
|
|
257
|
+
pathMagnitude: "self.environment.current.drift",
|
|
258
|
+
pathAngle: "self.environment.current.setTrue",
|
|
259
|
+
subscribe: false,
|
|
260
|
+
app,
|
|
261
|
+
pluginId: plugin.id,
|
|
262
|
+
SmootherClass: BaseSmoother,
|
|
263
|
+
smootherOptions: smootherOptions,
|
|
264
|
+
displayAttributes: { label: "NoCurrent", plane: "Ground" },
|
|
265
|
+
});
|
|
266
|
+
noCurrent.xSmoother.reset(0,0);
|
|
267
|
+
noCurrent.ySmoother.reset(0,0);
|
|
268
|
+
PolarSmoother.send(app, plugin.id, [noCurrent]);
|
|
283
269
|
|
|
284
270
|
// boat speed
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
boatSpeedStat = new PolarDamped("boatSpeedDamped", boatSpeed);
|
|
289
|
-
boatSpeedStat.setDisplayAttributes({ label: "boat speed (damped)", plane: "Boat" });
|
|
290
|
-
|
|
291
|
-
correctedBoatSpeed = new Polar("correctedBoatSpeed", "navigation.speedThroughWater", "environment.wind.directionTruenavigation.leewayAngle");
|
|
271
|
+
smoothedBoatSpeed = new SmoothedSpeedThroughWater(app, plugin.id, settings.boatSpeedSource, true, MovingAverageSmoother, smootherOptions, !settings.preventDuplication);
|
|
272
|
+
rawBoatSpeed = smoothedBoatSpeed.polar;
|
|
273
|
+
correctedBoatSpeed = new Polar("correctedBoatSpeed", "navigation.speedThroughWater", "navigation.leewayAngle");
|
|
292
274
|
correctedBoatSpeed.setDisplayAttributes({ label: "corrected boat speed", plane: "Boat" });
|
|
293
|
-
//const correctedBoatSpeedStat = new PolarDamped("correctedBoatSpeedDamped", correctedBoatSpeed);
|
|
294
|
-
|
|
295
275
|
boatSpeedRefGround = new Polar("boatSpeedRefGround");
|
|
296
|
-
boatSpeedRefGround.setDisplayAttributes("
|
|
297
|
-
|
|
276
|
+
boatSpeedRefGround.setDisplayAttributes("Boat speed over ground", "Ground");
|
|
277
|
+
|
|
298
278
|
// ground speed
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
groundSpeed.setDisplayAttributes({ label: "ground speed", plane: "Ground" });
|
|
302
|
-
groundSpeed.setAngleRange('0to2pi');
|
|
303
|
-
groundSpeedStat = new PolarDamped("groundSpeedDamped", groundSpeed);
|
|
304
|
-
groundSpeedStat.setDisplayAttributes({ label: "ground speed (damped)", plane: "Ground" });
|
|
305
|
-
groundSpeedStat.setAngleRange('0to2pi');
|
|
279
|
+
smoothedGroundSpeed = new SmoothedGroundSpeed(app, plugin.id, settings.SOGSource, true, MovingAverageSmoother, smootherOptions, !settings.preventDuplication);
|
|
280
|
+
rawGroundSpeed = smoothedGroundSpeed.polar;
|
|
306
281
|
|
|
307
282
|
// correction vector
|
|
308
283
|
speedCorrection = new Polar("speedCorrection");
|
|
@@ -313,326 +288,254 @@ module.exports = function (app) {
|
|
|
313
288
|
residual = new Polar("residual");
|
|
314
289
|
residual.setDisplayAttributes({ label: "residual", plane: "Ground" });
|
|
315
290
|
|
|
316
|
-
|
|
291
|
+
//#endregion
|
|
292
|
+
|
|
293
|
+
//#region Reporting
|
|
317
294
|
reportFull = new Reporter();
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
295
|
+
|
|
296
|
+
if (settings.estimateBoatSpeed) {
|
|
297
|
+
reportFull.addDelta(rawHeading);
|
|
298
|
+
reportFull.addAttitude(rawAttitude);
|
|
299
|
+
reportFull.addPolar(rawBoatSpeed);
|
|
300
|
+
reportFull.addPolar(speedCorrection);
|
|
301
|
+
reportFull.addPolar(correctedBoatSpeed);
|
|
302
|
+
reportFull.addPolar(rawGroundSpeed);
|
|
303
|
+
reportFull.addPolar(smoothedCurrent);
|
|
304
|
+
}
|
|
305
|
+
if (settings.updateCorrectionTable) {
|
|
306
|
+
reportFull.addDelta(smoothedHeading);
|
|
307
|
+
reportFull.addAttitude(smoothedAttitude);
|
|
308
|
+
reportFull.addPolar(smoothedBoatSpeed);
|
|
309
|
+
reportFull.addPolar(smoothedGroundSpeed);
|
|
310
|
+
|
|
311
|
+
if (settings.assumeCurrent) {
|
|
312
|
+
reportFull.addPolar(boatSpeedRefGround);
|
|
313
|
+
reportFull.addPolar(smoothedCurrent);
|
|
314
|
+
}
|
|
315
|
+
reportFull.addPolar(speedCorrection);
|
|
316
|
+
reportFull.addPolar(residual);
|
|
317
|
+
}
|
|
328
318
|
reportFull.addTable(table);
|
|
329
319
|
|
|
320
|
+
// Make reporting object for webApp
|
|
321
|
+
|
|
322
|
+
|
|
330
323
|
reportVector = new Reporter();
|
|
331
|
-
reportVector.addDelta(
|
|
324
|
+
reportVector.addDelta(rawHeading);
|
|
332
325
|
reportVector.addPolar(residual);
|
|
333
326
|
reportVector.addPolar(speedCorrection);
|
|
334
|
-
reportVector.addPolar(
|
|
327
|
+
if (settings.assumeCurrent) reportVector.addPolar(smoothedCurrent);
|
|
335
328
|
reportVector.addPolar(correctedBoatSpeed);
|
|
336
|
-
reportVector.addPolar(
|
|
337
|
-
reportVector.addPolar(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
candidates.sort((a, b) => (a.frequency || 0) - (b.frequency || 0));
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (!candidates[0].lackingInputData()) {
|
|
362
|
-
slowestInput = candidates[0];
|
|
363
|
-
app.debug("Using " + slowestInput.displayAttributes.label + " as heartbeat for calculations ( " + Math.round(candidates[0].frequency) + " updates per second)");
|
|
364
|
-
slowestInput.onChange = calculate;
|
|
365
|
-
if (slowestInput == groundSpeed) {
|
|
366
|
-
groundSpeed.onChange = () => {
|
|
367
|
-
groundSpeedStat.sample();
|
|
368
|
-
calculate();
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
else {
|
|
372
|
-
groundSpeed.onChange = () => {
|
|
373
|
-
groundSpeedStat.sample();
|
|
374
|
-
};
|
|
329
|
+
reportVector.addPolar(rawBoatSpeed);
|
|
330
|
+
reportVector.addPolar(rawGroundSpeed);
|
|
331
|
+
//#endregion
|
|
332
|
+
|
|
333
|
+
isRunning = true;
|
|
334
|
+
started = new Date();
|
|
335
|
+
app.setPluginStatus("Running");
|
|
336
|
+
app.debug("Running");
|
|
337
|
+
|
|
338
|
+
let lastSave = 0;
|
|
339
|
+
smoothedBoatSpeed.onChange = () => {
|
|
340
|
+
const wellUnderway = started < new Date() - 60 * 1000;
|
|
341
|
+
const minSpeed = SI.fromKnots(settings.speedStep / 2);
|
|
342
|
+
if (settings.estimateBoatSpeed) correct(wellUnderway);
|
|
343
|
+
if (settings.updateCorrectionTable && wellUnderway) {
|
|
344
|
+
updateTable(settings.assumeCurrent, minSpeed);
|
|
345
|
+
// Save correction table periodically
|
|
346
|
+
const now = new Date();
|
|
347
|
+
if (now - lastSave > 5 * 1000) {
|
|
348
|
+
saveTable(settings, table);
|
|
349
|
+
lastSave = now;
|
|
375
350
|
}
|
|
376
|
-
if (slowestInput == boatSpeed) {
|
|
377
|
-
boatSpeed.onChange = () => {
|
|
378
|
-
boatSpeedStat.sample();
|
|
379
|
-
calculate();
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
else {
|
|
383
|
-
boatSpeed.onChange = () => {
|
|
384
|
-
boatSpeedStat.sample();
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
isRunning = true;
|
|
389
|
-
app.setPluginStatus("Running");
|
|
390
|
-
app.debug("Running");
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
}, 5000);
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
// Main function, estimates boatSpeed, leeway and current
|
|
400
|
-
function calculate() {
|
|
401
|
-
// prepare iteration
|
|
402
|
-
heel = attitude.value.roll;
|
|
403
|
-
speed = boatSpeed.magnitude;
|
|
404
|
-
theta = heading.value;
|
|
405
|
-
if (!Number.isFinite(heel)) {
|
|
406
|
-
app.debug("Heel is not a valid number, skipping calculation");
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
if (!Number.isFinite(speed)) {
|
|
410
|
-
app.debug("Speed is not a valid number, skipping calculation");
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
if (!Number.isFinite(theta)) {
|
|
414
|
-
app.debug("Heading is not a valid number, skipping calculation");
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// update correction table
|
|
419
|
-
if (options.updateCorrectionTable) {
|
|
420
|
-
table.update(speed, heel, groundSpeedStat, currentStat, boatSpeedStat, theta);
|
|
421
351
|
}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
422
354
|
|
|
423
|
-
|
|
424
|
-
|
|
355
|
+
plugin.stop = () => {
|
|
356
|
+
return new Promise((resolve, reject) => {
|
|
357
|
+
try {
|
|
358
|
+
smoothedHeading = smoothedHeading?.terminate(app);
|
|
359
|
+
stability = stability?.terminate?.(app);
|
|
360
|
+
smoothedAttitude = smoothedAttitude?.terminate(app);
|
|
361
|
+
rawCurrent = rawCurrent?.terminate(app);
|
|
362
|
+
smoothedCurrent = smoothedCurrent?.terminate?.(app);
|
|
363
|
+
smoothedBoatSpeed = smoothedBoatSpeed?.terminate(app);
|
|
364
|
+
correctedBoatSpeed = correctedBoatSpeed?.terminate(app);
|
|
365
|
+
boatSpeedRefGround = boatSpeedRefGround?.terminate(app);
|
|
366
|
+
smoothedGroundSpeed = smoothedGroundSpeed?.terminate(app);
|
|
367
|
+
speedCorrection = speedCorrection?.terminate(app);
|
|
368
|
+
residual = residual?.terminate(app);
|
|
369
|
+
reportFull = null;
|
|
370
|
+
reportVector = null;
|
|
371
|
+
table = null;
|
|
372
|
+
rawHeading = null;
|
|
373
|
+
rawAttitude = null;
|
|
374
|
+
noCurrent = null;
|
|
375
|
+
rawBoatSpeed = null;
|
|
376
|
+
rawGroundSpeed = null;
|
|
377
|
+
started = null;
|
|
378
|
+
app.setPluginStatus("Stopped");
|
|
379
|
+
app.debug("Stopped");
|
|
425
380
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
381
|
+
isRunning = false;
|
|
382
|
+
resolve();
|
|
383
|
+
} catch (error) {
|
|
384
|
+
reject(error);
|
|
429
385
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
// Send calculated delta's
|
|
434
|
-
updates = [];
|
|
435
|
-
if (options.estimateCurrent) updates.push(current);
|
|
436
|
-
if (options.estimateBoatSpeed) updates.push(correctedBoatSpeed);
|
|
437
|
-
if (updates.length) Polar.send(app, plugin.id, updates);
|
|
386
|
+
});
|
|
387
|
+
};
|
|
438
388
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
389
|
+
/**
|
|
390
|
+
* Estimates and applies corrections to boat speed, leeway, and current.
|
|
391
|
+
*
|
|
392
|
+
* This function uses the current attitude, boat speed, and heading to compute a correction
|
|
393
|
+
* vector from the correction table, applies it to the observed boat speed, and sends the
|
|
394
|
+
* corrected value. If the vessel is well underway, it also estimates the current vector
|
|
395
|
+
* by comparing the corrected boat speed (rotated to heading) with the ground speed.
|
|
396
|
+
*
|
|
397
|
+
* @param {boolean} wellUnderway - True if the vessel is considered underway and current estimation should be performed.
|
|
398
|
+
*/
|
|
399
|
+
function correct(wellUnderway) {
|
|
400
|
+
// prepare iteration
|
|
401
|
+
const heel = rawAttitude.value?.roll;
|
|
402
|
+
const speed = rawBoatSpeed.magnitude;
|
|
403
|
+
const theta = rawHeading.value;
|
|
404
|
+
//app.debug(`Heel: ${SI.toDegrees(heel).toFixed(1)}°, Speed: ${SI.toKnots(speed).toFixed(2)} kn, Heading: ${SI.toDegrees(theta).toFixed(1)}°`);
|
|
405
|
+
if (!Number.isFinite(heel) || !Number.isFinite(speed) || !Number.isFinite(theta)) {
|
|
406
|
+
//app.debug("Invalid input data");
|
|
407
|
+
return;
|
|
444
408
|
}
|
|
445
409
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
speedCorrection.setVectorValue(
|
|
410
|
+
correctedBoatSpeed.copyFrom(rawBoatSpeed);
|
|
411
|
+
speedCorrection.setVectorValue({ x: 0, y: 0 }) ;
|
|
412
|
+
if (speed > 0 && !rawAttitude.stale ) {
|
|
413
|
+
const { correction, variance } = table.getCorrection(speed, heel);
|
|
414
|
+
speedCorrection.setVectorValue(correction, variance);
|
|
451
415
|
correctedBoatSpeed.add(speedCorrection);
|
|
452
416
|
}
|
|
453
|
-
|
|
454
|
-
//
|
|
455
|
-
|
|
417
|
+
Polar.send(app, plugin.id, [correctedBoatSpeed]);
|
|
418
|
+
// estimate current
|
|
419
|
+
if (wellUnderway && !rawGroundSpeed.stale) {
|
|
456
420
|
boatSpeedRefGround.copyFrom(correctedBoatSpeed);
|
|
457
421
|
boatSpeedRefGround.rotate(theta);
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
current.copyFrom(currentStat);
|
|
422
|
+
rawCurrent.copyFrom(rawGroundSpeed);
|
|
423
|
+
rawCurrent.substract(boatSpeedRefGround);
|
|
424
|
+
smoothedCurrent.sample();
|
|
462
425
|
}
|
|
426
|
+
PolarSmoother.send(app, plugin.id, [smoothedCurrent]);
|
|
427
|
+
}
|
|
463
428
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
429
|
+
/**
|
|
430
|
+
* Updates the correction table and calculates the residual error.
|
|
431
|
+
*
|
|
432
|
+
* This function uses the current smoothed attitude, boat speed, and heading to update the correction table
|
|
433
|
+
* if the boat speed exceeds the specified minimum. It also calculates the residual between the measured ground speed
|
|
434
|
+
* and the expected ground speed (corrected boat speed plus current). The correction table is periodically saved.
|
|
435
|
+
*
|
|
436
|
+
* @param {boolean} [assumeCurrent=false] - If true, uses the smoothed current for correction; otherwise, assumes no current.
|
|
437
|
+
* @param {number} [minSpeed=0] - The minimum speed threshold (in SI units) required to update the correction table.
|
|
438
|
+
*/
|
|
439
|
+
function updateTable(assumeCurrent = false, minSpeed = 0) {
|
|
440
|
+
// prepare iteration
|
|
441
|
+
const heel = smoothedAttitude.value.roll;
|
|
442
|
+
const speed = smoothedBoatSpeed.magnitude;
|
|
443
|
+
const theta = smoothedHeading.value;
|
|
444
|
+
|
|
445
|
+
if (!Number.isFinite(heel) || !Number.isFinite(speed) || !Number.isFinite(theta)) return;
|
|
446
|
+
|
|
447
|
+
// update correction table
|
|
448
|
+
if (speed > minSpeed && !smoothedAttitude.stale && !smoothedHeading.stale && !smoothedGroundSpeed.stale) {
|
|
449
|
+
table.update(speed, heel, smoothedGroundSpeed, assumeCurrent ? smoothedCurrent : noCurrent, smoothedBoatSpeed, theta);
|
|
471
450
|
}
|
|
472
451
|
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
if (!table.row) return false;
|
|
480
|
-
if (table.row.min != row.min) return false;
|
|
481
|
-
if (table.row.max != row.max) return false;
|
|
482
|
-
if (table.row.step != row.step) return false;
|
|
483
|
-
if (table.col.min != col.min) return false;
|
|
484
|
-
if (table.col.max != col.max) return false;
|
|
485
|
-
if (table.col.step != col.step) return false;
|
|
486
|
-
return true;
|
|
487
|
-
}
|
|
452
|
+
// calculate residual
|
|
453
|
+
residual.copyFrom(smoothedGroundSpeed);
|
|
454
|
+
boatSpeedRefGround.copyFrom(correctedBoatSpeed);
|
|
455
|
+
boatSpeedRefGround.rotate(theta);
|
|
456
|
+
residual.substract(boatSpeedRefGround);
|
|
457
|
+
residual.substract(smoothedCurrent);
|
|
488
458
|
|
|
489
|
-
|
|
490
|
-
const row = { min: 0, max: SI.fromKnots(options.maxSpeed), step: SI.fromKnots(options.speedStep) };
|
|
491
|
-
const col = { min: -SI.fromDegrees(options.maxHeel), max: SI.fromDegrees(options.maxHeel), step: SI.fromDegrees(options.heelStep) };
|
|
492
|
-
let table;
|
|
493
|
-
if (enforceConsistancy(row, col) && !options.doStartFresh) {
|
|
494
|
-
table = CorrectionTable.fromJSON(options.correctionTable, options.correctionStability);
|
|
495
|
-
app.debug("Correction table loaded");
|
|
496
|
-
lastSave = new Date();
|
|
497
|
-
}
|
|
498
|
-
else {
|
|
499
|
-
table = new CorrectionTable("correctionTable", row, col, options.correctionStability);
|
|
500
|
-
app.debug("Correction table created");
|
|
501
|
-
options.doStartFresh = false;
|
|
502
|
-
lastSave = saveTable(options, table);
|
|
503
|
-
}
|
|
504
|
-
table.setDisplayAttributes({ label: "correction table" });
|
|
505
|
-
return table;
|
|
506
|
-
}
|
|
459
|
+
}
|
|
507
460
|
|
|
461
|
+
/**
|
|
462
|
+
* Checks if the correction table in settings matches the expected row and column configuration.
|
|
463
|
+
*
|
|
464
|
+
* This function compares the min, max, and step values of the row and column definitions
|
|
465
|
+
* in the current correction table against the provided row and col objects. Returns true
|
|
466
|
+
* if all values match, otherwise false. If the table is missing or not properly structured,
|
|
467
|
+
* returns false.
|
|
468
|
+
*
|
|
469
|
+
* @param {Object} row - The expected row configuration ({ min, max, step }).
|
|
470
|
+
* @param {Object} col - The expected column configuration ({ min, max, step }).
|
|
471
|
+
* @returns {boolean} True if the table matches the configuration, false otherwise.
|
|
472
|
+
*/
|
|
473
|
+
function enforceConsistancy(options, row, col) {
|
|
474
|
+
if (!options?.correctionTable) return false;
|
|
475
|
+
table = options.correctionTable;
|
|
476
|
+
if (!table) return false;
|
|
477
|
+
if (!table.row) return false;
|
|
478
|
+
if (table.row.min != row.min) return false;
|
|
479
|
+
if (table.row.max != row.max) return false;
|
|
480
|
+
if (table.row.step != row.step) return false;
|
|
481
|
+
if (table.col.min != col.min) return false;
|
|
482
|
+
if (table.col.max != col.max) return false;
|
|
483
|
+
if (table.col.step != col.step) return false;
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
508
486
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
break;
|
|
529
|
-
case 1:
|
|
530
|
-
// new table, with current
|
|
531
|
-
options.doStartFresh = true;
|
|
532
|
-
options.updateCorrectionTable = true;
|
|
533
|
-
options.estimateBoatSpeed = false;
|
|
534
|
-
options.assumeCurrent = true;
|
|
535
|
-
options.estimateCurrent = false;
|
|
536
|
-
options.correctionStability = 5;
|
|
537
|
-
options.currentStability = 7;
|
|
538
|
-
options.mode = modeEnum[3];
|
|
539
|
-
break;
|
|
540
|
-
case 2:
|
|
541
|
-
// fresh table, no current
|
|
542
|
-
options.doStartFresh = false;
|
|
543
|
-
options.updateCorrectionTable = true;
|
|
544
|
-
options.estimateBoatSpeed = false;
|
|
545
|
-
options.assumeCurrent = false;
|
|
546
|
-
options.estimateCurrent = false;
|
|
547
|
-
options.correctionStability = 6;
|
|
548
|
-
break;
|
|
549
|
-
case 3:
|
|
550
|
-
// fresh table, with current
|
|
551
|
-
options.doStartFresh = false;
|
|
552
|
-
options.updateCorrectionTable = true;
|
|
553
|
-
options.estimateBoatSpeed = false;
|
|
554
|
-
options.assumeCurrent = true;
|
|
555
|
-
options.estimateCurrent = false;
|
|
556
|
-
options.correctionStability = 6;
|
|
557
|
-
options.currentStability = 6;
|
|
558
|
-
break;
|
|
559
|
-
case 4:
|
|
560
|
-
// mature table, no current
|
|
561
|
-
options.doStartFresh = false;
|
|
562
|
-
options.updateCorrectionTable = true;
|
|
563
|
-
options.estimateBoatSpeed = true;
|
|
564
|
-
options.assumeCurrent = false;
|
|
565
|
-
options.estimateCurrent = false;
|
|
566
|
-
options.correctionStability = 8;
|
|
567
|
-
break;
|
|
568
|
-
case 5:
|
|
569
|
-
// mature table, with current
|
|
570
|
-
options.doStartFresh = false;
|
|
571
|
-
options.updateCorrectionTable = true;
|
|
572
|
-
options.estimateBoatSpeed = true;
|
|
573
|
-
options.assumeCurrent = true;
|
|
574
|
-
options.estimateCurrent = true;
|
|
575
|
-
options.correctionStability = 8;
|
|
576
|
-
options.currentStability = 3;
|
|
577
|
-
break;
|
|
578
|
-
case 6:
|
|
579
|
-
// locked table
|
|
580
|
-
options.doStartFresh = false;
|
|
581
|
-
options.updateCorrectionTable = false;
|
|
582
|
-
options.estimateBoatSpeed = true;
|
|
583
|
-
options.assumeCurrent = true;
|
|
584
|
-
options.estimateCurrent = true;
|
|
585
|
-
options.currentStability = 2;
|
|
586
|
-
break;
|
|
587
|
-
case 7:
|
|
588
|
-
// all manual configuration;
|
|
589
|
-
break;
|
|
590
|
-
}
|
|
487
|
+
/**
|
|
488
|
+
* Loads or creates a correction table based on the provided options.
|
|
489
|
+
*
|
|
490
|
+
* This function checks if the correction table in the options matches the expected row and column configuration.
|
|
491
|
+
* If so, it loads the table from JSON; otherwise, it creates a new correction table with the specified parameters.
|
|
492
|
+
* The table is labeled and, if newly created, saved to the plugin options.
|
|
493
|
+
*
|
|
494
|
+
* @param {Object} options - The plugin options containing correction table settings and parameters.
|
|
495
|
+
* @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
|
|
496
|
+
*/
|
|
497
|
+
function loadTable(options) {
|
|
498
|
+
const row = { min: 0, max: SI.fromKnots(options.maxSpeed), step: SI.fromKnots(options.speedStep) };
|
|
499
|
+
const col = { min: -SI.fromDegrees(options.maxHeel), max: SI.fromDegrees(options.maxHeel), step: SI.fromDegrees(options.heelStep) };
|
|
500
|
+
let table;
|
|
501
|
+
const stability = (options.stability !== undefined) ? options.stability : 6;
|
|
502
|
+
const VAR_FLOOR = 1e-4;
|
|
503
|
+
if (enforceConsistancy(options, row, col) && !options.startWithNewTable) {
|
|
504
|
+
table = CorrectionTable.fromJSON(options.correctionTable, stability);
|
|
505
|
+
app.debug("Correction table loaded");
|
|
591
506
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
507
|
+
else if (options.correctionTable && !options.startWithNewTable) {
|
|
508
|
+
// Resample existing table to new dimensions conservatively (N=0, diag covariance with floor)
|
|
509
|
+
table = CorrectionTable.resampleFromJSON(options.correctionTable, row, col, stability, VAR_FLOOR);
|
|
510
|
+
app.debug("Correction table migrated to new dimensions");
|
|
511
|
+
options.startWithNewTable = false;
|
|
512
|
+
saveTable(options, table);
|
|
513
|
+
} else {
|
|
514
|
+
table = new CorrectionTable("correctionTable", row, col, stability);
|
|
515
|
+
app.debug("Correction table created");
|
|
516
|
+
// doStartFresh is not user-settable, so no need to reset it here
|
|
517
|
+
options.startWithNewTable = false;
|
|
518
|
+
saveTable(options, table);
|
|
600
519
|
}
|
|
520
|
+
table.setDisplayAttributes({ label: "correction table" });
|
|
521
|
+
return table;
|
|
601
522
|
}
|
|
602
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Saves the correction table to the plugin options and logs the save event.
|
|
526
|
+
*
|
|
527
|
+
* This function serializes the provided correction table, updates the options object,
|
|
528
|
+
* and triggers the plugin's save mechanism. It returns the current date/time to indicate
|
|
529
|
+
* when the save occurred.
|
|
530
|
+
*
|
|
531
|
+
* @param {Object} options - The plugin options object to update.
|
|
532
|
+
* @param {CorrectionTable} correctionTable - The correction table instance to save.
|
|
533
|
+
* @returns {Date} The date and time when the table was saved.
|
|
534
|
+
*/
|
|
535
|
+
function saveTable(options, correctionTable) {
|
|
536
|
+
options.correctionTable = correctionTable.toJSON();
|
|
537
|
+
app.savePluginOptions(options, () => { /*app.debug("Correction table saved")*/; });
|
|
538
|
+
}
|
|
603
539
|
|
|
604
|
-
plugin.stop = () => {
|
|
605
|
-
return new Promise((resolve, reject) => {
|
|
606
|
-
try {
|
|
607
|
-
//corrTable.save(app);
|
|
608
|
-
if (corrTable != null) {
|
|
609
|
-
lastSave = saveTable(options, corrTable);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
heading = null;
|
|
613
|
-
attitude = null;
|
|
614
|
-
current = null;
|
|
615
|
-
currentStat = null;
|
|
616
|
-
boatSpeed = null;
|
|
617
|
-
boatSpeedStat = null;
|
|
618
|
-
correctedBoatSpeed = null;
|
|
619
|
-
boatSpeedRefGround = null;
|
|
620
|
-
groundSpeed = null;
|
|
621
|
-
groundSpeedStat = null;
|
|
622
|
-
speedCorrection = null;
|
|
623
|
-
residual = null;
|
|
624
|
-
reportFull = null;
|
|
625
|
-
reportVector = null;
|
|
626
|
-
table =
|
|
627
|
-
app.setPluginStatus("Stopped");
|
|
628
|
-
app.debug("Stopped");
|
|
629
|
-
|
|
630
|
-
isRunning = false;
|
|
631
|
-
resolve();
|
|
632
|
-
} catch (error) {
|
|
633
|
-
reject(error);
|
|
634
|
-
}
|
|
635
|
-
});
|
|
636
|
-
};
|
|
637
540
|
return plugin;
|
|
638
541
|
};
|