speedandcurrent 2.1.1 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +296 -294
- package/correctionTable.js +332 -332
- package/icon.png +0 -0
- package/index.js +843 -780
- package/package.json +50 -49
- package/public/TableRenderer.js +143 -143
- package/public/app.js +876 -843
- package/public/index.html +346 -345
- package/public/main.css +123 -123
package/index.js
CHANGED
|
@@ -1,780 +1,843 @@
|
|
|
1
|
-
const path = require('path');
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
|
|
4
|
-
const {
|
|
5
|
-
SmoothedAngle,
|
|
6
|
-
SI,
|
|
7
|
-
MessageHandler,
|
|
8
|
-
Polar,
|
|
9
|
-
Reporter,
|
|
10
|
-
BaseSmoother,
|
|
11
|
-
MovingAverageSmoother,
|
|
12
|
-
ExponentialSmoother,
|
|
13
|
-
KalmanSmoother,
|
|
14
|
-
PolarSmoother,
|
|
15
|
-
createSmoothedPolar,
|
|
16
|
-
createSmoothedHandler,
|
|
17
|
-
Table2D
|
|
18
|
-
} = require('signalkutilities');
|
|
19
|
-
|
|
20
|
-
const { CorrectionTable } = require('./correctionTable.js');
|
|
21
|
-
|
|
22
|
-
module.exports = function (app) {
|
|
23
|
-
|
|
24
|
-
const DEFAULT_DIMS = { maxSpeed: 9, speedStep: 1, maxHeel: 32, heelStep: 8 };
|
|
25
|
-
|
|
26
|
-
let options = {};
|
|
27
|
-
let changedOptions = {};
|
|
28
|
-
let hasPendingChanges = false;
|
|
29
|
-
const defaultOptions = {
|
|
30
|
-
sogFallback: true,
|
|
31
|
-
estimateBoatSpeed: false,
|
|
32
|
-
updateCorrectionTable: true,
|
|
33
|
-
stability: 7,
|
|
34
|
-
assumeCurrent: false,
|
|
35
|
-
tableName: 'correctionTable',
|
|
36
|
-
configVersion: 2,
|
|
37
|
-
smootherClass: 'MovingAverageSmoother',
|
|
38
|
-
smootherTau: 3,
|
|
39
|
-
smootherTimeSpan: 5,
|
|
40
|
-
smootherSteadyState: 0.2,
|
|
41
|
-
showStatistics: false
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* -
|
|
86
|
-
* -
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
let
|
|
119
|
-
let
|
|
120
|
-
let
|
|
121
|
-
let
|
|
122
|
-
let
|
|
123
|
-
let
|
|
124
|
-
let
|
|
125
|
-
let
|
|
126
|
-
let
|
|
127
|
-
let
|
|
128
|
-
let
|
|
129
|
-
let
|
|
130
|
-
let
|
|
131
|
-
let
|
|
132
|
-
let
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
let
|
|
136
|
-
let
|
|
137
|
-
let
|
|
138
|
-
let
|
|
139
|
-
let
|
|
140
|
-
let
|
|
141
|
-
let
|
|
142
|
-
let
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
const
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
res.json({ name
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
SmoothedAngle,
|
|
6
|
+
SI,
|
|
7
|
+
MessageHandler,
|
|
8
|
+
Polar,
|
|
9
|
+
Reporter,
|
|
10
|
+
BaseSmoother,
|
|
11
|
+
MovingAverageSmoother,
|
|
12
|
+
ExponentialSmoother,
|
|
13
|
+
KalmanSmoother,
|
|
14
|
+
PolarSmoother,
|
|
15
|
+
createSmoothedPolar,
|
|
16
|
+
createSmoothedHandler,
|
|
17
|
+
Table2D
|
|
18
|
+
} = require('signalkutilities');
|
|
19
|
+
|
|
20
|
+
const { CorrectionTable } = require('./correctionTable.js');
|
|
21
|
+
|
|
22
|
+
module.exports = function (app) {
|
|
23
|
+
|
|
24
|
+
const DEFAULT_DIMS = { maxSpeed: 9, speedStep: 1, maxHeel: 32, heelStep: 8 };
|
|
25
|
+
|
|
26
|
+
let options = {};
|
|
27
|
+
let changedOptions = {};
|
|
28
|
+
let hasPendingChanges = false;
|
|
29
|
+
const defaultOptions = {
|
|
30
|
+
sogFallback: true,
|
|
31
|
+
estimateBoatSpeed: false,
|
|
32
|
+
updateCorrectionTable: true,
|
|
33
|
+
stability: 7,
|
|
34
|
+
assumeCurrent: false,
|
|
35
|
+
tableName: 'correctionTable',
|
|
36
|
+
configVersion: 2,
|
|
37
|
+
smootherClass: 'MovingAverageSmoother',
|
|
38
|
+
smootherTau: 3,
|
|
39
|
+
smootherTimeSpan: 5,
|
|
40
|
+
smootherSteadyState: 0.2,
|
|
41
|
+
showStatistics: false
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
function readOptions() {
|
|
47
|
+
const stored = app.readPluginOptions();
|
|
48
|
+
const raw = stored && stored.configuration ? stored.configuration : (stored || {});
|
|
49
|
+
// Strip embedded table — stored separately on disk
|
|
50
|
+
const { correctionTable: _drop, ...rest } = raw;
|
|
51
|
+
options = { ...defaultOptions, ...rest };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function saveOptions() {
|
|
55
|
+
app.savePluginOptions({ ...options }, (err) => {
|
|
56
|
+
if (err) app.error(`Error saving plugin options: ${err.message}`);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function saveTableName(name) {
|
|
61
|
+
options.tableName = name;
|
|
62
|
+
saveOptions();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Strips obsolete source-selection fields from the persisted config and
|
|
67
|
+
* writes it back if anything changed. Called once on every start().
|
|
68
|
+
*/
|
|
69
|
+
function migrateConfig() {
|
|
70
|
+
const obsoleteKeys = ['headingSource', 'boatSpeedSource', 'SOGSource', 'attitudeSource', 'preventDuplication'];
|
|
71
|
+
const hadObsolete = obsoleteKeys.some(k => k in options);
|
|
72
|
+
for (const k of obsoleteKeys) delete options[k];
|
|
73
|
+
if (hadObsolete || (options.configVersion || 0) < 2) {
|
|
74
|
+
options.configVersion = 2;
|
|
75
|
+
saveOptions();
|
|
76
|
+
app.debug('Config migrated to v2: removed obsolete source-selection fields');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Derives { SmootherClass, smootherOptions } from the current options.
|
|
82
|
+
* Enforces minimums so the smoother always has at least two observations for
|
|
83
|
+
* variance to be meaningful:
|
|
84
|
+
* - MovingAverageSmoother: timeSpan >= 2 s (≥ 2 samples at typical 1 Hz)
|
|
85
|
+
* - ExponentialSmoother: tau >= 1 s
|
|
86
|
+
* - KalmanSmoother: steadyState in [0.01, 0.99]
|
|
87
|
+
*/
|
|
88
|
+
function resolveSmootherConfig() {
|
|
89
|
+
const cls = options.smootherClass || 'MovingAverageSmoother';
|
|
90
|
+
if (cls === 'ExponentialSmoother') {
|
|
91
|
+
return {
|
|
92
|
+
SmootherClass: ExponentialSmoother,
|
|
93
|
+
smootherOptions: { timeConstant: Math.max(1, Number(options.smootherTau) || 3) }
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (cls === 'KalmanSmoother') {
|
|
97
|
+
const K = Math.min(0.99, Math.max(0.01, Number(options.smootherSteadyState) || 0.2));
|
|
98
|
+
return {
|
|
99
|
+
SmootherClass: KalmanSmoother,
|
|
100
|
+
smootherOptions: { steadyState: K }
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Default: MovingAverageSmoother
|
|
104
|
+
return {
|
|
105
|
+
SmootherClass: MovingAverageSmoother,
|
|
106
|
+
smootherOptions: { timeSpan: Math.max(2, Number(options.smootherTimeSpan) || 5) }
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function swapTable(newTable) {
|
|
111
|
+
table = newTable;
|
|
112
|
+
minSpeed = table.step[0] / 2;
|
|
113
|
+
if (reportFull) reportFull.setTables([table]);
|
|
114
|
+
lastSave = Date.now(); // explicit table operations already save; defer next periodic save
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let isRunning = false;
|
|
118
|
+
let pluginStatus = 'Stopped';
|
|
119
|
+
let smoothedHeading = null;
|
|
120
|
+
let smoothedAttitude = null;
|
|
121
|
+
let rawCurrent = null;
|
|
122
|
+
let smoothedCurrent = null;
|
|
123
|
+
let smoothedBoatSpeed = null;
|
|
124
|
+
let correctedBoatSpeed = null;
|
|
125
|
+
let lrnBoatSpeed = null;
|
|
126
|
+
let boatSpeedRefGround = null;
|
|
127
|
+
let smoothedGroundSpeed = null;
|
|
128
|
+
let speedCorrection = null;
|
|
129
|
+
let residual = null;
|
|
130
|
+
let smoothedResidual = null;
|
|
131
|
+
let reportFull = null;
|
|
132
|
+
let table = null;
|
|
133
|
+
|
|
134
|
+
let rawHeading = null;
|
|
135
|
+
let rawAttitude = null;
|
|
136
|
+
let noCurrent = null;
|
|
137
|
+
let rawBoatSpeed = null;
|
|
138
|
+
let rawGroundSpeed = null;
|
|
139
|
+
let started = null;
|
|
140
|
+
let minSpeed = 0;
|
|
141
|
+
let lastSave = 0;
|
|
142
|
+
let lifecycleWarningMap = new Map();
|
|
143
|
+
let lifecycleWarnings = [];
|
|
144
|
+
|
|
145
|
+
function setLifecycleWarning(id, status, path) {
|
|
146
|
+
const safePath = path || 'unknown path';
|
|
147
|
+
const message = status === 'idle'
|
|
148
|
+
? `Input ${id} is idle on ${safePath}; resubscribing`
|
|
149
|
+
: `Input ${id} is stale on ${safePath}`;
|
|
150
|
+
lifecycleWarningMap.set(id, {
|
|
151
|
+
id,
|
|
152
|
+
status,
|
|
153
|
+
path: safePath,
|
|
154
|
+
message,
|
|
155
|
+
updatedAt: Date.now()
|
|
156
|
+
});
|
|
157
|
+
lifecycleWarnings = Array.from(lifecycleWarningMap.values())
|
|
158
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function clearLifecycleWarning(id) {
|
|
162
|
+
if (!lifecycleWarningMap.has(id)) return;
|
|
163
|
+
lifecycleWarningMap.delete(id);
|
|
164
|
+
lifecycleWarnings = Array.from(lifecycleWarningMap.values())
|
|
165
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function buildLifecycleCallbacks(id, getPath, resubscribe) {
|
|
169
|
+
return {
|
|
170
|
+
onDelta: () => {
|
|
171
|
+
clearLifecycleWarning(id);
|
|
172
|
+
},
|
|
173
|
+
onStale: () => {
|
|
174
|
+
if (!isRunning) return;
|
|
175
|
+
const path = getPath();
|
|
176
|
+
app.debug(`[${plugin.id}] stale input ${id} on ${path}`);
|
|
177
|
+
setLifecycleWarning(id, 'stale', path);
|
|
178
|
+
},
|
|
179
|
+
onIdle: () => {
|
|
180
|
+
if (!isRunning) return;
|
|
181
|
+
const path = getPath();
|
|
182
|
+
app.debug(`[${plugin.id}] idle input ${id} on ${path}; resubscribing`);
|
|
183
|
+
setLifecycleWarning(id, 'idle', path);
|
|
184
|
+
try {
|
|
185
|
+
resubscribe();
|
|
186
|
+
} catch (e) {
|
|
187
|
+
app.debug(`[${plugin.id}] resubscribe failed for ${id}: ${e.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const plugin = {};
|
|
194
|
+
plugin.id = "SpeedAndCurrent";
|
|
195
|
+
plugin.name = "Speed and current";
|
|
196
|
+
plugin.description = "A plugin that uses sensor fusion to get boat speed, current and leeway.";
|
|
197
|
+
|
|
198
|
+
plugin.schema = {
|
|
199
|
+
type: "object",
|
|
200
|
+
description: "Speed and Current is configured through its own webapp. Open it from the Signal K app list.",
|
|
201
|
+
properties: {}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
plugin.registerWithRouter = function (router) {
|
|
206
|
+
app.debug('registerWithRouter');
|
|
207
|
+
readOptions(); // pre-load so /api/settings works before start()
|
|
208
|
+
|
|
209
|
+
router.get('/api/report', (req, res) => {
|
|
210
|
+
if (!isRunning) {
|
|
211
|
+
res.status(503).json({ error: "Plugin is not running" });
|
|
212
|
+
} else {
|
|
213
|
+
const payload = reportFull.report();
|
|
214
|
+
payload.lifecycleWarnings = lifecycleWarnings;
|
|
215
|
+
res.json(payload);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
router.get('/api/meta', (req, res) => {
|
|
220
|
+
if (!isRunning) {
|
|
221
|
+
res.status(503).json({ error: "Plugin is not running" });
|
|
222
|
+
} else {
|
|
223
|
+
res.json(reportFull.meta());
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
router.get('/api/status', (req, res) => {
|
|
230
|
+
res.json({ status: pluginStatus, isRunning, lifecycleWarnings });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// --- Settings API ---
|
|
234
|
+
router.get('/api/settings', (req, res) => {
|
|
235
|
+
res.json({ ...options, ...changedOptions });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
router.put('/api/settings', (req, res) => {
|
|
239
|
+
const body = req.body;
|
|
240
|
+
if (!body || typeof body !== 'object') {
|
|
241
|
+
return res.status(400).json({ error: 'JSON body required' });
|
|
242
|
+
}
|
|
243
|
+
// Reject keys managed by the table manager
|
|
244
|
+
const blocked = ['correctionTable', 'tableName'];
|
|
245
|
+
for (const k of blocked) {
|
|
246
|
+
if (k in body) {
|
|
247
|
+
return res.status(400).json({ error: `Key '${k}' is managed via the table manager` });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
changedOptions = { ...changedOptions, ...body };
|
|
251
|
+
hasPendingChanges = true;
|
|
252
|
+
res.json({ ...options, ...changedOptions });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// --- Correction Table Manager API ---
|
|
256
|
+
|
|
257
|
+
// List all table files in dataDir
|
|
258
|
+
router.get('/api/tables', (req, res) => {
|
|
259
|
+
const dataDir = app.getDataDirPath();
|
|
260
|
+
let files;
|
|
261
|
+
try { files = fs.readdirSync(dataDir).filter(f => f.endsWith('.json')); }
|
|
262
|
+
catch (e) { return res.json([]); }
|
|
263
|
+
const activeName = options.tableName || 'correctionTable';
|
|
264
|
+
const tables = [];
|
|
265
|
+
for (const file of files) {
|
|
266
|
+
try {
|
|
267
|
+
const data = Table2D.readFromFile(path.join(dataDir, file));
|
|
268
|
+
if (data && data.row && data.col && Array.isArray(data.table)) {
|
|
269
|
+
const name = file.replace(/\.json$/, '');
|
|
270
|
+
tables.push({ name, active: name === activeName });
|
|
271
|
+
}
|
|
272
|
+
} catch (e) { /* skip non-table files */ }
|
|
273
|
+
}
|
|
274
|
+
res.json(tables);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Create a new table and hot-swap it
|
|
278
|
+
router.post('/api/tables/create', (req, res) => {
|
|
279
|
+
const body = req.body || {};
|
|
280
|
+
const name = (body.name || '').trim();
|
|
281
|
+
if (!name || !/^[\w-]+$/.test(name))
|
|
282
|
+
return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
|
|
283
|
+
const dims = ['maxSpeed', 'speedStep', 'maxHeel', 'heelStep'];
|
|
284
|
+
for (const f of dims) {
|
|
285
|
+
if (!Number.isFinite(body[f]) || body[f] <= 0)
|
|
286
|
+
return res.status(400).json({ error: `Invalid or missing field: ${f}` });
|
|
287
|
+
}
|
|
288
|
+
const row = { min: 0, max: body.maxSpeed, step: body.speedStep };
|
|
289
|
+
const col = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
|
|
290
|
+
const newTable = new CorrectionTable(name, row, col, options.stability || 7);
|
|
291
|
+
newTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
|
|
292
|
+
saveTable(newTable, path.join(app.getDataDirPath(), name + '.json'));
|
|
293
|
+
if (isRunning) swapTable(newTable);
|
|
294
|
+
saveTableName(name);
|
|
295
|
+
res.json({ name });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Load a saved table and make it active
|
|
299
|
+
router.post('/api/tables/load', (req, res) => {
|
|
300
|
+
const body = req.body || {};
|
|
301
|
+
const name = (body.name || '').trim();
|
|
302
|
+
if (!name || !/^[\w-]+$/.test(name))
|
|
303
|
+
return res.status(400).json({ error: 'Invalid table name' });
|
|
304
|
+
const filePath = path.join(app.getDataDirPath(), name + '.json');
|
|
305
|
+
const fileData = Table2D.readFromFile(filePath);
|
|
306
|
+
if (!fileData) return res.status(404).json({ error: `Table '${name}' not found` });
|
|
307
|
+
const loadedTable = CorrectionTable.fromJSON(fileData, options.stability || 7);
|
|
308
|
+
loadedTable.setDisplayAttributes({ label: name }); // Table2D API unchanged
|
|
309
|
+
if (isRunning) swapTable(loadedTable);
|
|
310
|
+
saveTableName(name);
|
|
311
|
+
res.json({ name });
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Copy active table under a new name and hot-swap to it
|
|
315
|
+
router.post('/api/tables/copy', (req, res) => {
|
|
316
|
+
if (!isRunning || !table) return res.status(503).json({ error: 'Plugin is not running' });
|
|
317
|
+
const body = req.body || {};
|
|
318
|
+
const newName = (body.newName || '').trim();
|
|
319
|
+
if (!newName || !/^[\w-]+$/.test(newName))
|
|
320
|
+
return res.status(400).json({ error: 'Name must be alphanumeric (underscores and hyphens allowed)' });
|
|
321
|
+
const data = table.toJSON();
|
|
322
|
+
data.id = newName;
|
|
323
|
+
const copiedTable = CorrectionTable.fromJSON(data, options.stability || 7);
|
|
324
|
+
copiedTable.setDisplayAttributes({ label: newName }); // Table2D API unchanged
|
|
325
|
+
saveTable(copiedTable, path.join(app.getDataDirPath(), newName + '.json'));
|
|
326
|
+
swapTable(copiedTable);
|
|
327
|
+
saveTableName(newName);
|
|
328
|
+
res.json({ name: newName });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Resize the active table (resamples onto new grid, preserves name)
|
|
332
|
+
router.post('/api/tables/resize', (req, res) => {
|
|
333
|
+
if (!isRunning || !table) return res.status(503).json({ error: 'Plugin is not running' });
|
|
334
|
+
const body = req.body || {};
|
|
335
|
+
const dims = ['maxSpeed', 'speedStep', 'maxHeel', 'heelStep'];
|
|
336
|
+
for (const f of dims) {
|
|
337
|
+
if (!Number.isFinite(body[f]) || body[f] <= 0)
|
|
338
|
+
return res.status(400).json({ error: `Invalid or missing field: ${f}` });
|
|
339
|
+
}
|
|
340
|
+
const newRow = { min: 0, max: body.maxSpeed, step: body.speedStep };
|
|
341
|
+
const newCol = { min: -body.maxHeel, max: body.maxHeel, step: body.heelStep };
|
|
342
|
+
const resized = CorrectionTable.resampleFromJSON(table.toJSON(), newRow, newCol, options.stability || 7, 1e-4);
|
|
343
|
+
resized.setDisplayAttributes({ label: resized.id }); // Table2D API unchanged
|
|
344
|
+
saveTable(resized, path.join(app.getDataDirPath(), resized.id + '.json'));
|
|
345
|
+
swapTable(resized);
|
|
346
|
+
res.json({ name: resized.id });
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function setStatus(msg) {
|
|
352
|
+
pluginStatus = msg;
|
|
353
|
+
app.setPluginStatus(msg);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
plugin.start = (settings) => {
|
|
357
|
+
setStatus('Starting');
|
|
358
|
+
app.debug("Starting");
|
|
359
|
+
lifecycleWarningMap = new Map();
|
|
360
|
+
lifecycleWarnings = [];
|
|
361
|
+
readOptions(); // pick up any saves since registerWithRouter ran
|
|
362
|
+
migrateConfig(); // strip obsolete fields from persisted config
|
|
363
|
+
const tableName = options.tableName || 'correctionTable';
|
|
364
|
+
const tableFilePath = path.join(app.getDataDirPath(), tableName + '.json');
|
|
365
|
+
table = loadTable(options, tableFilePath);
|
|
366
|
+
minSpeed = table.step[0] / 2;
|
|
367
|
+
|
|
368
|
+
//#region Handler and Polar Initialization
|
|
369
|
+
const { SmootherClass, smootherOptions } = resolveSmootherConfig();
|
|
370
|
+
|
|
371
|
+
// heading
|
|
372
|
+
smoothedHeading = new SmoothedAngle(app, plugin.id, 'heading', 'navigation.headingTrue', {
|
|
373
|
+
angleRange: '0to2pi',
|
|
374
|
+
meta: { displayName: 'Heading', plane: 'Ground' },
|
|
375
|
+
SmootherClass,
|
|
376
|
+
smootherOptions,
|
|
377
|
+
...buildLifecycleCallbacks(
|
|
378
|
+
'heading.angle',
|
|
379
|
+
() => smoothedHeading?.handler?.path || 'navigation.headingTrue',
|
|
380
|
+
() => { smoothedHeading?.unsubscribe(); smoothedHeading?.subscribe(false, true); }
|
|
381
|
+
)
|
|
382
|
+
});
|
|
383
|
+
rawHeading = smoothedHeading.handler;
|
|
384
|
+
|
|
385
|
+
//attitude
|
|
386
|
+
smoothedAttitude = createSmoothedHandler({
|
|
387
|
+
app, pluginId: plugin.id,
|
|
388
|
+
id: 'attitude',
|
|
389
|
+
path: 'navigation.attitude',
|
|
390
|
+
subscribe: true,
|
|
391
|
+
SmootherClass,
|
|
392
|
+
smootherOptions,
|
|
393
|
+
...buildLifecycleCallbacks(
|
|
394
|
+
'attitude.smoothed',
|
|
395
|
+
() => smoothedAttitude?.handler?.path || 'navigation.attitude',
|
|
396
|
+
() => { smoothedAttitude?.unsubscribe(); smoothedAttitude?.subscribe(); }
|
|
397
|
+
)
|
|
398
|
+
});
|
|
399
|
+
rawAttitude = smoothedAttitude.handler;
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
// current
|
|
403
|
+
// send metadata for current
|
|
404
|
+
MessageHandler.setMeta(app, plugin.id, "environment.current.drift", {units: "m/s", type: "number", description: "Speed of the current"});
|
|
405
|
+
MessageHandler.setMeta(app, plugin.id, "environment.current.setTrue", { units: "rad", type: "number", description: "Direction of the current" });
|
|
406
|
+
rawCurrent = new Polar(app, plugin.id, "current");
|
|
407
|
+
rawCurrent.configureMagnitude("environment.current.drift");
|
|
408
|
+
rawCurrent.configureAngle("environment.current.setTrue");
|
|
409
|
+
rawCurrent.setMeta({ displayName: "Current", plane: "Ground" });
|
|
410
|
+
rawCurrent.setAngleRange('0to2pi');
|
|
411
|
+
smoothedCurrent = new PolarSmoother(rawCurrent, KalmanSmoother, { processVariance: 0.000001, measurementVariance: 0.01 }); // id auto-derived: 'current.smoothed'
|
|
412
|
+
smoothedCurrent.setAngleRange('0to2pi');
|
|
413
|
+
// Current should be initialised as no current
|
|
414
|
+
rawCurrent.setVectorValue({ x: 0, y: 0 });
|
|
415
|
+
// Strongly assume no current at start
|
|
416
|
+
smoothedCurrent.xSmoother.reset(0, 0.00000001);
|
|
417
|
+
smoothedCurrent.ySmoother.reset(0, 0.00000001);
|
|
418
|
+
// no current
|
|
419
|
+
noCurrent = createSmoothedPolar({
|
|
420
|
+
id: "noCurrent",
|
|
421
|
+
pathMagnitude: "environment.current.drift",
|
|
422
|
+
pathAngle: "environment.current.setTrue",
|
|
423
|
+
subscribe: false,
|
|
424
|
+
app,
|
|
425
|
+
pluginId: plugin.id,
|
|
426
|
+
SmootherClass: BaseSmoother,
|
|
427
|
+
smootherOptions: smootherOptions,
|
|
428
|
+
meta: { displayName: "NoCurrent", plane: "Ground" },
|
|
429
|
+
});
|
|
430
|
+
noCurrent.xSmoother.reset(0,0);
|
|
431
|
+
noCurrent.ySmoother.reset(0,0);
|
|
432
|
+
PolarSmoother.send(app, plugin.id, [noCurrent]);
|
|
433
|
+
|
|
434
|
+
MessageHandler.setMeta(app, plugin.id, 'navigation.leewayAngle', {
|
|
435
|
+
units: 'rad',
|
|
436
|
+
description: 'Leeway Angle',
|
|
437
|
+
displayUnits: {
|
|
438
|
+
category: 'angle'
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// boatspeed
|
|
443
|
+
smoothedBoatSpeed = createSmoothedHandler({
|
|
444
|
+
app, pluginId: plugin.id,
|
|
445
|
+
id: 'boatSpeed',
|
|
446
|
+
path: 'navigation.speedThroughWater',
|
|
447
|
+
subscribe: true,
|
|
448
|
+
SmootherClass,
|
|
449
|
+
smootherOptions,
|
|
450
|
+
onDelta: () => {
|
|
451
|
+
clearLifecycleWarning('boatSpeed.smoothed');
|
|
452
|
+
// Drain any pending option changes before calculating
|
|
453
|
+
if (Object.keys(changedOptions).length) applyOptionChanges();
|
|
454
|
+
|
|
455
|
+
const wellUnderway = started < new Date() - 60 * 1000;
|
|
456
|
+
|
|
457
|
+
setStatus(wellUnderway ? 'Running' : 'Stabilizing');
|
|
458
|
+
if (options.estimateBoatSpeed) correct(wellUnderway);
|
|
459
|
+
if (options.updateCorrectionTable) {
|
|
460
|
+
if (wellUnderway) {
|
|
461
|
+
updateTable();
|
|
462
|
+
// Save correction table periodically (skip if table is empty to avoid overwriting good data on disk)
|
|
463
|
+
const now = Date.now();
|
|
464
|
+
if (now - lastSave > 60_000) {
|
|
465
|
+
if (!isTableEmpty(table)) {
|
|
466
|
+
saveTable(table, path.join(app.getDataDirPath(), table.id + '.json'));
|
|
467
|
+
}
|
|
468
|
+
lastSave = now;
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
table.lastUpdateResult = 'stabilizing';
|
|
472
|
+
}
|
|
473
|
+
} else {
|
|
474
|
+
table.lastUpdateResult = null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
,
|
|
478
|
+
onIdle: () => {
|
|
479
|
+
if (!isRunning) return;
|
|
480
|
+
const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
|
|
481
|
+
app.debug(`[${plugin.id}] idle input boatSpeed.smoothed on ${path}; resubscribing`);
|
|
482
|
+
setLifecycleWarning('boatSpeed.smoothed', 'idle', path);
|
|
483
|
+
smoothedBoatSpeed?.unsubscribe();
|
|
484
|
+
smoothedBoatSpeed?.subscribe();
|
|
485
|
+
},
|
|
486
|
+
onStale: () => {
|
|
487
|
+
if (!isRunning) return;
|
|
488
|
+
const path = smoothedBoatSpeed?.handler?.path || 'navigation.speedThroughWater';
|
|
489
|
+
app.debug(`[${plugin.id}] stale input boatSpeed.smoothed on ${path}`);
|
|
490
|
+
setLifecycleWarning('boatSpeed.smoothed', 'stale', path);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
rawBoatSpeed = smoothedBoatSpeed.handler;
|
|
494
|
+
|
|
495
|
+
// Learning polar — used for updating the correction table
|
|
496
|
+
lrnBoatSpeed = new Polar(app, plugin.id, "lrnBoatSpeed");
|
|
497
|
+
lrnBoatSpeed.configureMagnitude("navigation.speedThroughWater");
|
|
498
|
+
lrnBoatSpeed.configureAngle("navigation.leewayAngle");
|
|
499
|
+
lrnBoatSpeed.setMeta({ displayName: "Learning boat speed", plane: "Boat" });
|
|
500
|
+
lrnBoatSpeed.setAngleRange('-piToPi');
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
// corrected boatspeed holds both corrected boatspeed and estimated leeway
|
|
504
|
+
correctedBoatSpeed = new Polar(app, plugin.id, "correctedBoatSpeed");
|
|
505
|
+
correctedBoatSpeed.configureMagnitude("navigation.speedThroughWater");
|
|
506
|
+
correctedBoatSpeed.configureAngle("navigation.leewayAngle");
|
|
507
|
+
correctedBoatSpeed.setMeta({ displayName: "Corrected boatspeed / Leeway", plane: "Boat" });
|
|
508
|
+
|
|
509
|
+
// boatspeed vector in ground frame, used for current estimation and residual calculation
|
|
510
|
+
boatSpeedRefGround = new Polar(app, plugin.id, "boatSpeedRefGround");
|
|
511
|
+
boatSpeedRefGround.setMeta({ displayName: "Boat speed over ground", plane: "Ground" });
|
|
512
|
+
|
|
513
|
+
// ground speed
|
|
514
|
+
smoothedGroundSpeed = createSmoothedPolar({
|
|
515
|
+
app, pluginId: plugin.id,
|
|
516
|
+
id: 'groundSpeed',
|
|
517
|
+
pathMagnitude: 'navigation.speedOverGround',
|
|
518
|
+
pathAngle: 'navigation.courseOverGroundTrue',
|
|
519
|
+
angleRange: '0to2pi',
|
|
520
|
+
meta: { displayName: 'Groundspeed', plane: 'Ground' },
|
|
521
|
+
SmootherClass,
|
|
522
|
+
smootherOptions,
|
|
523
|
+
...buildLifecycleCallbacks(
|
|
524
|
+
'groundSpeed.smoothed',
|
|
525
|
+
() => `${smoothedGroundSpeed?.polar?.pathMagnitude || 'navigation.speedOverGround'}, ${smoothedGroundSpeed?.polar?.pathAngle || 'navigation.courseOverGroundTrue'}`,
|
|
526
|
+
() => { smoothedGroundSpeed?.unsubscribe(); smoothedGroundSpeed?.subscribe(true, true); }
|
|
527
|
+
)
|
|
528
|
+
});
|
|
529
|
+
rawGroundSpeed = smoothedGroundSpeed.polar;
|
|
530
|
+
|
|
531
|
+
// correction vector
|
|
532
|
+
speedCorrection = new Polar(app, plugin.id, "speedCorrection");
|
|
533
|
+
speedCorrection.setMeta({ displayName: "Speed correction", plane: "Boat" });
|
|
534
|
+
|
|
535
|
+
// residual
|
|
536
|
+
residual = new Polar(app, plugin.id, "residual");
|
|
537
|
+
residual.setMeta({ displayName: "Residual", plane: "Ground" });
|
|
538
|
+
smoothedResidual = new PolarSmoother(residual, ExponentialSmoother, { tau: 30, timeSpan: 30 }); // id auto-derived: 'residual.smoothed'
|
|
539
|
+
smoothedResidual.setAngleRange('0to2pi');
|
|
540
|
+
|
|
541
|
+
//#endregion
|
|
542
|
+
|
|
543
|
+
//#region Reporting
|
|
544
|
+
reportFull = new Reporter();
|
|
545
|
+
|
|
546
|
+
if (options.estimateBoatSpeed) {
|
|
547
|
+
reportFull.addDelta(rawHeading);
|
|
548
|
+
reportFull.addAttitude(rawAttitude);
|
|
549
|
+
reportFull.addDelta(rawBoatSpeed);
|
|
550
|
+
reportFull.addPolar(speedCorrection);
|
|
551
|
+
reportFull.addPolar(boatSpeedRefGround);
|
|
552
|
+
reportFull.addPolar(correctedBoatSpeed);
|
|
553
|
+
reportFull.addPolar(rawGroundSpeed);
|
|
554
|
+
reportFull.addPolar(smoothedCurrent);
|
|
555
|
+
reportFull.addPolar(residual);
|
|
556
|
+
reportFull.addPolar(smoothedResidual);
|
|
557
|
+
}
|
|
558
|
+
// Smoothed learning inputs are always subscribed regardless of updateCorrectionTable,
|
|
559
|
+
// so always add them to the report — otherwise warnings never clear when
|
|
560
|
+
// learning was disabled at startup and later toggled on.
|
|
561
|
+
reportFull.addDelta(smoothedHeading);
|
|
562
|
+
reportFull.addAttitude(smoothedAttitude);
|
|
563
|
+
reportFull.addDelta(smoothedBoatSpeed);
|
|
564
|
+
reportFull.addPolar(smoothedGroundSpeed);
|
|
565
|
+
// smoothedCurrent is already added by the estimateBoatSpeed block when that is on;
|
|
566
|
+
// only add it here when estimateBoatSpeed is off, to avoid a duplicate entry.
|
|
567
|
+
if (options.assumeCurrent && !options.estimateBoatSpeed) {
|
|
568
|
+
reportFull.addPolar(smoothedCurrent);
|
|
569
|
+
}
|
|
570
|
+
reportFull.addTable(table);
|
|
571
|
+
|
|
572
|
+
//#endregion
|
|
573
|
+
|
|
574
|
+
isRunning = true;
|
|
575
|
+
started = Date.now();
|
|
576
|
+
lastSave = 0;
|
|
577
|
+
setStatus('Running');
|
|
578
|
+
app.debug("Running");
|
|
579
|
+
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
plugin.stop = () => {
|
|
583
|
+
return new Promise((resolve, reject) => {
|
|
584
|
+
try {
|
|
585
|
+
if (table && !isTableEmpty(table)) {
|
|
586
|
+
saveTableSync(table, path.join(app.getDataDirPath(), table.id + '.json'));
|
|
587
|
+
}
|
|
588
|
+
// Clear all active output paths from the SK bus before teardown.
|
|
589
|
+
if (smoothedCurrent) PolarSmoother.clear(app, plugin.id, [smoothedCurrent]);
|
|
590
|
+
if (options.estimateBoatSpeed && correctedBoatSpeed) Polar.clear(app, plugin.id, [correctedBoatSpeed]);
|
|
591
|
+
smoothedHeading = smoothedHeading?.terminate();
|
|
592
|
+
smoothedAttitude = smoothedAttitude?.terminate();
|
|
593
|
+
rawCurrent = rawCurrent?.terminate();
|
|
594
|
+
smoothedCurrent = smoothedCurrent?.terminate?.();
|
|
595
|
+
smoothedBoatSpeed = smoothedBoatSpeed?.terminate();
|
|
596
|
+
correctedBoatSpeed = correctedBoatSpeed?.terminate();
|
|
597
|
+
lrnBoatSpeed = lrnBoatSpeed?.terminate();
|
|
598
|
+
boatSpeedRefGround = boatSpeedRefGround?.terminate();
|
|
599
|
+
smoothedGroundSpeed = smoothedGroundSpeed?.terminate();
|
|
600
|
+
speedCorrection = speedCorrection?.terminate();
|
|
601
|
+
residual = residual?.terminate();
|
|
602
|
+
smoothedResidual = smoothedResidual?.terminate?.();
|
|
603
|
+
reportFull = null;
|
|
604
|
+
table = null;
|
|
605
|
+
rawHeading = null;
|
|
606
|
+
rawAttitude = null;
|
|
607
|
+
noCurrent = null;
|
|
608
|
+
rawBoatSpeed = null;
|
|
609
|
+
rawGroundSpeed = null;
|
|
610
|
+
started = null;
|
|
611
|
+
app.setPluginStatus("Stopped");
|
|
612
|
+
app.debug("Stopped");
|
|
613
|
+
|
|
614
|
+
pluginStatus = 'Stopped';
|
|
615
|
+
isRunning = false;
|
|
616
|
+
lifecycleWarningMap = new Map();
|
|
617
|
+
lifecycleWarnings = [];
|
|
618
|
+
resolve();
|
|
619
|
+
} catch (error) {
|
|
620
|
+
reject(error);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Corrects and publishes boat speed. Handles SOG fallback and missing-input
|
|
627
|
+
* silent fallback so that navigation.speedThroughWater is always written
|
|
628
|
+
* when estimateBoatSpeed is on, as long as any usable speed source exists.
|
|
629
|
+
*
|
|
630
|
+
* Priority order:
|
|
631
|
+
* 1. STW zero + sogFallback enabled + SOG available → publish SOG magnitude
|
|
632
|
+
* 2. attitude/heading not ready → publish raw STW unchanged (silent fallback)
|
|
633
|
+
* 3. All inputs ready → apply table correction; estimate current if wellUnderway
|
|
634
|
+
*
|
|
635
|
+
* @param {boolean} wellUnderway - Gates current estimation (requires 60 s settling).
|
|
636
|
+
*/
|
|
637
|
+
function correct(wellUnderway) {
|
|
638
|
+
// copy the boatspeed delta to polar
|
|
639
|
+
correctedBoatSpeed.setVectorValue({ x: rawBoatSpeed.value, y: 0 });
|
|
640
|
+
|
|
641
|
+
speedCorrection.setVectorValue({ x: 0, y: 0 });
|
|
642
|
+
|
|
643
|
+
// Priority 1: SOG fallback when STW is stuck at zero
|
|
644
|
+
// (onChange only fires when STW is ready, so a missing STW cannot reach here)
|
|
645
|
+
if (options.sogFallback && rawGroundSpeed.ready && correctedBoatSpeed.magnitude === 0 && rawGroundSpeed.magnitude >= minSpeed) {
|
|
646
|
+
correctedBoatSpeed.setVectorValue({ x: rawGroundSpeed.magnitude, y: 0 });
|
|
647
|
+
}
|
|
648
|
+
else if (rawAttitude.ready) {
|
|
649
|
+
if (correctedBoatSpeed.magnitude > 0) {
|
|
650
|
+
const { correction, variance } = table.getCorrection(correctedBoatSpeed.magnitude, rawAttitude.value?.roll);
|
|
651
|
+
speedCorrection.setVectorValue(correction, variance);
|
|
652
|
+
correctedBoatSpeed.add(speedCorrection);
|
|
653
|
+
}
|
|
654
|
+
// Current estimation and residual also require heading (to rotate into ground frame).
|
|
655
|
+
// Also handle near-zero SOG where COG is unavailable: treat groundspeed as zero vector.
|
|
656
|
+
const sogHandler = rawGroundSpeed.magnitudeHandler;
|
|
657
|
+
const nearZeroGroundSpeed = !rawGroundSpeed.ready &&
|
|
658
|
+
sogHandler.ready &&
|
|
659
|
+
Number.isFinite(sogHandler.value) &&
|
|
660
|
+
sogHandler.value < 0.3;
|
|
661
|
+
if (rawHeading.ready && (rawGroundSpeed.ready || nearZeroGroundSpeed)) {
|
|
662
|
+
boatSpeedRefGround.copyFrom(correctedBoatSpeed);
|
|
663
|
+
boatSpeedRefGround.rotate(rawHeading.value);
|
|
664
|
+
// Current estimation gated by wellUnderway (smoothers need to settle first)
|
|
665
|
+
if (wellUnderway) {
|
|
666
|
+
if (rawGroundSpeed.ready) {
|
|
667
|
+
rawCurrent.copyFrom(rawGroundSpeed);
|
|
668
|
+
} else {
|
|
669
|
+
// COG unavailable but SOG is near-zero: boat is stationary, groundspeed treated as zero
|
|
670
|
+
rawCurrent.setVectorValue({ x: 0, y: 0 });
|
|
671
|
+
}
|
|
672
|
+
rawCurrent.substract(boatSpeedRefGround);
|
|
673
|
+
smoothedCurrent.sample();
|
|
674
|
+
}
|
|
675
|
+
// Residual requires full groundspeed (both SOG and COG)
|
|
676
|
+
if (rawGroundSpeed.ready) {
|
|
677
|
+
residual.copyFrom(rawGroundSpeed);
|
|
678
|
+
residual.substract(boatSpeedRefGround);
|
|
679
|
+
residual.substract(smoothedCurrent);
|
|
680
|
+
smoothedResidual.sample();
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
// Implicit fallback: attitude not ready — correctedBoatSpeed = raw STW, no correction
|
|
685
|
+
|
|
686
|
+
PolarSmoother.send(app, plugin.id, [smoothedCurrent, smoothedResidual]);
|
|
687
|
+
|
|
688
|
+
Polar.send(app, plugin.id, [correctedBoatSpeed]);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Updates the correction table from the current smoothed inputs.
|
|
693
|
+
* Reads assumeCurrent and minSpeed from module-level options/state;
|
|
694
|
+
* silently returns if any required input is not yet ready.
|
|
695
|
+
*/
|
|
696
|
+
function updateTable() {
|
|
697
|
+
lrnBoatSpeed.setVectorValue({ x: smoothedBoatSpeed.value, y: 0 }, { x: smoothedBoatSpeed.variance ?? 0, y: 0 });
|
|
698
|
+
if (!smoothedAttitude.ready || !smoothedBoatSpeed.ready || !smoothedHeading.ready || !smoothedGroundSpeed.ready || (options.assumeCurrent && !smoothedCurrent.ready)) {
|
|
699
|
+
table.lastUpdateResult = 'invalid';
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// update correction table
|
|
704
|
+
if (smoothedBoatSpeed.value > minSpeed) {
|
|
705
|
+
table.update(smoothedBoatSpeed.value, smoothedAttitude.value?.roll, smoothedGroundSpeed, options.assumeCurrent ? smoothedCurrent : noCurrent, lrnBoatSpeed, smoothedHeading.value);
|
|
706
|
+
} else {
|
|
707
|
+
table.lastUpdateResult = 'waiting';
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Loads or creates a correction table from disk.
|
|
714
|
+
* If the file exists it is deserialized; otherwise a new table is created
|
|
715
|
+
* with default dimensions and saved to disk.
|
|
716
|
+
*
|
|
717
|
+
* @param {Object} options - Plugin options (stability, tableName, and dimension defaults).
|
|
718
|
+
* @param {string} filePath - Absolute path of the JSON file to read.
|
|
719
|
+
* @returns {CorrectionTable} The loaded or newly created CorrectionTable instance.
|
|
720
|
+
*/
|
|
721
|
+
function loadTable(options, filePath) {
|
|
722
|
+
const stability = (options.stability !== undefined) ? options.stability : 6;
|
|
723
|
+
let fileData = Table2D.readFromFile(filePath);
|
|
724
|
+
let table;
|
|
725
|
+
if (fileData) {
|
|
726
|
+
table = CorrectionTable.fromJSON(fileData, stability);
|
|
727
|
+
app.debug("Correction table loaded: " + (fileData.id || filePath));
|
|
728
|
+
} else if (fs.existsSync(filePath)) {
|
|
729
|
+
// File is present but could not be read — transient lock or parse error. Retry once.
|
|
730
|
+
app.error(`Correction table file exists but could not be read, retrying: ${filePath}`);
|
|
731
|
+
fileData = Table2D.readFromFile(filePath);
|
|
732
|
+
if (fileData) {
|
|
733
|
+
table = CorrectionTable.fromJSON(fileData, stability);
|
|
734
|
+
app.debug("Correction table loaded on retry: " + (fileData.id || filePath));
|
|
735
|
+
} else {
|
|
736
|
+
app.error(`Correction table retry failed — starting with empty table. Disk file preserved: ${filePath}`);
|
|
737
|
+
const name = options.tableName || 'correctionTable';
|
|
738
|
+
const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
|
|
739
|
+
const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
|
|
740
|
+
table = new CorrectionTable(name, row, col, stability);
|
|
741
|
+
}
|
|
742
|
+
} else {
|
|
743
|
+
const name = options.tableName || 'correctionTable';
|
|
744
|
+
const row = { min: 0, max: SI.fromKnots(DEFAULT_DIMS.maxSpeed), step: SI.fromKnots(DEFAULT_DIMS.speedStep) };
|
|
745
|
+
const col = { min: -SI.fromDegrees(DEFAULT_DIMS.maxHeel), max: SI.fromDegrees(DEFAULT_DIMS.maxHeel), step: SI.fromDegrees(DEFAULT_DIMS.heelStep) };
|
|
746
|
+
table = new CorrectionTable(name, row, col, stability);
|
|
747
|
+
app.debug("Correction table created: " + name);
|
|
748
|
+
}
|
|
749
|
+
table.setDisplayAttributes({ label: table.id }); // Table2D API unchanged
|
|
750
|
+
return table;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Saves the correction table to disk as JSON (async, via library).
|
|
755
|
+
*
|
|
756
|
+
* @param {CorrectionTable} correctionTable - The correction table instance to save.
|
|
757
|
+
* @param {string} filePath - Absolute path of the target JSON file.
|
|
758
|
+
*/
|
|
759
|
+
function saveTable(correctionTable, filePath) {
|
|
760
|
+
correctionTable.saveToFile(filePath);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Saves the correction table synchronously. Used in stop() and when learning
|
|
765
|
+
* is toggled off, to ensure the write completes before the process can exit
|
|
766
|
+
* or the plugin be restarted.
|
|
767
|
+
*/
|
|
768
|
+
function saveTableSync(correctionTable, filePath) {
|
|
769
|
+
try {
|
|
770
|
+
const data = JSON.stringify(correctionTable.toJSON(), null, 2);
|
|
771
|
+
fs.writeFileSync(filePath, data);
|
|
772
|
+
} catch (err) {
|
|
773
|
+
app.error(`Error saving correction table: ${err.message}`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Returns true if every cell in the table has N === 0 (no learned data).
|
|
779
|
+
* Used to guard against overwriting a good on-disk file with an empty table.
|
|
780
|
+
*/
|
|
781
|
+
function isTableEmpty(correctionTable) {
|
|
782
|
+
return correctionTable.table.every(row => row.every(cell => cell.N === 0));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Drains changedOptions into options and hot-applies each change where possible.
|
|
787
|
+
* Flag changes take effect immediately since onChange reads from options.* directly.
|
|
788
|
+
*/
|
|
789
|
+
function applyOptionChanges() {
|
|
790
|
+
const changedKeys = Object.keys(changedOptions);
|
|
791
|
+
for (const key of changedKeys) {
|
|
792
|
+
const value = changedOptions[key];
|
|
793
|
+
options[key] = value;
|
|
794
|
+
|
|
795
|
+
if (key === 'updateCorrectionTable') {
|
|
796
|
+
if (!value && table && !isTableEmpty(table)) {
|
|
797
|
+
// Learning switched off — persist current state before periodic saves stop
|
|
798
|
+
saveTableSync(table, path.join(app.getDataDirPath(), table.id + '.json'));
|
|
799
|
+
app.debug('Correction table saved: learning switched off');
|
|
800
|
+
} else if (value && table && isTableEmpty(table)) {
|
|
801
|
+
// Learning switched on and in-memory table is empty — reload from disk
|
|
802
|
+
// to recover from a failed load at startup
|
|
803
|
+
const filePath = path.join(app.getDataDirPath(), table.id + '.json');
|
|
804
|
+
const fileData = Table2D.readFromFile(filePath);
|
|
805
|
+
if (fileData) {
|
|
806
|
+
const reloaded = CorrectionTable.fromJSON(fileData, options.stability || 7);
|
|
807
|
+
reloaded.setDisplayAttributes({ label: reloaded.id });
|
|
808
|
+
if (!isTableEmpty(reloaded)) {
|
|
809
|
+
swapTable(reloaded);
|
|
810
|
+
app.debug('Correction table reloaded from disk: learning switched on (was empty in memory)');
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// All other keys (sogFallback, estimateBoatSpeed, assumeCurrent,
|
|
817
|
+
// stability, smootherClass, etc.) are read
|
|
818
|
+
// directly from options.* so no extra action needed.
|
|
819
|
+
if (key === 'estimateBoatSpeed' && !value && correctedBoatSpeed) {
|
|
820
|
+
Polar.clear(app, plugin.id, [correctedBoatSpeed]);
|
|
821
|
+
PolarSmoother.clear(app, plugin.id, [smoothedCurrent]);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
delete changedOptions[key];
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Hot-apply smoother class / parameter changes to all user-tuned smoothers.
|
|
828
|
+
// (noCurrent and smoothedCurrent keep their own fixed Kalman settings.)
|
|
829
|
+
const SMOOTHER_KEYS = ['smootherClass', 'smootherTau', 'smootherTimeSpan', 'smootherSteadyState'];
|
|
830
|
+
if (changedKeys.some(k => SMOOTHER_KEYS.includes(k))) {
|
|
831
|
+
const { SmootherClass: SC, smootherOptions: so } = resolveSmootherConfig();
|
|
832
|
+
for (const s of [smoothedHeading, smoothedBoatSpeed, smoothedGroundSpeed]) {
|
|
833
|
+
if (s) { s.setSmootherClass(SC); s.setSmootherOptions(so); }
|
|
834
|
+
}
|
|
835
|
+
if (smoothedAttitude) { smoothedAttitude.setSmootherClass(SC); smoothedAttitude.setSmootherOptions(so); }
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
hasPendingChanges = false;
|
|
839
|
+
saveOptions();
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return plugin;
|
|
843
|
+
};
|