speedandcurrent 2.1.0 → 2.1.2

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.
@@ -1,332 +1,332 @@
1
- const { Table2D} = require('signalkutilities');
2
- const { KalmanFilter, State } = require('kalman-filter');
3
-
4
- // Module-level helpers — avoids creating new Function objects on every Kalman update call
5
- function _rotateValue(cos, sin, vector) {
6
- return [
7
- cos * vector[0] + sin * vector[1],
8
- -sin * vector[0] + cos * vector[1]
9
- ];
10
- }
11
-
12
- function _rotateVariance(cos, sin, vector) {
13
- return [
14
- [vector[0] * cos ** 2 + vector[1] * sin ** 2, (vector[0] - vector[1]) * cos * sin],
15
- [(vector[0] - vector[1]) * cos * sin, vector[0] * sin ** 2 + vector[1] * cos ** 2]
16
- ];
17
- }
18
-
19
- class CorrectionTable extends Table2D{
20
- /**
21
- * Represents a 2D correction table for heel and speed.
22
- */
23
-
24
-
25
-
26
- static fromJSON(data, stability) {
27
- const table = new CorrectionTable(data.id, data.row, data.col, stability);
28
- table.table = data.table.map(row => row.map(cellData => CorrectionEstimator.fromJSON(cellData, stability)));
29
- return table;
30
- }
31
-
32
- /**
33
- * Resample an existing table onto a new grid conservatively.
34
- * - Seeds mean from oldTable.getCorrection at each new cell center
35
- * - Seeds diagonal covariance with a per-axis floor
36
- * - Sets index (N) = 0 so all cells can re-learn on the new grid
37
- *
38
- * @param {CorrectionTable} oldTable - Source table to sample from
39
- * @param {{min:number,max:number,step:number}} newRow - New speed axis definition (SI units)
40
- * @param {{min:number,max:number,step:number}} newCol - New heel axis definition (SI units)
41
- * @param {number} [stability=5] - Stability passed to new table filter model
42
- * @param {number} [varianceFloor=1e-4] - Floor applied to cov[0][0] and cov[1][1]
43
- * @returns {CorrectionTable}
44
- */
45
- static resample(oldTable, newRow, newCol, stability = 5, varianceFloor = 1e-4) {
46
- const newTable = new CorrectionTable(oldTable.id, newRow, newCol, stability);
47
-
48
- const nRows = Math.round((newRow.max - newRow.min) / newRow.step) + 1;
49
- const nCols = Math.round((newCol.max - newCol.min) / newCol.step) + 1;
50
-
51
- for (let i = 0; i < nRows; i++) {
52
- const speed = newRow.min + i * newRow.step;
53
- for (let j = 0; j < nCols; j++) {
54
- const heel = newCol.min + j * newCol.step;
55
-
56
- const { correction, variance } = oldTable.getCorrection(speed, heel);
57
-
58
- const mean = [[(correction?.x ?? 0)], [(correction?.y ?? 0)]];
59
- const covXX = Math.max(Number.isFinite(variance?.x) ? variance.x : 0, varianceFloor);
60
- const covYY = Math.max(Number.isFinite(variance?.y) ? variance.y : 0, varianceFloor);
61
- const covariance = [[covXX, 0], [0, covYY]];
62
-
63
- // Decide initialization index based on coverage/support from old table
64
- const inBounds = (
65
- speed >= oldTable.min[0] && speed <= oldTable.max[0] &&
66
- heel >= oldTable.min[1] && heel <= oldTable.max[1]
67
- );
68
- let supportCount = 0;
69
- let effectiveN = 0;
70
- if (Array.isArray(oldTable.neighbours)) {
71
- for (const n of oldTable.neighbours) {
72
- const N = n?.cell?.N || 0;
73
- if (N > 0) supportCount++;
74
- const w = Number.isFinite(n?.normWeight) ? n.normWeight : 0;
75
- effectiveN += w * N;
76
- }
77
- }
78
- // Heuristic: require in-bounds AND at least 2 learned neighbours AND some effective support
79
- const index = (inBounds && supportCount >= 2 && effectiveN >= 1) ? 1 : 0;
80
-
81
- // Seed prior with mean and conservative covariance; index as decided above
82
- newTable.table[i][j].filterState = new State({ mean, covariance, index });
83
- }
84
- }
85
- newTable.setDisplayAttributes({ label: "correction table" });
86
- return newTable;
87
- }
88
-
89
- /**
90
- * Convenience to resample from serialized JSON table data
91
- */
92
- static resampleFromJSON(data, newRow, newCol, stability = 5, varianceFloor = 1e-4) {
93
- const oldTable = CorrectionTable.fromJSON(data, stability);
94
- return CorrectionTable.resample(oldTable, newRow, newCol, stability, varianceFloor);
95
- }
96
-
97
- constructor(id, row, col, stability=5) {
98
- super(id, row, col, CorrectionEstimator, CorrectionEstimator.getFilterModel(stability));
99
- this.lastUpdatedCell = null;
100
- this.lastUpdateResult = null;
101
- this.neighbours = [];
102
- }
103
-
104
- update(speed, heel, groundSpeed, current, boatSpeed, heading) {
105
- const cell = this.getCell(speed, heel);
106
- const accepted = cell?.update(groundSpeed, current, boatSpeed, heading);
107
- this.lastUpdatedCell = cell;
108
- this.lastUpdateResult = accepted === true ? 'accepted' : 'rejected';
109
- }
110
-
111
- getCorrection(speed, heel) {
112
- this.neighbours = this.findClosest(speed, heel, 5);
113
- if (this.neighbours.length == 0) return { correction: {x: 0, y: 0}, variance: null };
114
- // Compute the correction and variance from the neighbours
115
- let x = 0;
116
- let y = 0;
117
- let varX = 0;
118
- let varY = 0;
119
- let totalWeight = 0;
120
- for (const neighbour of this.neighbours) {
121
- const { cell:correction, dist } = neighbour;
122
- if (correction.N > 0) {
123
- const weight = 1 / (dist + 1e-6);
124
- neighbour.normWeight = weight;
125
- x += correction.x * weight;
126
- y += correction.y * weight;
127
- varX += correction.covariance[0][0] * weight ** 2;
128
- varY += correction.covariance[1][1] * weight ** 2;
129
- totalWeight += weight;
130
- }
131
- }
132
- if (totalWeight > 0) {
133
- for (const neighbour of this.neighbours) {
134
- neighbour.normWeight /= totalWeight;
135
- }
136
- }
137
-
138
- if (totalWeight === 0) return { correction: { x: 0, y: 0 }, variance: { x: 0, y: 0 } };
139
-
140
- // Compute the final correction and variance
141
- // Variance of a weighted mean: Σ(w²·var_i) / (Σw)²
142
- const tw2 = totalWeight * totalWeight;
143
- x /= totalWeight;
144
- y /= totalWeight;
145
- varX /= tw2;
146
- varY /= tw2;
147
- this.totalWeight = totalWeight;
148
-
149
- return { correction: { x, y }, variance: { x: varX, y: varY } };
150
- }
151
-
152
- report() {
153
- return {
154
- id: this.id,
155
- row: { min: this.min[0], max: this.max[0], step: this.step[0] },
156
- col: { min: this.min[1], max: this.max[1], step: this.step[1] },
157
- table: this.table.map((row, rowIndex) =>
158
- row.map((correction, colIndex) => {
159
- const cellReport = correction.report();
160
- // Derive the bin coordinates from indices
161
- const speedBin = this.min[0] + this.step[0] * rowIndex; // row axis represents speed
162
- const heelBin = this.min[1] + this.step[1] * colIndex; // col axis represents heel
163
- // Compute forward speed after longitudinal correction
164
- const forward = speedBin + cellReport.x;
165
- // Factor (forward relative to original speed); guard division by zero
166
- const factor = speedBin > 0 ? forward / speedBin : null;
167
- // Leeway angle based on sideways over forward; only if forward > 0
168
- const leeway = (forward > 0 && cellReport.N > 0) ? Math.atan2(cellReport.y, forward) : null;
169
- // Trace ( cov_xx + cov_yy ) when covariance available and N>0
170
- let trace = null;
171
- if (cellReport.N > 0) {
172
- const cov = correction.covariance;
173
- if (cov && Array.isArray(cov) && cov[0] && cov[1] && Number.isFinite(cov[0][0]) && Number.isFinite(cov[1][1])) {
174
- trace = cov[0][0] + cov[1][1];
175
- }
176
- }
177
- cellReport.forward = forward;
178
- cellReport.factor = factor;
179
- cellReport.leeway = leeway;
180
- cellReport.trace = trace;
181
- cellReport.speedBin = speedBin;
182
- cellReport.heelBin = heelBin;
183
- // Mark selected if this is the last updated cell
184
- cellReport.displayAttributes = {
185
- selected: correction === this.lastUpdatedCell
186
- };
187
- const found = this.neighbours.find(n => n.cell === correction);
188
- if (found) {
189
- cellReport.displayAttributes.normWeight = found.normWeight;
190
- } else {
191
- cellReport.displayAttributes.normWeight = 0;
192
- }
193
- return cellReport;
194
- })
195
- ),
196
- displayAttributes: this.displayAttributes,
197
- lastUpdateResult: this.lastUpdateResult ?? null
198
- };
199
- }
200
-
201
- }
202
-
203
- class CorrectionEstimator {
204
- /**
205
- * Represents a Kalman correction at a cell in a correction table
206
- */
207
-
208
- static fromJSON(data, stability) {
209
- const filterModel = CorrectionEstimator.getFilterModel(stability);
210
- const estimator = new CorrectionEstimator(filterModel, data.state);
211
- return estimator;
212
- }
213
-
214
- static getFilterModel(stability = 5) {
215
- return {
216
- observation: {
217
- stateProjection: [[1, 0], [0, 1]], // observation matrix H
218
- covariance: [[1, 0], [0, 1]], //measurement noise R
219
- dimension: 2
220
- },
221
- dynamic: {
222
- transition: [[1, 0], [0, 1]], // state transition matrix F
223
- covariance: [1/10**stability, 1/10**stability],// process noise covariance matrix Q
224
- }
225
- };
226
- }
227
-
228
- constructor(filterModel, initialState) {
229
- this.filter = new KalmanFilter(filterModel);
230
- this.filterState = null;
231
- if (initialState != null) {
232
- this.filterState = new State(initialState);
233
- }
234
- }
235
-
236
- update(groundSpeed, current, boatSpeed, heading) {
237
- if(groundSpeed.xVariance == null || groundSpeed.yVariance == null ||
238
- current.xVariance == null || current.yVariance == null ||
239
- boatSpeed.xVariance == null || boatSpeed.yVariance == null ) {
240
- return false;
241
- }
242
- // Rotation matrix for -theta
243
- const cosTheta = Math.cos(heading);
244
- const sinTheta = Math.sin(heading);
245
-
246
- var groundVector = _rotateValue(cosTheta, sinTheta, groundSpeed.vector);
247
- var currentVector = _rotateValue(cosTheta, sinTheta, current.vector);
248
- var boatVector = boatSpeed.vector;
249
-
250
- const observation = [
251
- -boatVector[0] + groundVector[0] - currentVector[0],
252
- -boatVector[1] + groundVector[1] - currentVector[1]
253
- ];
254
-
255
- var groundCov = _rotateVariance(cosTheta, sinTheta, groundSpeed.variance);
256
- var currentCov = _rotateVariance(cosTheta, sinTheta, current.variance);
257
- var boatCov = [[boatSpeed.xVariance, 0], [0, boatSpeed.yVariance]];
258
-
259
- const observationCovariance = [[
260
- groundCov[0][0] + currentCov[0][0] + boatCov[0][0],
261
- groundCov[0][1] + currentCov[0][1] + boatCov[0][1]],
262
- [
263
- groundCov[1][0] + currentCov[1][0] + boatCov[1][0],
264
- groundCov[1][1] + currentCov[1][1] + boatCov[1][1]],
265
- ];
266
- // Mahalanobis distance check.
267
- // For empty cells (filterState === null) we use a diffuse prior — mean (0,0),
268
- // variance DIFFUSE_PRIOR_VAR — expressing "assume no correction needed, but
269
- // with high uncertainty". This gates the very first observation instead of
270
- // accepting it unconditionally, preventing a single outlier from locking a
271
- // cell at a bad value.
272
- const DIFFUSE_PRIOR_VAR = 1.0; // (m/s)² — rejects corrections > ~3 m/s from zero
273
- const priorMean = this.filterState !== null
274
- ? [this.filterState.mean[0][0], this.filterState.mean[1][0]]
275
- : [0, 0];
276
- const priorCov = this.filterState !== null
277
- ? this.filterState.covariance
278
- : [[DIFFUSE_PRIOR_VAR, 0], [0, DIFFUSE_PRIOR_VAR]];
279
- const inno = [observation[0] - priorMean[0], observation[1] - priorMean[1]];
280
- const S = [
281
- [priorCov[0][0] + observationCovariance[0][0],
282
- priorCov[0][1] + observationCovariance[0][1]],
283
- [priorCov[1][0] + observationCovariance[1][0],
284
- priorCov[1][1] + observationCovariance[1][1]]
285
- ];
286
- const det = S[0][0] * S[1][1] - S[0][1] * S[1][0];
287
- if (Number.isFinite(det) && det > 1e-12) {
288
- const Sinv = [
289
- [ S[1][1] / det, -S[0][1] / det],
290
- [-S[1][0] / det, S[0][0] / det]
291
- ];
292
- const d2 = inno[0] * (Sinv[0][0] * inno[0] + Sinv[0][1] * inno[1])
293
- + inno[1] * (Sinv[1][0] * inno[0] + Sinv[1][1] * inno[1]);
294
- if (d2 > 9.21) return false;
295
- }
296
- this.filterState = this.filter.filter({ previousCorrected: this.filterState, observation, observationCovariance });
297
- return true;
298
- }
299
-
300
-
301
- report() {
302
- return { x: this.x, y: this.y, N: this.N };
303
- }
304
-
305
- get N() {
306
- if (this.filterState == null) return 0;
307
- return this.filterState.index;
308
- }
309
-
310
- get x() {
311
- if (this.filterState == null) return 0;
312
- return this.filterState.mean[0][0];
313
- }
314
-
315
- get y() {
316
- if (this.filterState == null) return 0;
317
- return this.filterState.mean[1][0];
318
- }
319
-
320
- get covariance() {
321
- return this.filterState.covariance;
322
- }
323
-
324
-
325
- toJSON() {
326
- return this.N != 0 ? { state: this.filterState } : { state: null };
327
- }
328
-
329
- }
330
-
331
-
332
- module.exports = { CorrectionTable };
1
+ const { Table2D} = require('signalkutilities');
2
+ const { KalmanFilter, State } = require('kalman-filter');
3
+
4
+ // Module-level helpers — avoids creating new Function objects on every Kalman update call
5
+ function _rotateValue(cos, sin, vector) {
6
+ return [
7
+ cos * vector[0] + sin * vector[1],
8
+ -sin * vector[0] + cos * vector[1]
9
+ ];
10
+ }
11
+
12
+ function _rotateVariance(cos, sin, vector) {
13
+ return [
14
+ [vector[0] * cos ** 2 + vector[1] * sin ** 2, (vector[0] - vector[1]) * cos * sin],
15
+ [(vector[0] - vector[1]) * cos * sin, vector[0] * sin ** 2 + vector[1] * cos ** 2]
16
+ ];
17
+ }
18
+
19
+ class CorrectionTable extends Table2D{
20
+ /**
21
+ * Represents a 2D correction table for heel and speed.
22
+ */
23
+
24
+
25
+
26
+ static fromJSON(data, stability) {
27
+ const table = new CorrectionTable(data.id, data.row, data.col, stability);
28
+ table.table = data.table.map(row => row.map(cellData => CorrectionEstimator.fromJSON(cellData, stability)));
29
+ return table;
30
+ }
31
+
32
+ /**
33
+ * Resample an existing table onto a new grid conservatively.
34
+ * - Seeds mean from oldTable.getCorrection at each new cell center
35
+ * - Seeds diagonal covariance with a per-axis floor
36
+ * - Sets index (N) = 0 so all cells can re-learn on the new grid
37
+ *
38
+ * @param {CorrectionTable} oldTable - Source table to sample from
39
+ * @param {{min:number,max:number,step:number}} newRow - New speed axis definition (SI units)
40
+ * @param {{min:number,max:number,step:number}} newCol - New heel axis definition (SI units)
41
+ * @param {number} [stability=5] - Stability passed to new table filter model
42
+ * @param {number} [varianceFloor=1e-4] - Floor applied to cov[0][0] and cov[1][1]
43
+ * @returns {CorrectionTable}
44
+ */
45
+ static resample(oldTable, newRow, newCol, stability = 5, varianceFloor = 1e-4) {
46
+ const newTable = new CorrectionTable(oldTable.id, newRow, newCol, stability);
47
+
48
+ const nRows = Math.round((newRow.max - newRow.min) / newRow.step) + 1;
49
+ const nCols = Math.round((newCol.max - newCol.min) / newCol.step) + 1;
50
+
51
+ for (let i = 0; i < nRows; i++) {
52
+ const speed = newRow.min + i * newRow.step;
53
+ for (let j = 0; j < nCols; j++) {
54
+ const heel = newCol.min + j * newCol.step;
55
+
56
+ const { correction, variance } = oldTable.getCorrection(speed, heel);
57
+
58
+ const mean = [[(correction?.x ?? 0)], [(correction?.y ?? 0)]];
59
+ const covXX = Math.max(Number.isFinite(variance?.x) ? variance.x : 0, varianceFloor);
60
+ const covYY = Math.max(Number.isFinite(variance?.y) ? variance.y : 0, varianceFloor);
61
+ const covariance = [[covXX, 0], [0, covYY]];
62
+
63
+ // Decide initialization index based on coverage/support from old table
64
+ const inBounds = (
65
+ speed >= oldTable.min[0] && speed <= oldTable.max[0] &&
66
+ heel >= oldTable.min[1] && heel <= oldTable.max[1]
67
+ );
68
+ let supportCount = 0;
69
+ let effectiveN = 0;
70
+ if (Array.isArray(oldTable.neighbours)) {
71
+ for (const n of oldTable.neighbours) {
72
+ const N = n?.cell?.N || 0;
73
+ if (N > 0) supportCount++;
74
+ const w = Number.isFinite(n?.normWeight) ? n.normWeight : 0;
75
+ effectiveN += w * N;
76
+ }
77
+ }
78
+ // Heuristic: require in-bounds AND at least 2 learned neighbours AND some effective support
79
+ const index = (inBounds && supportCount >= 2 && effectiveN >= 1) ? 1 : 0;
80
+
81
+ // Seed prior with mean and conservative covariance; index as decided above
82
+ newTable.table[i][j].filterState = new State({ mean, covariance, index });
83
+ }
84
+ }
85
+ newTable.setDisplayAttributes({ label: "correction table" });
86
+ return newTable;
87
+ }
88
+
89
+ /**
90
+ * Convenience to resample from serialized JSON table data
91
+ */
92
+ static resampleFromJSON(data, newRow, newCol, stability = 5, varianceFloor = 1e-4) {
93
+ const oldTable = CorrectionTable.fromJSON(data, stability);
94
+ return CorrectionTable.resample(oldTable, newRow, newCol, stability, varianceFloor);
95
+ }
96
+
97
+ constructor(id, row, col, stability=5) {
98
+ super(id, row, col, CorrectionEstimator, CorrectionEstimator.getFilterModel(stability));
99
+ this.lastUpdatedCell = null;
100
+ this.lastUpdateResult = null;
101
+ this.neighbours = [];
102
+ }
103
+
104
+ update(speed, heel, groundSpeed, current, boatSpeed, heading) {
105
+ const cell = this.getCell(speed, heel);
106
+ const accepted = cell?.update(groundSpeed, current, boatSpeed, heading);
107
+ this.lastUpdatedCell = cell;
108
+ this.lastUpdateResult = accepted === true ? 'accepted' : 'rejected';
109
+ }
110
+
111
+ getCorrection(speed, heel) {
112
+ this.neighbours = this.findClosest(speed, heel, 5);
113
+ if (this.neighbours.length == 0) return { correction: {x: 0, y: 0}, variance: null };
114
+ // Compute the correction and variance from the neighbours
115
+ let x = 0;
116
+ let y = 0;
117
+ let varX = 0;
118
+ let varY = 0;
119
+ let totalWeight = 0;
120
+ for (const neighbour of this.neighbours) {
121
+ const { cell:correction, dist } = neighbour;
122
+ if (correction.N > 0) {
123
+ const weight = 1 / (dist + 1e-6);
124
+ neighbour.normWeight = weight;
125
+ x += correction.x * weight;
126
+ y += correction.y * weight;
127
+ varX += correction.covariance[0][0] * weight ** 2;
128
+ varY += correction.covariance[1][1] * weight ** 2;
129
+ totalWeight += weight;
130
+ }
131
+ }
132
+ if (totalWeight > 0) {
133
+ for (const neighbour of this.neighbours) {
134
+ neighbour.normWeight /= totalWeight;
135
+ }
136
+ }
137
+
138
+ if (totalWeight === 0) return { correction: { x: 0, y: 0 }, variance: { x: 0, y: 0 } };
139
+
140
+ // Compute the final correction and variance
141
+ // Variance of a weighted mean: Σ(w²·var_i) / (Σw)²
142
+ const tw2 = totalWeight * totalWeight;
143
+ x /= totalWeight;
144
+ y /= totalWeight;
145
+ varX /= tw2;
146
+ varY /= tw2;
147
+ this.totalWeight = totalWeight;
148
+
149
+ return { correction: { x, y }, variance: { x: varX, y: varY } };
150
+ }
151
+
152
+ report() {
153
+ return {
154
+ id: this.id,
155
+ row: { min: this.min[0], max: this.max[0], step: this.step[0] },
156
+ col: { min: this.min[1], max: this.max[1], step: this.step[1] },
157
+ table: this.table.map((row, rowIndex) =>
158
+ row.map((correction, colIndex) => {
159
+ const cellReport = correction.report();
160
+ // Derive the bin coordinates from indices
161
+ const speedBin = this.min[0] + this.step[0] * rowIndex; // row axis represents speed
162
+ const heelBin = this.min[1] + this.step[1] * colIndex; // col axis represents heel
163
+ // Compute forward speed after longitudinal correction
164
+ const forward = speedBin + cellReport.x;
165
+ // Factor (forward relative to original speed); guard division by zero
166
+ const factor = speedBin > 0 ? forward / speedBin : null;
167
+ // Leeway angle based on sideways over forward; only if forward > 0
168
+ const leeway = (forward > 0 && cellReport.N > 0) ? Math.atan2(cellReport.y, forward) : null;
169
+ // Trace ( cov_xx + cov_yy ) when covariance available and N>0
170
+ let trace = null;
171
+ if (cellReport.N > 0) {
172
+ const cov = correction.covariance;
173
+ if (cov && Array.isArray(cov) && cov[0] && cov[1] && Number.isFinite(cov[0][0]) && Number.isFinite(cov[1][1])) {
174
+ trace = cov[0][0] + cov[1][1];
175
+ }
176
+ }
177
+ cellReport.forward = forward;
178
+ cellReport.factor = factor;
179
+ cellReport.leeway = leeway;
180
+ cellReport.trace = trace;
181
+ cellReport.speedBin = speedBin;
182
+ cellReport.heelBin = heelBin;
183
+ // Mark selected if this is the last updated cell
184
+ cellReport.displayAttributes = {
185
+ selected: correction === this.lastUpdatedCell
186
+ };
187
+ const found = this.neighbours.find(n => n.cell === correction);
188
+ if (found) {
189
+ cellReport.displayAttributes.normWeight = found.normWeight;
190
+ } else {
191
+ cellReport.displayAttributes.normWeight = 0;
192
+ }
193
+ return cellReport;
194
+ })
195
+ ),
196
+ displayAttributes: this.displayAttributes,
197
+ lastUpdateResult: this.lastUpdateResult ?? null
198
+ };
199
+ }
200
+
201
+ }
202
+
203
+ class CorrectionEstimator {
204
+ /**
205
+ * Represents a Kalman correction at a cell in a correction table
206
+ */
207
+
208
+ static fromJSON(data, stability) {
209
+ const filterModel = CorrectionEstimator.getFilterModel(stability);
210
+ const estimator = new CorrectionEstimator(filterModel, data.state);
211
+ return estimator;
212
+ }
213
+
214
+ static getFilterModel(stability = 5) {
215
+ return {
216
+ observation: {
217
+ stateProjection: [[1, 0], [0, 1]], // observation matrix H
218
+ covariance: [[1, 0], [0, 1]], //measurement noise R
219
+ dimension: 2
220
+ },
221
+ dynamic: {
222
+ transition: [[1, 0], [0, 1]], // state transition matrix F
223
+ covariance: [1/10**stability, 1/10**stability],// process noise covariance matrix Q
224
+ }
225
+ };
226
+ }
227
+
228
+ constructor(filterModel, initialState) {
229
+ this.filter = new KalmanFilter(filterModel);
230
+ this.filterState = null;
231
+ if (initialState != null) {
232
+ this.filterState = new State(initialState);
233
+ }
234
+ }
235
+
236
+ update(groundSpeed, current, boatSpeed, heading) {
237
+ if(groundSpeed.xVariance == null || groundSpeed.yVariance == null ||
238
+ current.xVariance == null || current.yVariance == null ||
239
+ boatSpeed.xVariance == null || boatSpeed.yVariance == null ) {
240
+ return false;
241
+ }
242
+ // Rotation matrix for -theta
243
+ const cosTheta = Math.cos(heading);
244
+ const sinTheta = Math.sin(heading);
245
+
246
+ var groundVector = _rotateValue(cosTheta, sinTheta, groundSpeed.vector);
247
+ var currentVector = _rotateValue(cosTheta, sinTheta, current.vector);
248
+ var boatVector = boatSpeed.vector;
249
+
250
+ const observation = [
251
+ -boatVector[0] + groundVector[0] - currentVector[0],
252
+ -boatVector[1] + groundVector[1] - currentVector[1]
253
+ ];
254
+
255
+ var groundCov = _rotateVariance(cosTheta, sinTheta, groundSpeed.variance);
256
+ var currentCov = _rotateVariance(cosTheta, sinTheta, current.variance);
257
+ var boatCov = [[boatSpeed.xVariance, 0], [0, boatSpeed.yVariance]];
258
+
259
+ const observationCovariance = [[
260
+ groundCov[0][0] + currentCov[0][0] + boatCov[0][0],
261
+ groundCov[0][1] + currentCov[0][1] + boatCov[0][1]],
262
+ [
263
+ groundCov[1][0] + currentCov[1][0] + boatCov[1][0],
264
+ groundCov[1][1] + currentCov[1][1] + boatCov[1][1]],
265
+ ];
266
+ // Mahalanobis distance check.
267
+ // For empty cells (filterState === null) we use a diffuse prior — mean (0,0),
268
+ // variance DIFFUSE_PRIOR_VAR — expressing "assume no correction needed, but
269
+ // with high uncertainty". This gates the very first observation instead of
270
+ // accepting it unconditionally, preventing a single outlier from locking a
271
+ // cell at a bad value.
272
+ const DIFFUSE_PRIOR_VAR = 1.0; // (m/s)² — rejects corrections > ~3 m/s from zero
273
+ const priorMean = this.filterState !== null
274
+ ? [this.filterState.mean[0][0], this.filterState.mean[1][0]]
275
+ : [0, 0];
276
+ const priorCov = this.filterState !== null
277
+ ? this.filterState.covariance
278
+ : [[DIFFUSE_PRIOR_VAR, 0], [0, DIFFUSE_PRIOR_VAR]];
279
+ const inno = [observation[0] - priorMean[0], observation[1] - priorMean[1]];
280
+ const S = [
281
+ [priorCov[0][0] + observationCovariance[0][0],
282
+ priorCov[0][1] + observationCovariance[0][1]],
283
+ [priorCov[1][0] + observationCovariance[1][0],
284
+ priorCov[1][1] + observationCovariance[1][1]]
285
+ ];
286
+ const det = S[0][0] * S[1][1] - S[0][1] * S[1][0];
287
+ if (Number.isFinite(det) && det > 1e-12) {
288
+ const Sinv = [
289
+ [ S[1][1] / det, -S[0][1] / det],
290
+ [-S[1][0] / det, S[0][0] / det]
291
+ ];
292
+ const d2 = inno[0] * (Sinv[0][0] * inno[0] + Sinv[0][1] * inno[1])
293
+ + inno[1] * (Sinv[1][0] * inno[0] + Sinv[1][1] * inno[1]);
294
+ if (d2 > 9.21) return false;
295
+ }
296
+ this.filterState = this.filter.filter({ previousCorrected: this.filterState, observation, observationCovariance });
297
+ return true;
298
+ }
299
+
300
+
301
+ report() {
302
+ return { x: this.x, y: this.y, N: this.N };
303
+ }
304
+
305
+ get N() {
306
+ if (this.filterState == null) return 0;
307
+ return this.filterState.index;
308
+ }
309
+
310
+ get x() {
311
+ if (this.filterState == null) return 0;
312
+ return this.filterState.mean[0][0];
313
+ }
314
+
315
+ get y() {
316
+ if (this.filterState == null) return 0;
317
+ return this.filterState.mean[1][0];
318
+ }
319
+
320
+ get covariance() {
321
+ return this.filterState.covariance;
322
+ }
323
+
324
+
325
+ toJSON() {
326
+ return this.N != 0 ? { state: this.filterState } : { state: null };
327
+ }
328
+
329
+ }
330
+
331
+
332
+ module.exports = { CorrectionTable };
package/icon.png ADDED
Binary file