signalk-polar-performance-plugin 1.4.0 → 2.1.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.
@@ -1,924 +0,0 @@
1
- const SI = require('./SI');
2
-
3
- /**
4
- * PolarTable - A class for managing and interpolating sailing boat polar performance data.
5
- *
6
- * Polar tables contain boat performance data across different True Wind Speeds (TWS) and
7
- * True Wind Angles (TWA), including optimal sailing angles, speeds, and VMG calculations.
8
- *
9
- * The class provides interpolation capabilities to get smooth performance data for any
10
- * wind condition, even between measured data points.
11
- *
12
- * @example
13
- * const polar = new PolarTable();
14
- * polar.loadFromCanonical(resource);
15
- * polar.setPerformanceAdjustment(0.9); // 90% performance
16
- *
17
- * const beatAngle = polar.getBeatAngle(SI.fromKnots(12)); // Get optimal beating angle
18
- * const boatSpeed = polar.getBoatSpeed(SI.fromKnots(15), SI.fromDegrees(90)); // Get boat speed
19
- */
20
- class PolarTable {
21
- /**
22
- * Creates a new PolarTable instance.
23
- * Initializes with empty table and default performance adjustment of 1.0 (100%).
24
- */
25
- constructor() {
26
- this.table = [];
27
- this.perfAdjust = 1;
28
- this.pinchFactor = 0.9; // TWA below pinchFactor × beatAngle returns null (in irons)
29
- this.pinchAngle = SI.fromDegrees(25); // Hard minimum pinch-to-zero angle — zero speed at or below this TWA
30
- this.runExtrapFactor = 0.3; // Fraction of (π − runAngle) to extrapolate beyond the last tabulated angle
31
- }
32
-
33
- /**
34
- * Sets the performance adjustment factor for all speed-related calculations.
35
- * This allows scaling the entire polar table performance up or down.
36
- *
37
- * @param {number} value - Performance multiplier (1.0 = 100%, 0.9 = 90%, etc.)
38
- * @example
39
- * polar.setPerformanceAdjustment(0.85); // Conservative 85% performance
40
- * polar.setPerformanceAdjustment(1.1); // Optimistic 110% performance
41
- */
42
- setPerformanceAdjustment(value) {
43
- this.perfAdjust = value;
44
- }
45
-
46
- /**
47
- * Gets the current performance adjustment factor.
48
- *
49
- * @returns {number} Current performance multiplier
50
- */
51
- getPerformanceAdjustment() {
52
- return this.perfAdjust;
53
- }
54
-
55
- /**
56
- * Helper function to find the two closest TWS values and calculate interpolation ratio.
57
- * Handles edge cases where target TWS is outside the available data range.
58
- *
59
- * @private
60
- * @param {number} targetTws - Target True Wind Speed in m/s
61
- * @returns {Object|null} Interpolation data with lowerIndex, upperIndex, and ratio, or null if no data
62
- */
63
- _findTwsInterpolation(targetTws) {
64
- if (!this.table || this.table.length === 0) {
65
- return null;
66
- }
67
-
68
- // Find the two closest TWS values
69
- let lowerIndex = -1;
70
- let upperIndex = -1;
71
-
72
- for (let i = 0; i < this.table.length; i++) {
73
- if (this.table[i].tws <= targetTws) {
74
- lowerIndex = i;
75
- }
76
- if (this.table[i].tws >= targetTws && upperIndex === -1) {
77
- upperIndex = i;
78
- break;
79
- }
80
- }
81
-
82
- // Edge case: target TWS is below all available data
83
- if (lowerIndex === -1) {
84
- return { lowerIndex: 0, upperIndex: 0, ratio: 0 };
85
- }
86
-
87
- // Edge case: target TWS is above all available data
88
- if (upperIndex === -1) {
89
- const lastIndex = this.table.length - 1;
90
- return { lowerIndex: lastIndex, upperIndex: lastIndex, ratio: 0 };
91
- }
92
-
93
- // Exact match or normal interpolation
94
- if (lowerIndex === upperIndex) {
95
- return { lowerIndex: lowerIndex, upperIndex: upperIndex, ratio: 0 };
96
- }
97
-
98
- // Calculate interpolation ratio
99
- const lowerTws = this.table[lowerIndex].tws;
100
- const upperTws = this.table[upperIndex].tws;
101
- const ratio = (targetTws - lowerTws) / (upperTws - lowerTws);
102
-
103
- return { lowerIndex: lowerIndex, upperIndex: upperIndex, ratio: ratio };
104
- }
105
-
106
- /**
107
- * Helper function to find the two closest TWA values and interpolation ratio within a TWS entry.
108
- * Used for bilinear interpolation when getting boat speeds at specific wind angles.
109
- *
110
- * @private
111
- * @param {Array} twaArray - Array of TWA data points from a specific TWS entry
112
- * @param {number} targetTwa - Target True Wind Angle in radians
113
- * @returns {Object|null} Interpolation data with lowerIndex, upperIndex, and ratio, or null if no data
114
- */
115
- _findTwaInterpolation(twaArray, targetTwa) {
116
- if (!twaArray || twaArray.length === 0) {
117
- return null;
118
- }
119
-
120
- // Find the two closest TWA values
121
- let lowerIndex = -1;
122
- let upperIndex = -1;
123
-
124
- for (let i = 0; i < twaArray.length; i++) {
125
- if (twaArray[i].twa <= targetTwa) {
126
- lowerIndex = i;
127
- }
128
- if (twaArray[i].twa >= targetTwa && upperIndex === -1) {
129
- upperIndex = i;
130
- break;
131
- }
132
- }
133
-
134
- // Edge case: target TWA is below all available data — boat is in irons, no polar data
135
- if (lowerIndex === -1) {
136
- return null;
137
- }
138
-
139
- // Edge case: target TWA is above all available data — clamp to last entry
140
- if (upperIndex === -1) {
141
- const lastIndex = twaArray.length - 1;
142
- return { lowerIndex: lastIndex, upperIndex: lastIndex, ratio: 0 };
143
- }
144
-
145
- // Exact match or normal interpolation
146
- if (lowerIndex === upperIndex) {
147
- return { lowerIndex: lowerIndex, upperIndex: upperIndex, ratio: 0 };
148
- }
149
-
150
- // Calculate interpolation ratio
151
- const lowerTwa = twaArray[lowerIndex].twa;
152
- const upperTwa = twaArray[upperIndex].twa;
153
- const ratio = (targetTwa - lowerTwa) / (upperTwa - lowerTwa);
154
-
155
- return { lowerIndex: lowerIndex, upperIndex: upperIndex, ratio: ratio };
156
- }
157
-
158
- /**
159
- * Returns the interpolation state for a given TWS and TWA.
160
- *
161
- * Useful for display layers that want to communicate whether values are
162
- * within the polar table bounds, extrapolated, or undefined.
163
- *
164
- * @param {number} tws - True Wind Speed in m/s
165
- * @param {number} twa - True Wind Angle in radians (positive, 0–π)
166
- * @returns {{ tws: string, twa: string }|null}
167
- * null if the table is empty, otherwise an object with:
168
- * - tws: 'below_range' | 'in_range' | 'above_range'
169
- * - twa: 'in_irons' | 'pinching' | 'in_range' | 'above_range'
170
- */
171
- getInterpolationState(tws, twa) {
172
- if (!this.table || this.table.length === 0) return null
173
-
174
- // Ignore the zero-padding entry (tws ≈ 0.0001) when finding real bounds
175
- const realEntries = this.table.filter(e => e.tws > 0.001)
176
- if (realEntries.length === 0) return null
177
-
178
- const minTws = realEntries[0].tws
179
- const maxTws = realEntries[realEntries.length - 1].tws
180
-
181
- let twsState
182
- if (tws < minTws) twsState = 'below_range'
183
- else if (tws > maxTws) twsState = 'above_range'
184
- else twsState = 'in_range'
185
-
186
- // Beat angle at requested TWS for TWA boundary checks
187
- const beatAngle = this.getBeatAngle(tws)
188
-
189
- let twaState
190
- const normalizedTwa = Math.abs(twa)
191
-
192
- if (!beatAngle) {
193
- // No beat angle data — use first entry's twa array for range check
194
- twaState = 'in_range'
195
- } else if (normalizedTwa < this.pinchFactor * beatAngle) {
196
- twaState = 'in_irons'
197
- } else if (normalizedTwa < beatAngle) {
198
- twaState = 'pinching'
199
- } else {
200
- // Check against max TWA using interpolated extrap limit (mirrors getBoatSpeed logic)
201
- const twsInterp = this._findTwsInterpolation(tws)
202
- const lowerEntry = this.table[twsInterp.lowerIndex]
203
- const upperEntry = this.table[twsInterp.upperIndex]
204
- const lowerTwaArr = lowerEntry.twa
205
- const upperTwaArr = upperEntry.twa
206
- const lowerLastTwa = lowerTwaArr?.[lowerTwaArr.length - 1]?.twa ?? 0
207
- const upperLastTwa = upperTwaArr?.[upperTwaArr.length - 1]?.twa ?? 0
208
- const maxTwa = Math.max(lowerLastTwa, upperLastTwa)
209
- const lowerLimit = lowerEntry._runExtrap?.extrapLimit ?? lowerLastTwa
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).
215
- const extrapLimit = lowerLimit + twsInterp.ratio * (upperLimit - lowerLimit)
216
- if (normalizedTwa > extrapLimit) {
217
- twaState = 'above_range'
218
- } else if (normalizedTwa > maxTwa) {
219
- twaState = 'extrapolated'
220
- } else {
221
- twaState = 'in_range'
222
- }
223
- }
224
-
225
- return { tws: twsState, twa: twaState }
226
- }
227
-
228
- /**
229
- * Gets the optimal beating angle (upwind) for a given true wind speed.
230
- * Uses linear interpolation between the closest TWS data points.
231
- *
232
- * @param {number} tws - True Wind Speed in m/s
233
- * @returns {number|null} Optimal beating angle in radians, or null if no data available
234
- * @example
235
- * const beatAngle = polar.getBeatAngle(SI.fromKnots(12)); // Get beat angle for 12 knots
236
- */
237
- getBeatAngle(tws) {
238
- const interpolation = this._findTwsInterpolation(tws);
239
- if (!interpolation) {
240
- return null;
241
- }
242
-
243
- const lowerAngle = this.table[interpolation.lowerIndex]['Beat angle'];
244
- const upperAngle = this.table[interpolation.upperIndex]['Beat angle'];
245
- return lowerAngle + interpolation.ratio * (upperAngle - lowerAngle);
246
- }
247
-
248
- /**
249
- * Gets the optimal running angle (downwind) for a given true wind speed.
250
- * Uses linear interpolation between the closest TWS data points.
251
- *
252
- * @param {number} tws - True Wind Speed in m/s
253
- * @returns {number|null} Optimal running angle in radians, or null if no data available
254
- * @example
255
- * const runAngle = polar.getRunAngle(SI.fromKnots(20)); // Get run angle for 20 knots
256
- */
257
- getRunAngle(tws) {
258
- const interpolation = this._findTwsInterpolation(tws);
259
- if (!interpolation) {
260
- return null;
261
- }
262
-
263
- const lowerAngle = this.table[interpolation.lowerIndex]['Run angle'];
264
- const upperAngle = this.table[interpolation.upperIndex]['Run angle'];
265
- return lowerAngle + interpolation.ratio * (upperAngle - lowerAngle);
266
- }
267
-
268
- /**
269
- * Gets the Velocity Made Good (VMG) when beating for a given true wind speed.
270
- * VMG represents the effective speed toward the wind direction.
271
- * Result is scaled by the performance adjustment factor.
272
- *
273
- * @param {number} tws - True Wind Speed in m/s
274
- * @returns {number|null} Beat VMG in m/s (scaled by perfAdjust), or null if no data available
275
- * @example
276
- * const beatVMG = polar.getBeatVMG(SI.fromKnots(15)); // Get beat VMG for 15 knots
277
- */
278
- getBeatVMG(tws) {
279
- const interpolation = this._findTwsInterpolation(tws);
280
- if (!interpolation) {
281
- return null;
282
- }
283
-
284
- const lowerVMG = this.table[interpolation.lowerIndex]['Beat VMG'];
285
- const upperVMG = this.table[interpolation.upperIndex]['Beat VMG'];
286
- return (lowerVMG + interpolation.ratio * (upperVMG - lowerVMG)) * this.perfAdjust;
287
- }
288
-
289
- /**
290
- * Gets the Velocity Made Good (VMG) when running for a given true wind speed.
291
- * VMG represents the effective speed away from the wind direction.
292
- * Result is scaled by the performance adjustment factor.
293
- *
294
- * @param {number} tws - True Wind Speed in m/s
295
- * @returns {number|null} Run VMG in m/s (scaled by perfAdjust), or null if no data available
296
- * @example
297
- * const runVMG = polar.getRunVMG(SI.fromKnots(18)); // Get run VMG for 18 knots
298
- */
299
- getRunVMG(tws) {
300
- const interpolation = this._findTwsInterpolation(tws);
301
- if (!interpolation) {
302
- return null;
303
- }
304
-
305
- const lowerVMG = this.table[interpolation.lowerIndex]['Run VMG'];
306
- const upperVMG = this.table[interpolation.upperIndex]['Run VMG'];
307
- return (lowerVMG + interpolation.ratio * (upperVMG - lowerVMG)) * this.perfAdjust;
308
- }
309
-
310
- /**
311
- * Gets the maximum achievable boat speed for a given true wind speed.
312
- * This represents the peak performance speed regardless of wind angle.
313
- * Result is scaled by the performance adjustment factor.
314
- *
315
- * @param {number} tws - True Wind Speed in m/s
316
- * @returns {number|null} Maximum boat speed in m/s (scaled by perfAdjust), or null if no data available
317
- * @example
318
- * const maxSpeed = polar.getMaxSpeed(SI.fromKnots(25)); // Get max speed for 25 knots
319
- */
320
- getMaxSpeed(tws) {
321
- const interpolation = this._findTwsInterpolation(tws);
322
- if (!interpolation) {
323
- return null;
324
- }
325
-
326
- const lowerSpeed = this.table[interpolation.lowerIndex]['Max speed'];
327
- const upperSpeed = this.table[interpolation.upperIndex]['Max speed'];
328
- return (lowerSpeed + interpolation.ratio * (upperSpeed - lowerSpeed)) * this.perfAdjust;
329
- }
330
-
331
- /**
332
- * Gets the true wind angle at which maximum speed is achieved for a given true wind speed.
333
- * This indicates the optimal angle for achieving peak boat speed.
334
- *
335
- * @param {number} tws - True Wind Speed in m/s
336
- * @returns {number|null} Angle of maximum speed in radians, or null if no data available
337
- * @example
338
- * const maxSpeedAngle = polar.getMaxSpeedAngle(SI.fromKnots(20)); // Get angle for max speed
339
- */
340
- getMaxSpeedAngle(tws) {
341
- const interpolation = this._findTwsInterpolation(tws);
342
- if (!interpolation) {
343
- return null;
344
- }
345
-
346
- const lowerAngle = this.table[interpolation.lowerIndex]['Max speed angle'];
347
- const upperAngle = this.table[interpolation.upperIndex]['Max speed angle'];
348
- return lowerAngle + interpolation.ratio * (upperAngle - lowerAngle);
349
- }
350
-
351
-
352
- /**
353
- * Gets the boat speed for specific true wind speed and true wind angle.
354
- * Uses bilinear interpolation to provide smooth speed data between measured points.
355
- * Result is scaled by the performance adjustment factor.
356
- *
357
- * Handles negative wind angles (port tack) by using symmetry - negative angles
358
- * are converted to their positive equivalents since sailboat performance is
359
- * typically symmetric between port and starboard tacks.
360
- *
361
- * @param {number} tws - True Wind Speed in m/s
362
- * @param {number} twa - True Wind Angle in radians (positive or negative)
363
- * @returns {number|null} Boat speed in m/s (scaled by perfAdjust), or null if no data available
364
- * @example
365
- * const speed = polar.getBoatSpeed(SI.fromKnots(15), SI.fromDegrees(90)); // Speed at 15kt, 90°
366
- * const speedPort = polar.getBoatSpeed(SI.fromKnots(15), SI.fromDegrees(-90)); // Same as +90°
367
- */
368
- /**
369
- * Returns the polar speed for a single TWS table entry at a given TWA.
370
- * Handles beat-angle quadratic extrapolation, run-angle quadratic extrapolation,
371
- * and normal bilinear lookup.
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
- *
379
- * @private
380
- * @param {Object} entry - A single TWS table entry (this.table[i])
381
- * @param {number} twa - True Wind Angle in radians (0–π, already normalised)
382
- * @returns {number|null} Boat speed in m/s, or null when no tabulated data
383
- * covers the angle
384
- */
385
- _getSpeedFromEntry(entry, twa) {
386
- const beatAngle = entry['Beat angle']
387
- const twaArray = entry.twa
388
-
389
- // ── Beat angle extrapolation zone (pinch point … beat angle) ─────────────
390
- // Quadratic ramp anchored at 0 speed at pinchAngle (25°) through beat angle
391
- // with C1 continuity at beat angle.
392
- if (beatAngle && twa < beatAngle) {
393
- if (!entry._beatExtrap) return 0
394
- const u = twa - entry._beatExtrap.zeroAngle
395
- if (u <= 0) return 0 // below the analytic zero — stopped
396
- return Math.max(0, entry._beatExtrap.a * u * u + entry._beatExtrap.b * u)
397
- }
398
-
399
- // ── Run angle extrapolation zone (last tabulated angle … π) ────────────────
400
- // Limit check is handled by getBoatSpeed using the interpolated extrap limit
401
- // so that the boundary is smooth across TWS brackets. Here we only guard
402
- // against going beyond π or missing coefficients.
403
- if (twaArray && twaArray.length > 0) {
404
- const lastTwa = twaArray[twaArray.length - 1].twa
405
- if (twa > lastTwa) {
406
- if (!entry._runExtrap || twa > Math.PI) return null
407
- const { runVMG, omega, runAngle } = entry._runExtrap
408
- const vmg = runVMG * Math.cos(omega * (twa - runAngle))
409
- const cosTwa = Math.cos(twa) // negative for twa > 90°
410
- if (Math.abs(cosTwa) < 1e-9) return null
411
- return Math.max(0, vmg / cosTwa)
412
- }
413
- }
414
-
415
- // ── Normal bilinear lookup ────────────────────────────────────────────────
416
- const interp = this._findTwaInterpolation(twaArray, twa)
417
- if (!interp) return null
418
- const tbs1 = twaArray[interp.lowerIndex].tbs
419
- const tbs2 = twaArray[interp.upperIndex].tbs
420
- return tbs1 + interp.ratio * (tbs2 - tbs1)
421
- }
422
-
423
- getBoatSpeed(tws, twa) {
424
- // Handle negative angles by using symmetry (port tack = starboard tack performance)
425
- const normalizedTwa = Math.abs(twa)
426
-
427
- const twsInterpolation = this._findTwsInterpolation(tws)
428
- if (!twsInterpolation) return null
429
-
430
- const lowerEntry = this.table[twsInterpolation.lowerIndex]
431
- const upperEntry = this.table[twsInterpolation.upperIndex]
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
-
441
- // ── Run extrap limit: interpolate the limit between the two TWS brackets ───
442
- // Moving the check here (rather than per-entry) prevents a discontinuity
443
- // where the lower bracket's tighter limit would otherwise cause a sudden jump
444
- // to the upper bracket's value.
445
- const lowerLastTwa = lowerEntry.twa?.length ? lowerEntry.twa[lowerEntry.twa.length - 1].twa : 0
446
- const upperLastTwa = upperEntry.twa?.length ? upperEntry.twa[upperEntry.twa.length - 1].twa : 0
447
- if (normalizedTwa > lowerLastTwa || normalizedTwa > upperLastTwa) {
448
- const lowerLimit = lowerEntry._runExtrap?.extrapLimit ?? lowerLastTwa
449
- const upperLimit = upperEntry._runExtrap?.extrapLimit ?? upperLastTwa
450
- const limit = lowerLimit + twsInterpolation.ratio * (upperLimit - lowerLimit)
451
- if (normalizedTwa > limit) return null
452
- }
453
-
454
- const lowerBoatSpeed = this._getSpeedFromEntry(lowerEntry, normalizedTwa)
455
- if (lowerBoatSpeed === null) return null
456
- const upperBoatSpeed = this._getSpeedFromEntry(upperEntry, normalizedTwa)
457
- if (upperBoatSpeed === null) return null
458
-
459
- // Interpolate between the two TWS values and scale by performance adjustment
460
- return (lowerBoatSpeed + twsInterpolation.ratio * (upperBoatSpeed - lowerBoatSpeed)) * this.perfAdjust
461
- }
462
-
463
- /**
464
- * Gets the Velocity Made Good (VMG) for specific true wind speed and true wind angle.
465
- * VMG represents the component of boat speed in the direction toward or away from the wind.
466
- *
467
- * Handles negative wind angles (port tack) correctly by using the original angle
468
- * for VMG calculation while using symmetry for boat speed lookup.
469
- *
470
- * @param {number} tws - True Wind Speed in m/s
471
- * @param {number} twa - True Wind Angle in radians (positive or negative)
472
- * @returns {number|null} VMG in m/s (positive = toward wind, negative = away from wind), or null if no data
473
- * @example
474
- * const vmg = polar.getVMG(SI.fromKnots(12), SI.fromDegrees(45)); // VMG at 12kt, 45°
475
- * const vmgPort = polar.getVMG(SI.fromKnots(12), SI.fromDegrees(-45)); // VMG at 12kt, -45° (port)
476
- */
477
- getVMG(tws, twa) {
478
- const boatSpeed = this.getBoatSpeed(tws, twa);
479
- if (boatSpeed === null) {
480
- return null;
481
- }
482
-
483
- // VMG = boat speed * cos(original true wind angle)
484
- // Use original angle (not normalized) to preserve sign for VMG direction
485
- return boatSpeed * Math.cos(twa);
486
- }
487
-
488
-
489
- /**
490
- * Helper method to add speed data to a polar table entry.
491
- * Handles TWA data addition and max speed tracking.
492
- *
493
- * @private
494
- * @param {Object} polarEntry - Polar table entry to update
495
- * @param {number} angle - True wind angle in radians
496
- * @param {number} tbs - True boat speed in m/s
497
- * @param {number} vmg - Velocity made good in m/s
498
- * @param {string} angleName - Optional angle property name ('Beat angle' or 'Run angle')
499
- * @param {string} VMGName - Optional VMG property name ('Beat VMG' or 'Run VMG')
500
- * @param {Object} app - Optional debug logging object
501
- */
502
- _addSpeedData(polarEntry, angle, tbs, vmg, angleName, VMGName, app) {
503
- if (!polarEntry.twa) {
504
- polarEntry.twa = []
505
- }
506
-
507
- // Set beat/run specific data
508
- if (angleName) {
509
- polarEntry[angleName] = angle
510
- polarEntry[VMGName] = this._roundDec(vmg)
511
- }
512
-
513
- // Add to TWA array (upsert: overwrite if same angle already present)
514
- const existingIdx = polarEntry.twa.findIndex(e => e.twa === angle)
515
- if (existingIdx >= 0) {
516
- app && app.error('Duplicate TWA %.1f°: overwriting existing entry', SI.toDegrees(angle))
517
- polarEntry.twa[existingIdx] = { twa: angle, tbs: tbs, vmg: vmg }
518
- } else {
519
- polarEntry.twa.push({ twa: angle, tbs: tbs, vmg: vmg })
520
- }
521
-
522
- // Update max speed if necessary
523
- if (!polarEntry['Max speed'] || tbs > polarEntry['Max speed']) {
524
- polarEntry['Max speed'] = tbs
525
- polarEntry['Max speed angle'] = angle
526
- app && app.debug('Found max speed: %s', JSON.stringify(polarEntry))
527
- }
528
- }
529
-
530
- /**
531
- * Helper method for decimal rounding.
532
- *
533
- * @private
534
- * @param {number} num - Number to round
535
- * @param {number} decimals - Number of decimal places (default: 2)
536
- * @returns {number} Rounded number
537
- */
538
- _roundDec(num, decimals = 2) {
539
- return Math.round(num * Math.pow(10, decimals)) / Math.pow(10, decimals)
540
- }
541
-
542
- /**
543
- * Computes quadratic extrapolation coefficients for each TWS entry and stores
544
- * them on the entry as `_beatExtrap` and `_runExtrap`.
545
- *
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.
550
- *
551
- * Beat angle model — f(u) = a·u² + b·u, u = twa − zeroAngle (pinchAngle = 25°):
552
- * f(d) = beatSpeed where d = beatAngle − zeroAngle
553
- * f'(d) = slope slope estimated from first data point above beatAngle
554
- * → a = (slope·d − beatSpeed) / d², b = slope − 2·a·d
555
- *
556
- * Run angle model — f(twa) = a·(twa − π)² + c (vertex = zero-slope at 180°):
557
- * f(runAngle) = runSpeed
558
- * f'(runAngle) = slope slope estimated from last two points at/before runAngle
559
- * → a = slope / (2·(runAngle − π)), c = runSpeed − a·(runAngle − π)²
560
- *
561
- * @private
562
- * @param {Array} polar - Polar table array (sorted, beat/run angles set)
563
- * @param {Object} app - Optional debug logger
564
- */
565
- _computeExtrapolationCoefficients(polar, app) {
566
- polar.forEach(entry => {
567
- if (!entry.twa || entry.twa.length < 2) return
568
-
569
- const beatAngle = entry['Beat angle']
570
- const runAngle = entry['Run angle']
571
-
572
- // ── Beat angle quadratic ───────────────────────────────────────────────
573
- // Only meaningful when beatAngle > pinchAngle so the zone is non-degenerate.
574
- if (beatAngle && beatAngle > this.pinchAngle) {
575
- const beatIdx = entry.twa.findIndex(p => Math.abs(p.twa - beatAngle) < 1e-9)
576
- if (beatIdx >= 0) {
577
- const beatSpeed = entry.twa[beatIdx].tbs
578
- // Slope from beat angle to the next tabulated point (bearing away upwind)
579
- let slope = 0
580
- if (beatIdx + 1 < entry.twa.length) {
581
- const p0 = entry.twa[beatIdx]
582
- const p1 = entry.twa[beatIdx + 1]
583
- slope = (p1.tbs - p0.tbs) / (p1.twa - p0.twa)
584
- }
585
- const zeroAngle = this.pinchAngle
586
- const d = beatAngle - zeroAngle
587
- const a = (slope * d - beatSpeed) / (d * d)
588
- const b = slope - 2 * a * d
589
- entry._beatExtrap = { zeroAngle, a, b }
590
- app && app.debug(
591
- 'Beat extrap TWS %.0fkn: beatAngle=%.1f° zeroAt=%.1f° slope=%.4f m/s/rad a=%.4f b=%.4f',
592
- SI.toKnots(entry.tws), SI.toDegrees(beatAngle), SI.toDegrees(zeroAngle), slope, a, b
593
- )
594
- }
595
- }
596
-
597
- // ── Run angle cosine-VMG model ────────────────────────────────────────
598
- // VMG(twa) = runVMG × cos(ω × (twa − runAngle))
599
- // where ω is chosen so that VMG = 0 at twa = 90° (π/2):
600
- // cos(ω × (π/2 − runAngle)) = 0 ⇒ ω × (runAngle − π/2) = π/2
601
- // ω = π / (2 × (runAngle − π/2))
602
- // bsp(twa) = VMG(twa) / cos(twa) (cos(twa) < 0 for twa > 90°, runVMG < 0 ⇒ bsp > 0)
603
- if (runAngle) {
604
- const runIdx = entry.twa.findIndex(p => Math.abs(p.twa - runAngle) < 1e-9)
605
- if (runIdx >= 0) {
606
- // Compute signed VMG: tbs × cos(runAngle). cos(runAngle) < 0 for runAngle > 90°
607
- // so runVMG is negative (downwind). The stored .vmg field uses Math.abs(cos) —
608
- // always positive — so we must not use it here.
609
- const runVMG = entry.twa[runIdx].tbs * Math.cos(runAngle)
610
- const denom = runAngle - Math.PI / 2
611
- if (Math.abs(denom) > 1e-6) {
612
- const omega = Math.PI / (2 * denom)
613
- // Extrapolation limit: start from the last tabulated angle (which may be
614
- // beyond runAngle for some polars) and extend by the same fractional width
615
- // as the beat pinch zone uses on the upwind side.
616
- const lastTwa = entry.twa[entry.twa.length - 1].twa
617
- const extrapLimit = lastTwa + (Math.PI - runAngle) * this.runExtrapFactor
618
- entry._runExtrap = { runVMG, omega, runAngle, extrapLimit }
619
- app && app.debug(
620
- 'Run extrap TWS %.0fkn: runAngle=%.1f° limit=%.1f° ω=%.4f',
621
- SI.toKnots(entry.tws), SI.toDegrees(runAngle), SI.toDegrees(extrapLimit), omega
622
- )
623
- }
624
- }
625
- }
626
- })
627
- }
628
-
629
- /**
630
- * Helper method to sort TWA arrays and find optimal beat/run angles.
631
- * Sorts all TWA data by angle and calculates best VMG angles if not already set.
632
- *
633
- * @private
634
- * @param {Array} polar - Polar table array to process
635
- * @param {Object} app - Optional debug logging object
636
- */
637
- _sortAndOptimizePolar(polar, app) {
638
- const halfPi = Math.PI / 2
639
-
640
- // Sort the twa arrays by angle
641
- polar.forEach(twsEntry => {
642
- if (twsEntry.twa && twsEntry.twa.length > 0) {
643
- twsEntry.twa.sort((a, b) => a.twa - b.twa)
644
- }
645
- })
646
-
647
- // Find beat/run angles if not already set
648
- polar.forEach((twsEntry, index) => {
649
- // Skip entries without twa data
650
- if (!twsEntry.twa || twsEntry.twa.length === 0) {
651
- app && app.debug('Skipping TWS %s - no angle data available', SI.toKnots(twsEntry.tws).toFixed(0))
652
- return
653
- }
654
-
655
- if (typeof twsEntry['Beat angle'] === 'undefined') {
656
- app && app.debug('Finding beat angle for TWS %s', SI.toKnots(twsEntry.tws).toFixed(0))
657
-
658
- let beatVMG = 0, beatElement = 0, runVMG = 0, runElement = 0
659
-
660
- twsEntry.twa.forEach((twaObj, element) => {
661
- if (twaObj.twa < halfPi && twaObj.vmg > beatVMG) {
662
- beatVMG = twaObj.vmg
663
- beatElement = element
664
- } else if (twaObj.twa >= halfPi && twaObj.vmg > runVMG) {
665
- runVMG = twaObj.vmg
666
- runElement = element
667
- }
668
- })
669
-
670
- app && app.debug(
671
- 'beatVMG for %s is %s (angle %s)',
672
- SI.toKnots(twsEntry.tws).toFixed(0),
673
- SI.toKnots(twsEntry.twa[beatElement].vmg).toFixed(2),
674
- SI.toDegrees(twsEntry.twa[beatElement].twa).toFixed(1)
675
- )
676
- app && app.debug(
677
- 'runVMG for %s is %s (angle %s)',
678
- SI.toKnots(twsEntry.tws).toFixed(0),
679
- SI.toKnots(twsEntry.twa[runElement].vmg).toFixed(2),
680
- SI.toDegrees(twsEntry.twa[runElement].twa).toFixed(1)
681
- )
682
-
683
- twsEntry['Beat angle'] = twsEntry.twa[beatElement].twa
684
- twsEntry['Beat VMG'] = twsEntry.twa[beatElement].vmg
685
- twsEntry['Run angle'] = twsEntry.twa[runElement].twa
686
- twsEntry['Run VMG'] = twsEntry.twa[runElement].vmg
687
- }
688
- })
689
- }
690
-
691
- /**
692
- * Helper method to add padding for interpolation at wind angle extremes.
693
- * Adds zero-wind-speed entry and pads TWA ranges from 0 to π radians.
694
- *
695
- * @private
696
- * @param {Array} polar - Polar table array to pad
697
- * @param {Object} app - Optional debug logging object
698
- */
699
- _addPolarPadding(polar, app) {
700
- // Add zero wind speed entry for low wind interpolation
701
- if (polar.length > 0 && polar[0].tws > 0 && polar[0].twa && polar[0].twa.length > 0) {
702
- app && app.debug('Add a 0 line to allow interpolation at very low wind speeds')
703
- const zeroEntry = {
704
- tws: 0.0001,
705
- 'Beat angle': polar[0]['Beat angle'],
706
- 'Beat VMG': 0,
707
- 'Run angle': polar[0]['Run angle'],
708
- 'Run VMG': 0,
709
- 'Max speed': 0,
710
- 'Max speed angle': polar[0]['Max speed angle'],
711
- twa: polar[0].twa.map(twaObj => ({ twa: twaObj.twa, tbs: 0, vmg: 0 }))
712
- }
713
- polar.unshift(zeroEntry)
714
- }
715
-
716
- // Add padding at low and high wind angles for each TWS
717
- polar.forEach(twsEntry => {
718
- // Skip entries without twa data
719
- if (!twsEntry.twa || twsEntry.twa.length === 0) {
720
- return
721
- }
722
-
723
- const twaArray = twsEntry.twa
724
- const lowTWA = twaArray[0].twa
725
-
726
- // Beat angle extrapolation is now handled analytically by _getSpeedFromEntry
727
- // using the quadratic coefficients in entry._beatExtrap. No padding point needed.
728
- })
729
- }
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
-
792
- /**
793
- * Loads polar table data from the canonical PolarResource representation.
794
- *
795
- * @param {Object} resource - Canonical polar resource with axes and boatSpeedMatrix in SI units.
796
- * @returns {PolarTable} Returns this instance for method chaining.
797
- */
798
- loadFromCanonical(resource) {
799
- const twsAxis = resource?.axes?.tws
800
- const twaAxis = resource?.axes?.twa
801
- const matrix = resource?.values?.boatSpeedMatrix
802
-
803
- if (!Array.isArray(twsAxis) || !Array.isArray(twaAxis) || !Array.isArray(matrix)) {
804
- throw new Error('Invalid canonical polar resource')
805
- }
806
- if (matrix.length !== twsAxis.length) {
807
- throw new Error('boatSpeedMatrix row count must match axes.tws length')
808
- }
809
-
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)
817
- const polar = twsAxis.map(tws => ({ tws, twa: [] }))
818
-
819
- for (let rowIndex = 0; rowIndex < twsAxis.length; rowIndex++) {
820
- const row = matrix[rowIndex]
821
- if (!Array.isArray(row) || row.length !== twaAxis.length) {
822
- throw new Error('boatSpeedMatrix column count must match axes.twa length')
823
- }
824
-
825
- for (let colIndex = 0; colIndex < twaAxis.length; colIndex++) {
826
- const angle = twaAxis[colIndex]
827
- const tbs = row[colIndex]
828
- if (!Number.isFinite(angle) || angle < 0 || angle > Math.PI) {
829
- throw new Error('Invalid TWA axis value in canonical resource')
830
- }
831
- if (!Number.isFinite(tbs) || tbs <= 0) continue
832
- const vmg = tbs * Math.abs(Math.cos(angle))
833
- this._addSpeedData(polar[rowIndex], angle, tbs, vmg, null, null, null)
834
- }
835
-
836
- const derivedRow = derivedTargets[rowIndex]
837
-
838
- if (derivedRow?.beat && Number.isFinite(derivedRow.beat.twa) && Number.isFinite(derivedRow.beat.tbs)) {
839
- const beatVmg = Number.isFinite(derivedRow.beat.vmg)
840
- ? derivedRow.beat.vmg
841
- : derivedRow.beat.tbs * Math.abs(Math.cos(derivedRow.beat.twa))
842
- this._addSpeedData(
843
- polar[rowIndex],
844
- derivedRow.beat.twa,
845
- derivedRow.beat.tbs,
846
- beatVmg,
847
- 'Beat angle',
848
- 'Beat VMG',
849
- null
850
- )
851
- }
852
-
853
- if (derivedRow?.run && Number.isFinite(derivedRow.run.twa) && Number.isFinite(derivedRow.run.tbs)) {
854
- const runVmg = Number.isFinite(derivedRow.run.vmg)
855
- ? derivedRow.run.vmg
856
- : derivedRow.run.tbs * Math.abs(Math.cos(derivedRow.run.twa))
857
- this._addSpeedData(
858
- polar[rowIndex],
859
- derivedRow.run.twa,
860
- derivedRow.run.tbs,
861
- runVmg,
862
- 'Run angle',
863
- 'Run VMG',
864
- null
865
- )
866
- }
867
-
868
- if (polar[rowIndex].twa.length === 0) {
869
- throw new Error('Each canonical TWS row must contain at least one positive boat speed')
870
- }
871
- }
872
-
873
- this._sortAndOptimizePolar(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.
878
- this._addPolarPadding(polar, null)
879
- this._computeExtrapolationCoefficients(polar, null)
880
- this.table = polar
881
- return this
882
- }
883
- }
884
-
885
- /**
886
- * Internal structure of this.table after loading polar data:
887
- *
888
- * @typedef {Object} PolarEntry
889
- * @property {number} tws - True Wind Speed in m/s (converted from knots)
890
- * @property {number} Beat angle - Optimal beating angle in radians
891
- * @property {number} Beat VMG - Velocity Made Good when beating in m/s
892
- * @property {number} Run angle - Optimal running angle in radians
893
- * @property {number} Run VMG - Velocity Made Good when running in m/s
894
- * @property {number} Max speed - Maximum boat speed for this TWS in m/s
895
- * @property {number} Max speed angle - Angle at which max speed occurs in radians
896
- * @property {Array<Object>} twa - Array of True Wind Angle data points
897
- * @property {number} twa[].twa - True wind angle in radians
898
- * @property {number} twa[].tbs - True boat speed in m/s
899
- * @property {number} twa[].vmg - Velocity made good in m/s
900
- *
901
- * @example
902
- * // Structure example:
903
- * this.table = [
904
- * {
905
- * tws: 2.57, // True Wind Speed in m/s (converted from knots)
906
- * 'Beat angle': 0.785, // Optimal beating angle in radians
907
- * 'Beat VMG': 1.2, // Velocity Made Good when beating
908
- * 'Run angle': 2.356, // Optimal running angle in radians
909
- * 'Run VMG': 1.8, // Velocity Made Good when running
910
- * 'Max speed': 3.5, // Maximum boat speed for this TWS
911
- * 'Max speed angle': 1.57, // Angle at which max speed occurs
912
- * twa: [ // Array of True Wind Angle data points
913
- * { twa: 0, tbs: 0, vmg: 0 }, // Padded start values
914
- * { twa: 0.087, tbs: 0.5, vmg: 0.49 }, // Angle in rad, boat speed, VMG
915
- * { twa: 0.174, tbs: 1.2, vmg: 1.18 },
916
- * // ... more data points covering 0 to π radians
917
- * { twa: 3.14, tbs: 2.1, vmg: -2.1 } // Padded end values
918
- * ]
919
- * },
920
- * // ... more TWS objects for different wind speeds
921
- * ]
922
- */
923
-
924
- module.exports = { PolarTable };