speedandcurrent 2.1.1 → 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.
- package/LICENSE +21 -21
- package/README.md +294 -294
- package/correctionTable.js +332 -332
- package/icon.png +0 -0
- package/index.js +780 -780
- package/package.json +50 -49
- package/public/TableRenderer.js +143 -143
- package/public/app.js +843 -843
- package/public/index.html +345 -345
- package/public/main.css +123 -123
package/correctionTable.js
CHANGED
|
@@ -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
|