signalk-polar-performance-plugin 1.3.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -3
- package/package.json +1 -1
- package/plugin/PolarTable.js +103 -76
- package/plugin/index.js +9 -3
- package/test/PolarTable.test.js +115 -0
- package/test/api.test.js +6 -1
- package/test/lifecycle.test.js +6 -1
- package/test/migration.test.js +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,15 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.4.0] - 2026-09-01
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- npm publishes now use GitHub Actions OIDC trusted publishing instead of a long-lived `NPM_TOKEN`, and only run after a merge to `main`.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Polar interpolation no longer reports 0 kn when the polar lacks derived beat/run target rows for some TWS columns (common in Jieter/Expedition exports that only emit targets when they change). Missing targets are now interpolated across TWS during load; previously the fallback pinned the beat angle to the lowest tabulated angle (e.g. 52° between 44° and 38° neighbours), which nulled the pinch zone for every TWS bracket interpolated against the gap — upwind polar speed read 0 kn at 11–15 kt for affected polars.
|
|
16
|
+
- `getBoatSpeed` now evaluates the in-irons boundary against the interpolated beat angle, matching `getInterpolationState`, instead of each TWS bracket's own angle. A bracket whose own pinch zone is stricter no longer nulls the entire interpolated result.
|
|
17
|
+
- The zero-wind padding entry is now created before extrapolation coefficients are computed, so light-wind lookups (below the polar's lowest TWS) in the pinch and run-extrapolation zones interpolate smoothly toward zero instead of returning null.
|
|
18
|
+
- `getInterpolationState` now reports `above_range` consistently with `getBoatSpeed`'s interpolated run-extrapolation limit, even when the TWA is still tabulated for one of the TWS brackets.
|
|
19
|
+
- Removed dead CSV-era helpers (`_processTWSHeader`, `_processSpeedRow`) that could insert zero-speed points into the table if ever reused with `0.0`-padded CSV.
|
|
20
|
+
- Plugin start no longer fails with `TypeError: callback is not a function` when migrating existing settings. `savePluginOptions` now receives the callback Signal K requires.
|
|
21
|
+
|
|
22
|
+
## [1.3.3] - 2026-08-28
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- Idle input recovery is now always enabled; the temporary `detectStaleData` setting has been removed from the runtime settings UI.
|
|
26
|
+
|
|
9
27
|
### Fixed
|
|
10
28
|
- Signal K admin styling now loads from standalone CSS records in current Vite manifests while retaining support for entry-associated CSS and older server fallbacks.
|
|
11
29
|
- `/live` and `/status` endpoints now return `null` for `tws`/`twa` (and downstream fields) when the wind smoother has no data or is stale, instead of `0`. The guard was checking for the presence of the smoother object rather than its `ready` state.
|
|
12
30
|
- `computeAndSend`: when the polar table lookup fails (boat outside polar range — in irons or above max TWS), the `performance.polarSpeed`, `performance.polarSpeedRatio`, and `performance.targetSpeed` SK paths are now written with `null` instead of `0`. Writing `0` was misleading because it is a valid-looking value rather than an explicit "no data" signal.
|
|
13
31
|
- Live input subscriptions are now re-established after prolonged silence for all subscribed inputs, not just true wind. Boat speed and optional true heading use the same recovery path, and the plugin now reports their lifecycle state through the webapp/status endpoints.
|
|
14
32
|
|
|
15
|
-
### Changed
|
|
16
|
-
- Idle input recovery is now always enabled; the temporary `detectStaleData` setting has been removed from the runtime settings UI.
|
|
17
|
-
|
|
18
33
|
## [1.2.1] - 2026-07-25
|
|
19
34
|
|
|
20
35
|
### Fixed
|
package/package.json
CHANGED
package/plugin/PolarTable.js
CHANGED
|
@@ -208,13 +208,17 @@ class PolarTable {
|
|
|
208
208
|
const maxTwa = Math.max(lowerLastTwa, upperLastTwa)
|
|
209
209
|
const lowerLimit = lowerEntry._runExtrap?.extrapLimit ?? lowerLastTwa
|
|
210
210
|
const upperLimit = upperEntry._runExtrap?.extrapLimit ?? upperLastTwa
|
|
211
|
+
// The interpolated limit is the effective polar boundary at this TWS —
|
|
212
|
+
// getBoatSpeed returns null above it, so report above_range even when the
|
|
213
|
+
// TWA is still tabulated for one of the brackets (the other bracket's
|
|
214
|
+
// tighter limit wins by design, keeping the boundary smooth across TWS).
|
|
211
215
|
const extrapLimit = lowerLimit + twsInterp.ratio * (upperLimit - lowerLimit)
|
|
212
|
-
if (normalizedTwa
|
|
213
|
-
twaState = '
|
|
214
|
-
} else if (normalizedTwa
|
|
216
|
+
if (normalizedTwa > extrapLimit) {
|
|
217
|
+
twaState = 'above_range'
|
|
218
|
+
} else if (normalizedTwa > maxTwa) {
|
|
215
219
|
twaState = 'extrapolated'
|
|
216
220
|
} else {
|
|
217
|
-
twaState = '
|
|
221
|
+
twaState = 'in_range'
|
|
218
222
|
}
|
|
219
223
|
}
|
|
220
224
|
|
|
@@ -366,25 +370,29 @@ class PolarTable {
|
|
|
366
370
|
* Handles beat-angle quadratic extrapolation, run-angle quadratic extrapolation,
|
|
367
371
|
* and normal bilinear lookup.
|
|
368
372
|
*
|
|
373
|
+
* The in-irons boundary itself is NOT checked here — getBoatSpeed evaluates it
|
|
374
|
+
* against the interpolated beat angle so both TWS brackets share one boundary.
|
|
375
|
+
* When this entry's own beat angle is above the requested TWA, the boat is
|
|
376
|
+
* treated as stopped (0) rather than "no data", so the other bracket can
|
|
377
|
+
* still contribute to the TWS interpolation.
|
|
378
|
+
*
|
|
369
379
|
* @private
|
|
370
380
|
* @param {Object} entry - A single TWS table entry (this.table[i])
|
|
371
381
|
* @param {number} twa - True Wind Angle in radians (0–π, already normalised)
|
|
372
|
-
* @returns {number|null} Boat speed in m/s, or null when
|
|
382
|
+
* @returns {number|null} Boat speed in m/s, or null when no tabulated data
|
|
383
|
+
* covers the angle
|
|
373
384
|
*/
|
|
374
385
|
_getSpeedFromEntry(entry, twa) {
|
|
375
386
|
const beatAngle = entry['Beat angle']
|
|
376
387
|
const twaArray = entry.twa
|
|
377
388
|
|
|
378
|
-
// ── In irons: below the pinch point — no polar data ──────────────────────
|
|
379
|
-
if (beatAngle && twa < this.pinchFactor * beatAngle) return null
|
|
380
|
-
|
|
381
389
|
// ── Beat angle extrapolation zone (pinch point … beat angle) ─────────────
|
|
382
390
|
// Quadratic ramp anchored at 0 speed at pinchAngle (25°) through beat angle
|
|
383
391
|
// with C1 continuity at beat angle.
|
|
384
392
|
if (beatAngle && twa < beatAngle) {
|
|
385
|
-
if (!entry._beatExtrap) return
|
|
393
|
+
if (!entry._beatExtrap) return 0
|
|
386
394
|
const u = twa - entry._beatExtrap.zeroAngle
|
|
387
|
-
if (u <= 0) return
|
|
395
|
+
if (u <= 0) return 0 // below the analytic zero — stopped
|
|
388
396
|
return Math.max(0, entry._beatExtrap.a * u * u + entry._beatExtrap.b * u)
|
|
389
397
|
}
|
|
390
398
|
|
|
@@ -422,6 +430,14 @@ class PolarTable {
|
|
|
422
430
|
const lowerEntry = this.table[twsInterpolation.lowerIndex]
|
|
423
431
|
const upperEntry = this.table[twsInterpolation.upperIndex]
|
|
424
432
|
|
|
433
|
+
// ── In irons: below the pinch point of the interpolated beat angle ──────
|
|
434
|
+
// Evaluating the boundary against the interpolated beat angle (like
|
|
435
|
+
// getInterpolationState) keeps the two consistent and avoids a discontinuity
|
|
436
|
+
// when neighbouring TWS entries carry different beat angles: with per-entry
|
|
437
|
+
// boundaries, the stricter bracket would null the whole interpolation.
|
|
438
|
+
const beatAngle = this.getBeatAngle(tws)
|
|
439
|
+
if (beatAngle && normalizedTwa < this.pinchFactor * beatAngle) return null
|
|
440
|
+
|
|
425
441
|
// ── Run extrap limit: interpolate the limit between the two TWS brackets ───
|
|
426
442
|
// Moving the check here (rather than per-entry) prevents a discontinuity
|
|
427
443
|
// where the lower bracket's tighter limit would otherwise cause a sudden jump
|
|
@@ -470,25 +486,6 @@ class PolarTable {
|
|
|
470
486
|
}
|
|
471
487
|
|
|
472
488
|
|
|
473
|
-
/**
|
|
474
|
-
* Helper method to process the TWS header row from CSV data.
|
|
475
|
-
* Creates the initial polar table structure with TWS entries.
|
|
476
|
-
*
|
|
477
|
-
* @private
|
|
478
|
-
* @param {Array} row - CSV row containing 'twa/tws' and wind speed values
|
|
479
|
-
* @param {Object} app - Optional debug logging object
|
|
480
|
-
* @returns {Array} Array of polar entries with TWS values
|
|
481
|
-
*/
|
|
482
|
-
_processTWSHeader(row, app) {
|
|
483
|
-
app && app.debug('First row with TWS columns')
|
|
484
|
-
const polar = []
|
|
485
|
-
for (let index = 1; index < row.length; index++) {
|
|
486
|
-
polar.push({ tws: SI.fromKnots(row[index]) })
|
|
487
|
-
}
|
|
488
|
-
app && app.debug('polar: %s', JSON.stringify(polar))
|
|
489
|
-
return polar
|
|
490
|
-
}
|
|
491
|
-
|
|
492
489
|
/**
|
|
493
490
|
* Helper method to add speed data to a polar table entry.
|
|
494
491
|
* Handles TWA data addition and max speed tracking.
|
|
@@ -530,47 +527,6 @@ class PolarTable {
|
|
|
530
527
|
}
|
|
531
528
|
}
|
|
532
529
|
|
|
533
|
-
/**
|
|
534
|
-
* Helper method to process a speed data row from CSV.
|
|
535
|
-
* Handles both regular speed data and optimal angle rows.
|
|
536
|
-
*
|
|
537
|
-
* @private
|
|
538
|
-
* @param {Array} row - CSV row with angle and speed data
|
|
539
|
-
* @param {Array} polar - Polar table array to update
|
|
540
|
-
* @param {Object} app - Optional debug logging object
|
|
541
|
-
*/
|
|
542
|
-
_processSpeedRow(row, polar, app) {
|
|
543
|
-
const angle = SI.fromDegrees(Number(row[0]))
|
|
544
|
-
const halfPi = Math.PI / 2
|
|
545
|
-
|
|
546
|
-
// Check if this is a beat/run angle row (multiple zeros — trim before comparing)
|
|
547
|
-
const isOptimalAngle = row.filter(i => i.trim() === '0').length > 1
|
|
548
|
-
let angleName, VMGName
|
|
549
|
-
|
|
550
|
-
if (isOptimalAngle) {
|
|
551
|
-
app && app.debug('beat and run angles are included')
|
|
552
|
-
if (angle < halfPi) {
|
|
553
|
-
angleName = 'Beat angle'
|
|
554
|
-
VMGName = 'Beat VMG'
|
|
555
|
-
app && app.debug('cvsToPolar: row includes Beat angle: %s', row.join(';'))
|
|
556
|
-
} else {
|
|
557
|
-
angleName = 'Run angle'
|
|
558
|
-
VMGName = 'Run VMG'
|
|
559
|
-
app && app.debug('cvsToPolar: row includes Run angle: %s', row.join(';'))
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// Process each TWS column
|
|
564
|
-
for (let index = 0; index < row.length - 1; index++) {
|
|
565
|
-
const speedValue = (row[index + 1] || '').trim()
|
|
566
|
-
if (speedValue && speedValue !== '0') {
|
|
567
|
-
const tbs = SI.fromKnots(Number(speedValue))
|
|
568
|
-
const vmg = tbs * Math.abs(Math.cos(angle))
|
|
569
|
-
this._addSpeedData(polar[index], angle, tbs, vmg, angleName, VMGName, app)
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
530
|
/**
|
|
575
531
|
* Helper method for decimal rounding.
|
|
576
532
|
*
|
|
@@ -587,8 +543,10 @@ class PolarTable {
|
|
|
587
543
|
* Computes quadratic extrapolation coefficients for each TWS entry and stores
|
|
588
544
|
* them on the entry as `_beatExtrap` and `_runExtrap`.
|
|
589
545
|
*
|
|
590
|
-
* Called after _sortAndOptimizePolar (needs sorted twa arrays and beat/run
|
|
591
|
-
* and
|
|
546
|
+
* Called after _sortAndOptimizePolar (needs sorted twa arrays and beat/run
|
|
547
|
+
* angles) and after _addPolarPadding, so the zero-wind padding entry receives
|
|
548
|
+
* degenerate zero-valued coefficients — exactly what light-wind interpolation
|
|
549
|
+
* toward zero needs.
|
|
592
550
|
*
|
|
593
551
|
* Beat angle model — f(u) = a·u² + b·u, u = twa − zeroAngle (pinchAngle = 25°):
|
|
594
552
|
* f(d) = beatSpeed where d = beatAngle − zeroAngle
|
|
@@ -770,6 +728,67 @@ class PolarTable {
|
|
|
770
728
|
})
|
|
771
729
|
}
|
|
772
730
|
|
|
731
|
+
/**
|
|
732
|
+
* Builds a complete per-TWS derived target table, filling gaps by interpolation.
|
|
733
|
+
*
|
|
734
|
+
* Many polar sources (Jieter/Expedition CSV exports, ORC data) only emit
|
|
735
|
+
* beat/run target rows for a subset of the TWS columns. Without this pass,
|
|
736
|
+
* TWS entries lacking a target fall back to _sortAndOptimizePolar's
|
|
737
|
+
* argmax-VMG-over-tabulated-angles heuristic, which pins the beat angle to
|
|
738
|
+
* the lowest tabulated angle and disagrees sharply with neighbouring TWS
|
|
739
|
+
* entries — nulling out the pinch zone for every TWS interpolated against
|
|
740
|
+
* the gap (polar speed reads 0 while sailing).
|
|
741
|
+
*
|
|
742
|
+
* For each TWS axis value missing a beat (or run) target, the target is
|
|
743
|
+
* linearly interpolated from the nearest entries below and above that have
|
|
744
|
+
* one; at the ends of the axis the nearest available target is used as-is.
|
|
745
|
+
*
|
|
746
|
+
* @private
|
|
747
|
+
* @param {number[]} twsAxis - TWS axis values in m/s (ascending)
|
|
748
|
+
* @param {Array} derivedRows - Derived rows from the canonical resource
|
|
749
|
+
* @returns {Array<Object>} Array aligned with twsAxis; each item has optional
|
|
750
|
+
* `beat`/`run` targets ({twa, tbs, vmg} in SI units)
|
|
751
|
+
*/
|
|
752
|
+
_interpolateDerivedTargets(twsAxis, derivedRows) {
|
|
753
|
+
const targets = twsAxis.map(tws => {
|
|
754
|
+
const row = derivedRows.find(candidate => Math.abs(candidate.tws - tws) < 1e-6)
|
|
755
|
+
const target = {}
|
|
756
|
+
if (row?.beat && Number.isFinite(row.beat.twa) && Number.isFinite(row.beat.tbs)) {
|
|
757
|
+
target.beat = { ...row.beat }
|
|
758
|
+
}
|
|
759
|
+
if (row?.run && Number.isFinite(row.run.twa) && Number.isFinite(row.run.tbs)) {
|
|
760
|
+
target.run = { ...row.run }
|
|
761
|
+
}
|
|
762
|
+
return target
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
for (const key of ['beat', 'run']) {
|
|
766
|
+
for (let i = 0; i < targets.length; i++) {
|
|
767
|
+
if (targets[i][key]) continue
|
|
768
|
+
|
|
769
|
+
let lo = -1
|
|
770
|
+
let hi = -1
|
|
771
|
+
for (let j = i - 1; j >= 0; j--) { if (targets[j][key]) { lo = j; break } }
|
|
772
|
+
for (let j = i + 1; j < targets.length; j++) { if (targets[j][key]) { hi = j; break } }
|
|
773
|
+
|
|
774
|
+
if (lo >= 0 && hi >= 0 && twsAxis[hi] - twsAxis[lo] > 1e-9) {
|
|
775
|
+
const ratio = (twsAxis[i] - twsAxis[lo]) / (twsAxis[hi] - twsAxis[lo])
|
|
776
|
+
const a = targets[lo][key]
|
|
777
|
+
const b = targets[hi][key]
|
|
778
|
+
const twa = a.twa + ratio * (b.twa - a.twa)
|
|
779
|
+
const tbs = a.tbs + ratio * (b.tbs - a.tbs)
|
|
780
|
+
targets[i][key] = { twa, tbs, vmg: tbs * Math.abs(Math.cos(twa)) }
|
|
781
|
+
} else if (lo >= 0) {
|
|
782
|
+
targets[i][key] = { ...targets[lo][key] }
|
|
783
|
+
} else if (hi >= 0) {
|
|
784
|
+
targets[i][key] = { ...targets[hi][key] }
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return targets
|
|
790
|
+
}
|
|
791
|
+
|
|
773
792
|
/**
|
|
774
793
|
* Loads polar table data from the canonical PolarResource representation.
|
|
775
794
|
*
|
|
@@ -789,6 +808,12 @@ class PolarTable {
|
|
|
789
808
|
}
|
|
790
809
|
|
|
791
810
|
const derivedRows = Array.isArray(resource?.derived?.rows) ? resource.derived.rows : []
|
|
811
|
+
// Fill gaps in derived beat/run targets across TWS so entries without an
|
|
812
|
+
// explicit target get an interpolated one instead of falling back to the
|
|
813
|
+
// argmax-VMG-over-tabulated-angles heuristic (which pins the beat angle to
|
|
814
|
+
// the lowest tabulated angle and disagrees sharply with neighbouring TWS,
|
|
815
|
+
// nulling out the pinch zone for every TWS interpolated against the gap).
|
|
816
|
+
const derivedTargets = this._interpolateDerivedTargets(twsAxis, derivedRows)
|
|
792
817
|
const polar = twsAxis.map(tws => ({ tws, twa: [] }))
|
|
793
818
|
|
|
794
819
|
for (let rowIndex = 0; rowIndex < twsAxis.length; rowIndex++) {
|
|
@@ -808,9 +833,7 @@ class PolarTable {
|
|
|
808
833
|
this._addSpeedData(polar[rowIndex], angle, tbs, vmg, null, null, null)
|
|
809
834
|
}
|
|
810
835
|
|
|
811
|
-
const derivedRow =
|
|
812
|
-
? derivedRows[rowIndex]
|
|
813
|
-
: derivedRows.find(candidate => Math.abs(candidate.tws - twsAxis[rowIndex]) < 1e-6)
|
|
836
|
+
const derivedRow = derivedTargets[rowIndex]
|
|
814
837
|
|
|
815
838
|
if (derivedRow?.beat && Number.isFinite(derivedRow.beat.twa) && Number.isFinite(derivedRow.beat.tbs)) {
|
|
816
839
|
const beatVmg = Number.isFinite(derivedRow.beat.vmg)
|
|
@@ -848,8 +871,12 @@ class PolarTable {
|
|
|
848
871
|
}
|
|
849
872
|
|
|
850
873
|
this._sortAndOptimizePolar(polar, null)
|
|
851
|
-
|
|
874
|
+
// Padding must be in place before coefficients are computed so the zero-wind
|
|
875
|
+
// entry gets zero-valued _beatExtrap/_runExtrap models; without them, any
|
|
876
|
+
// low-wind lookup in the pinch or run-extrapolation zone returns null
|
|
877
|
+
// instead of interpolating smoothly toward zero.
|
|
852
878
|
this._addPolarPadding(polar, null)
|
|
879
|
+
this._computeExtrapolationCoefficients(polar, null)
|
|
853
880
|
this.table = polar
|
|
854
881
|
return this
|
|
855
882
|
}
|
package/plugin/index.js
CHANGED
|
@@ -182,10 +182,16 @@ module.exports = (app) => {
|
|
|
182
182
|
app.debug('Settings migrated from v0 to v1')
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
// Persist if any migration ran, so migrations don't repeat on next start
|
|
185
|
+
// Persist if any migration ran, so migrations don't repeat on next start.
|
|
186
|
+
// Signal K requires a callback; omitting it throws TypeError and aborts start().
|
|
186
187
|
if ((s.settingsVersion ?? 0) > version) {
|
|
187
|
-
app.savePluginOptions(s)
|
|
188
|
-
|
|
188
|
+
app.savePluginOptions(s, (err) => {
|
|
189
|
+
if (err) {
|
|
190
|
+
app.error('Failed to save migrated settings: ' + err.message)
|
|
191
|
+
} else {
|
|
192
|
+
app.debug('Migrated settings saved (v%d → v%d)', version, s.settingsVersion)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
189
195
|
}
|
|
190
196
|
|
|
191
197
|
return s
|
package/test/PolarTable.test.js
CHANGED
|
@@ -243,6 +243,121 @@ describe('PolarTable — interpolation state', () => {
|
|
|
243
243
|
})
|
|
244
244
|
})
|
|
245
245
|
|
|
246
|
+
describe('PolarTable — derived target gap filling', () => {
|
|
247
|
+
// Polar shaped like real Jieter/Expedition exports: derived beat/run targets
|
|
248
|
+
// only exist for a subset of TWS columns (here 6 kt has both, 12 kt only a
|
|
249
|
+
// run target, 16 kt only a beat target). Before the fix, the missing targets
|
|
250
|
+
// fell back to argmax-VMG over tabulated angles (beat angle = 52°, the lowest
|
|
251
|
+
// tabulated angle), nulling the pinch zone for every TWS interpolated
|
|
252
|
+
// against the gap — polar speed read 0 kn while sailing upwind at 11–15 kt.
|
|
253
|
+
const GAPPED = {
|
|
254
|
+
kind: 'polarTable',
|
|
255
|
+
schemaVersion: '1.0.0',
|
|
256
|
+
name: 'Gapped Polar',
|
|
257
|
+
units: { tws: 'm/s', twa: 'rad', boatSpeed: 'm/s' },
|
|
258
|
+
symmetry: { portStarboardSymmetric: true },
|
|
259
|
+
axes: {
|
|
260
|
+
tws: [6, 12, 16].map(SI.fromKnots),
|
|
261
|
+
twa: [52, 60, 75, 90, 110, 120, 135, 150, 160].map(SI.fromDegrees)
|
|
262
|
+
},
|
|
263
|
+
values: {
|
|
264
|
+
boatSpeedMatrix: [
|
|
265
|
+
[3.3, 3.2, 3.59, 3.51, 3.38, 2.95, 2.93, 2.56, 2.65].map(SI.fromKnots),
|
|
266
|
+
[4.84, 5.32, 5.52, 5.69, 5.46, 5.22, 4.93, 4.82, 4.98].map(SI.fromKnots),
|
|
267
|
+
[5.41, 5.8, 6.18, 6.18, 6.03, 6.02, 5.87, 5.62, 5.7].map(SI.fromKnots)
|
|
268
|
+
]
|
|
269
|
+
},
|
|
270
|
+
derived: {
|
|
271
|
+
rows: [
|
|
272
|
+
{ tws: SI.fromKnots(6), beat: targetPoint(39, 3.308), run: targetPoint(175, 4.435) },
|
|
273
|
+
{ tws: SI.fromKnots(12), run: targetPoint(177, 5.177) },
|
|
274
|
+
{ tws: SI.fromKnots(16), beat: targetPoint(38, 5.316) }
|
|
275
|
+
]
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
let polar
|
|
279
|
+
|
|
280
|
+
before(() => { polar = new PolarTable().loadFromCanonical(GAPPED) })
|
|
281
|
+
|
|
282
|
+
it('interpolates a missing beat target across TWS (12 kt: between 39° and 38°)', () => {
|
|
283
|
+
const beat = polar.getBeatAngle(SI.fromKnots(12))
|
|
284
|
+
assert.ok(approxEqual(SI.toDegrees(beat), 38.4, 0.5),
|
|
285
|
+
`Expected ~38.4°, got ${SI.toDegrees(beat).toFixed(1)}°`)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('does not snap a gapped beat angle to the lowest tabulated angle (52°)', () => {
|
|
289
|
+
for (const tws of [7, 9, 11, 13, 15]) {
|
|
290
|
+
const beat = SI.toDegrees(polar.getBeatAngle(SI.fromKnots(tws)))
|
|
291
|
+
assert.ok(beat < 45, `Beat angle ${beat.toFixed(1)}° at ${tws} kt should stay below 45°`)
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('clamps a missing run target at the end of the TWS axis (16 kt ← 12 kt)', () => {
|
|
296
|
+
const run = polar.getRunAngle(SI.fromKnots(16))
|
|
297
|
+
assert.ok(approxEqual(SI.toDegrees(run), 177, 0.5),
|
|
298
|
+
`Expected ~177°, got ${SI.toDegrees(run).toFixed(1)}°`)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('returns polar speed upwind between gapped TWS brackets (was 0 kn / null)', () => {
|
|
302
|
+
for (const tws of [9, 11, 13, 15]) {
|
|
303
|
+
const speed = polar.getBoatSpeed(SI.fromKnots(tws), SI.fromDegrees(40))
|
|
304
|
+
assert.ok(speed !== null && SI.toKnots(speed) > 3,
|
|
305
|
+
`Expected > 3 kn at ${tws} kt / 40°, got ${speed === null ? 'null' : SI.toKnots(speed).toFixed(2) + ' kn'}`)
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('keeps dead-downwind data at TWS interpolated against a run-angle gap', () => {
|
|
310
|
+
// 14–15 kt interpolate against the 16 kt bracket whose run target is filled
|
|
311
|
+
const speed = polar.getBoatSpeed(SI.fromKnots(15), SI.fromDegrees(170))
|
|
312
|
+
assert.ok(speed !== null && SI.toKnots(speed) > 4,
|
|
313
|
+
`Expected > 4 kn at 15 kt / 170°, got ${speed === null ? 'null' : SI.toKnots(speed).toFixed(2) + ' kn'}`)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('interpolates toward zero below the first TWS column in the pinch zone', () => {
|
|
317
|
+
// 3 kt is below the 6 kt minimum: pinch zone (35.1° … 39°) must ramp
|
|
318
|
+
// toward zero instead of returning null
|
|
319
|
+
const speed = polar.getBoatSpeed(SI.fromKnots(3), SI.fromDegrees(37))
|
|
320
|
+
assert.ok(speed !== null && SI.toKnots(speed) > 0 && SI.toKnots(speed) < 3.308,
|
|
321
|
+
`Expected 0 < speed < 3.3 kn at 3 kt / 37°, got ${speed === null ? 'null' : SI.toKnots(speed).toFixed(2) + ' kn'}`)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('interpolates toward zero below the first TWS column beyond the last TWA', () => {
|
|
325
|
+
const speed = polar.getBoatSpeed(SI.fromKnots(3), SI.fromDegrees(176))
|
|
326
|
+
assert.ok(speed !== null && SI.toKnots(speed) > 0 && SI.toKnots(speed) < 4.435,
|
|
327
|
+
`Expected 0 < speed < 4.4 kn at 3 kt / 176°, got ${speed === null ? 'null' : SI.toKnots(speed).toFixed(2) + ' kn'}`)
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
describe('PolarTable — getBoatSpeed / getInterpolationState consistency', () => {
|
|
332
|
+
// getBoatSpeed must return null exactly when the state machine reports
|
|
333
|
+
// in_irons or above_range — the two share the interpolated beat angle and
|
|
334
|
+
// the interpolated run-extrapolation limit.
|
|
335
|
+
let polar
|
|
336
|
+
|
|
337
|
+
before(() => { polar = new PolarTable().loadFromCanonical(CANONICAL) })
|
|
338
|
+
|
|
339
|
+
it('null ⇔ in_irons or above_range across a TWS/TWA sweep', () => {
|
|
340
|
+
for (let tws = 3; tws <= 26; tws += 0.5) {
|
|
341
|
+
for (let twa = 5; twa <= 180; twa += 1) {
|
|
342
|
+
const speed = polar.getBoatSpeed(SI.fromKnots(tws), SI.fromDegrees(twa))
|
|
343
|
+
const state = polar.getInterpolationState(SI.fromKnots(tws), SI.fromDegrees(twa))
|
|
344
|
+
const expectNull = state.twa === 'in_irons' || state.twa === 'above_range'
|
|
345
|
+
assert.equal(speed === null, expectNull,
|
|
346
|
+
`TWS ${tws} kt / ${twa}°: speed=${speed} but state=${state.tws}/${state.twa}`)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('pinching zone returns a speed between 0 and the beat-angle speed', () => {
|
|
352
|
+
const tws = SI.fromKnots(12)
|
|
353
|
+
const beatAngle = polar.getBeatAngle(tws)
|
|
354
|
+
const speed = polar.getBoatSpeed(tws, 0.95 * beatAngle)
|
|
355
|
+
const beatSpeed = polar.getBoatSpeed(tws, beatAngle)
|
|
356
|
+
assert.ok(speed !== null && speed > 0 && speed < beatSpeed,
|
|
357
|
+
`Expected 0 < speed < ${SI.toKnots(beatSpeed).toFixed(2)} kn, got ${speed}`)
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
246
361
|
describe('PolarTable — port/starboard symmetry', () => {
|
|
247
362
|
let polar
|
|
248
363
|
|
package/test/api.test.js
CHANGED
|
@@ -157,7 +157,12 @@ function makeApp(dataDir) {
|
|
|
157
157
|
error: () => {},
|
|
158
158
|
setPluginStatus: () => {},
|
|
159
159
|
setPluginError: () => {},
|
|
160
|
-
savePluginOptions: (_options, callback) =>
|
|
160
|
+
savePluginOptions: (_options, callback) => {
|
|
161
|
+
if (typeof callback !== 'function') {
|
|
162
|
+
throw new TypeError('callback is not a function')
|
|
163
|
+
}
|
|
164
|
+
callback(null)
|
|
165
|
+
},
|
|
161
166
|
getDataDirPath: () => dataDir,
|
|
162
167
|
handleMessage: () => {},
|
|
163
168
|
config: { port: 3000 },
|
package/test/lifecycle.test.js
CHANGED
|
@@ -16,7 +16,12 @@ function makeApp(dataDir) {
|
|
|
16
16
|
error: () => {},
|
|
17
17
|
setPluginStatus: () => {},
|
|
18
18
|
setPluginError: () => {},
|
|
19
|
-
savePluginOptions: () => {
|
|
19
|
+
savePluginOptions: (_options, callback) => {
|
|
20
|
+
if (typeof callback !== 'function') {
|
|
21
|
+
throw new TypeError('callback is not a function')
|
|
22
|
+
}
|
|
23
|
+
callback(null)
|
|
24
|
+
},
|
|
20
25
|
getDataDirPath: () => dataDir,
|
|
21
26
|
handleMessage: () => {},
|
|
22
27
|
config: { port: 3000 },
|
package/test/migration.test.js
CHANGED
|
@@ -40,7 +40,13 @@ function makeApp(dataDir) {
|
|
|
40
40
|
error: () => {},
|
|
41
41
|
setPluginStatus: () => {},
|
|
42
42
|
setPluginError: (msg) => errors.push(msg),
|
|
43
|
-
savePluginOptions: (opts) =>
|
|
43
|
+
savePluginOptions: (opts, callback) => {
|
|
44
|
+
if (typeof callback !== 'function') {
|
|
45
|
+
throw new TypeError('callback is not a function')
|
|
46
|
+
}
|
|
47
|
+
saved.push(JSON.parse(JSON.stringify(opts)))
|
|
48
|
+
callback(null)
|
|
49
|
+
},
|
|
44
50
|
getDataDirPath: () => dataDir,
|
|
45
51
|
subscriptionmanager: {
|
|
46
52
|
subscribe: (_sub, unsubscribes, _onErr, _onDelta) => {
|