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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-polar-performance-plugin",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "Calculates live sailing performance from polar data, with built-in polar management and import tools.",
5
5
  "main": "plugin/index.js",
6
6
  "exports": {
@@ -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 <= maxTwa) {
213
- twaState = 'in_range'
214
- } else if (normalizedTwa <= extrapLimit) {
216
+ if (normalizedTwa > extrapLimit) {
217
+ twaState = 'above_range'
218
+ } else if (normalizedTwa > maxTwa) {
215
219
  twaState = 'extrapolated'
216
220
  } else {
217
- twaState = 'above_range'
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 in irons / beyond π
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 null
393
+ if (!entry._beatExtrap) return 0
386
394
  const u = twa - entry._beatExtrap.zeroAngle
387
- if (u <= 0) return null // below the analytic zero — treat as in irons
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 angles)
591
- * and before _addPolarPadding (operates on the raw data, not the padded form).
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 = derivedRows[rowIndex] && Math.abs(derivedRows[rowIndex].tws - twsAxis[rowIndex]) < 1e-6
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
- this._computeExtrapolationCoefficients(polar, null)
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
- app.debug('Migrated settings saved (v%d → v%d)', version, s.settingsVersion)
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
@@ -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) => callback && 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 },
@@ -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 },
@@ -40,7 +40,13 @@ function makeApp(dataDir) {
40
40
  error: () => {},
41
41
  setPluginStatus: () => {},
42
42
  setPluginError: (msg) => errors.push(msg),
43
- savePluginOptions: (opts) => saved.push(JSON.parse(JSON.stringify(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) => {