signalk-polar-performance-plugin 1.3.3 → 2.0.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,897 +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
- const extrapLimit = lowerLimit + twsInterp.ratio * (upperLimit - lowerLimit)
212
- if (normalizedTwa <= maxTwa) {
213
- twaState = 'in_range'
214
- } else if (normalizedTwa <= extrapLimit) {
215
- twaState = 'extrapolated'
216
- } else {
217
- twaState = 'above_range'
218
- }
219
- }
220
-
221
- return { tws: twsState, twa: twaState }
222
- }
223
-
224
- /**
225
- * Gets the optimal beating angle (upwind) for a given true wind speed.
226
- * Uses linear interpolation between the closest TWS data points.
227
- *
228
- * @param {number} tws - True Wind Speed in m/s
229
- * @returns {number|null} Optimal beating angle in radians, or null if no data available
230
- * @example
231
- * const beatAngle = polar.getBeatAngle(SI.fromKnots(12)); // Get beat angle for 12 knots
232
- */
233
- getBeatAngle(tws) {
234
- const interpolation = this._findTwsInterpolation(tws);
235
- if (!interpolation) {
236
- return null;
237
- }
238
-
239
- const lowerAngle = this.table[interpolation.lowerIndex]['Beat angle'];
240
- const upperAngle = this.table[interpolation.upperIndex]['Beat angle'];
241
- return lowerAngle + interpolation.ratio * (upperAngle - lowerAngle);
242
- }
243
-
244
- /**
245
- * Gets the optimal running angle (downwind) for a given true wind speed.
246
- * Uses linear interpolation between the closest TWS data points.
247
- *
248
- * @param {number} tws - True Wind Speed in m/s
249
- * @returns {number|null} Optimal running angle in radians, or null if no data available
250
- * @example
251
- * const runAngle = polar.getRunAngle(SI.fromKnots(20)); // Get run angle for 20 knots
252
- */
253
- getRunAngle(tws) {
254
- const interpolation = this._findTwsInterpolation(tws);
255
- if (!interpolation) {
256
- return null;
257
- }
258
-
259
- const lowerAngle = this.table[interpolation.lowerIndex]['Run angle'];
260
- const upperAngle = this.table[interpolation.upperIndex]['Run angle'];
261
- return lowerAngle + interpolation.ratio * (upperAngle - lowerAngle);
262
- }
263
-
264
- /**
265
- * Gets the Velocity Made Good (VMG) when beating for a given true wind speed.
266
- * VMG represents the effective speed toward the wind direction.
267
- * Result is scaled by the performance adjustment factor.
268
- *
269
- * @param {number} tws - True Wind Speed in m/s
270
- * @returns {number|null} Beat VMG in m/s (scaled by perfAdjust), or null if no data available
271
- * @example
272
- * const beatVMG = polar.getBeatVMG(SI.fromKnots(15)); // Get beat VMG for 15 knots
273
- */
274
- getBeatVMG(tws) {
275
- const interpolation = this._findTwsInterpolation(tws);
276
- if (!interpolation) {
277
- return null;
278
- }
279
-
280
- const lowerVMG = this.table[interpolation.lowerIndex]['Beat VMG'];
281
- const upperVMG = this.table[interpolation.upperIndex]['Beat VMG'];
282
- return (lowerVMG + interpolation.ratio * (upperVMG - lowerVMG)) * this.perfAdjust;
283
- }
284
-
285
- /**
286
- * Gets the Velocity Made Good (VMG) when running for a given true wind speed.
287
- * VMG represents the effective speed away from the wind direction.
288
- * Result is scaled by the performance adjustment factor.
289
- *
290
- * @param {number} tws - True Wind Speed in m/s
291
- * @returns {number|null} Run VMG in m/s (scaled by perfAdjust), or null if no data available
292
- * @example
293
- * const runVMG = polar.getRunVMG(SI.fromKnots(18)); // Get run VMG for 18 knots
294
- */
295
- getRunVMG(tws) {
296
- const interpolation = this._findTwsInterpolation(tws);
297
- if (!interpolation) {
298
- return null;
299
- }
300
-
301
- const lowerVMG = this.table[interpolation.lowerIndex]['Run VMG'];
302
- const upperVMG = this.table[interpolation.upperIndex]['Run VMG'];
303
- return (lowerVMG + interpolation.ratio * (upperVMG - lowerVMG)) * this.perfAdjust;
304
- }
305
-
306
- /**
307
- * Gets the maximum achievable boat speed for a given true wind speed.
308
- * This represents the peak performance speed regardless of wind angle.
309
- * Result is scaled by the performance adjustment factor.
310
- *
311
- * @param {number} tws - True Wind Speed in m/s
312
- * @returns {number|null} Maximum boat speed in m/s (scaled by perfAdjust), or null if no data available
313
- * @example
314
- * const maxSpeed = polar.getMaxSpeed(SI.fromKnots(25)); // Get max speed for 25 knots
315
- */
316
- getMaxSpeed(tws) {
317
- const interpolation = this._findTwsInterpolation(tws);
318
- if (!interpolation) {
319
- return null;
320
- }
321
-
322
- const lowerSpeed = this.table[interpolation.lowerIndex]['Max speed'];
323
- const upperSpeed = this.table[interpolation.upperIndex]['Max speed'];
324
- return (lowerSpeed + interpolation.ratio * (upperSpeed - lowerSpeed)) * this.perfAdjust;
325
- }
326
-
327
- /**
328
- * Gets the true wind angle at which maximum speed is achieved for a given true wind speed.
329
- * This indicates the optimal angle for achieving peak boat speed.
330
- *
331
- * @param {number} tws - True Wind Speed in m/s
332
- * @returns {number|null} Angle of maximum speed in radians, or null if no data available
333
- * @example
334
- * const maxSpeedAngle = polar.getMaxSpeedAngle(SI.fromKnots(20)); // Get angle for max speed
335
- */
336
- getMaxSpeedAngle(tws) {
337
- const interpolation = this._findTwsInterpolation(tws);
338
- if (!interpolation) {
339
- return null;
340
- }
341
-
342
- const lowerAngle = this.table[interpolation.lowerIndex]['Max speed angle'];
343
- const upperAngle = this.table[interpolation.upperIndex]['Max speed angle'];
344
- return lowerAngle + interpolation.ratio * (upperAngle - lowerAngle);
345
- }
346
-
347
-
348
- /**
349
- * Gets the boat speed for specific true wind speed and true wind angle.
350
- * Uses bilinear interpolation to provide smooth speed data between measured points.
351
- * Result is scaled by the performance adjustment factor.
352
- *
353
- * Handles negative wind angles (port tack) by using symmetry - negative angles
354
- * are converted to their positive equivalents since sailboat performance is
355
- * typically symmetric between port and starboard tacks.
356
- *
357
- * @param {number} tws - True Wind Speed in m/s
358
- * @param {number} twa - True Wind Angle in radians (positive or negative)
359
- * @returns {number|null} Boat speed in m/s (scaled by perfAdjust), or null if no data available
360
- * @example
361
- * const speed = polar.getBoatSpeed(SI.fromKnots(15), SI.fromDegrees(90)); // Speed at 15kt, 90°
362
- * const speedPort = polar.getBoatSpeed(SI.fromKnots(15), SI.fromDegrees(-90)); // Same as +90°
363
- */
364
- /**
365
- * Returns the polar speed for a single TWS table entry at a given TWA.
366
- * Handles beat-angle quadratic extrapolation, run-angle quadratic extrapolation,
367
- * and normal bilinear lookup.
368
- *
369
- * @private
370
- * @param {Object} entry - A single TWS table entry (this.table[i])
371
- * @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 π
373
- */
374
- _getSpeedFromEntry(entry, twa) {
375
- const beatAngle = entry['Beat angle']
376
- const twaArray = entry.twa
377
-
378
- // ── In irons: below the pinch point — no polar data ──────────────────────
379
- if (beatAngle && twa < this.pinchFactor * beatAngle) return null
380
-
381
- // ── Beat angle extrapolation zone (pinch point … beat angle) ─────────────
382
- // Quadratic ramp anchored at 0 speed at pinchAngle (25°) through beat angle
383
- // with C1 continuity at beat angle.
384
- if (beatAngle && twa < beatAngle) {
385
- if (!entry._beatExtrap) return null
386
- const u = twa - entry._beatExtrap.zeroAngle
387
- if (u <= 0) return null // below the analytic zero — treat as in irons
388
- return Math.max(0, entry._beatExtrap.a * u * u + entry._beatExtrap.b * u)
389
- }
390
-
391
- // ── Run angle extrapolation zone (last tabulated angle … π) ────────────────
392
- // Limit check is handled by getBoatSpeed using the interpolated extrap limit
393
- // so that the boundary is smooth across TWS brackets. Here we only guard
394
- // against going beyond π or missing coefficients.
395
- if (twaArray && twaArray.length > 0) {
396
- const lastTwa = twaArray[twaArray.length - 1].twa
397
- if (twa > lastTwa) {
398
- if (!entry._runExtrap || twa > Math.PI) return null
399
- const { runVMG, omega, runAngle } = entry._runExtrap
400
- const vmg = runVMG * Math.cos(omega * (twa - runAngle))
401
- const cosTwa = Math.cos(twa) // negative for twa > 90°
402
- if (Math.abs(cosTwa) < 1e-9) return null
403
- return Math.max(0, vmg / cosTwa)
404
- }
405
- }
406
-
407
- // ── Normal bilinear lookup ────────────────────────────────────────────────
408
- const interp = this._findTwaInterpolation(twaArray, twa)
409
- if (!interp) return null
410
- const tbs1 = twaArray[interp.lowerIndex].tbs
411
- const tbs2 = twaArray[interp.upperIndex].tbs
412
- return tbs1 + interp.ratio * (tbs2 - tbs1)
413
- }
414
-
415
- getBoatSpeed(tws, twa) {
416
- // Handle negative angles by using symmetry (port tack = starboard tack performance)
417
- const normalizedTwa = Math.abs(twa)
418
-
419
- const twsInterpolation = this._findTwsInterpolation(tws)
420
- if (!twsInterpolation) return null
421
-
422
- const lowerEntry = this.table[twsInterpolation.lowerIndex]
423
- const upperEntry = this.table[twsInterpolation.upperIndex]
424
-
425
- // ── Run extrap limit: interpolate the limit between the two TWS brackets ───
426
- // Moving the check here (rather than per-entry) prevents a discontinuity
427
- // where the lower bracket's tighter limit would otherwise cause a sudden jump
428
- // to the upper bracket's value.
429
- const lowerLastTwa = lowerEntry.twa?.length ? lowerEntry.twa[lowerEntry.twa.length - 1].twa : 0
430
- const upperLastTwa = upperEntry.twa?.length ? upperEntry.twa[upperEntry.twa.length - 1].twa : 0
431
- if (normalizedTwa > lowerLastTwa || normalizedTwa > upperLastTwa) {
432
- const lowerLimit = lowerEntry._runExtrap?.extrapLimit ?? lowerLastTwa
433
- const upperLimit = upperEntry._runExtrap?.extrapLimit ?? upperLastTwa
434
- const limit = lowerLimit + twsInterpolation.ratio * (upperLimit - lowerLimit)
435
- if (normalizedTwa > limit) return null
436
- }
437
-
438
- const lowerBoatSpeed = this._getSpeedFromEntry(lowerEntry, normalizedTwa)
439
- if (lowerBoatSpeed === null) return null
440
- const upperBoatSpeed = this._getSpeedFromEntry(upperEntry, normalizedTwa)
441
- if (upperBoatSpeed === null) return null
442
-
443
- // Interpolate between the two TWS values and scale by performance adjustment
444
- return (lowerBoatSpeed + twsInterpolation.ratio * (upperBoatSpeed - lowerBoatSpeed)) * this.perfAdjust
445
- }
446
-
447
- /**
448
- * Gets the Velocity Made Good (VMG) for specific true wind speed and true wind angle.
449
- * VMG represents the component of boat speed in the direction toward or away from the wind.
450
- *
451
- * Handles negative wind angles (port tack) correctly by using the original angle
452
- * for VMG calculation while using symmetry for boat speed lookup.
453
- *
454
- * @param {number} tws - True Wind Speed in m/s
455
- * @param {number} twa - True Wind Angle in radians (positive or negative)
456
- * @returns {number|null} VMG in m/s (positive = toward wind, negative = away from wind), or null if no data
457
- * @example
458
- * const vmg = polar.getVMG(SI.fromKnots(12), SI.fromDegrees(45)); // VMG at 12kt, 45°
459
- * const vmgPort = polar.getVMG(SI.fromKnots(12), SI.fromDegrees(-45)); // VMG at 12kt, -45° (port)
460
- */
461
- getVMG(tws, twa) {
462
- const boatSpeed = this.getBoatSpeed(tws, twa);
463
- if (boatSpeed === null) {
464
- return null;
465
- }
466
-
467
- // VMG = boat speed * cos(original true wind angle)
468
- // Use original angle (not normalized) to preserve sign for VMG direction
469
- return boatSpeed * Math.cos(twa);
470
- }
471
-
472
-
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
- /**
493
- * Helper method to add speed data to a polar table entry.
494
- * Handles TWA data addition and max speed tracking.
495
- *
496
- * @private
497
- * @param {Object} polarEntry - Polar table entry to update
498
- * @param {number} angle - True wind angle in radians
499
- * @param {number} tbs - True boat speed in m/s
500
- * @param {number} vmg - Velocity made good in m/s
501
- * @param {string} angleName - Optional angle property name ('Beat angle' or 'Run angle')
502
- * @param {string} VMGName - Optional VMG property name ('Beat VMG' or 'Run VMG')
503
- * @param {Object} app - Optional debug logging object
504
- */
505
- _addSpeedData(polarEntry, angle, tbs, vmg, angleName, VMGName, app) {
506
- if (!polarEntry.twa) {
507
- polarEntry.twa = []
508
- }
509
-
510
- // Set beat/run specific data
511
- if (angleName) {
512
- polarEntry[angleName] = angle
513
- polarEntry[VMGName] = this._roundDec(vmg)
514
- }
515
-
516
- // Add to TWA array (upsert: overwrite if same angle already present)
517
- const existingIdx = polarEntry.twa.findIndex(e => e.twa === angle)
518
- if (existingIdx >= 0) {
519
- app && app.error('Duplicate TWA %.1f°: overwriting existing entry', SI.toDegrees(angle))
520
- polarEntry.twa[existingIdx] = { twa: angle, tbs: tbs, vmg: vmg }
521
- } else {
522
- polarEntry.twa.push({ twa: angle, tbs: tbs, vmg: vmg })
523
- }
524
-
525
- // Update max speed if necessary
526
- if (!polarEntry['Max speed'] || tbs > polarEntry['Max speed']) {
527
- polarEntry['Max speed'] = tbs
528
- polarEntry['Max speed angle'] = angle
529
- app && app.debug('Found max speed: %s', JSON.stringify(polarEntry))
530
- }
531
- }
532
-
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
- /**
575
- * Helper method for decimal rounding.
576
- *
577
- * @private
578
- * @param {number} num - Number to round
579
- * @param {number} decimals - Number of decimal places (default: 2)
580
- * @returns {number} Rounded number
581
- */
582
- _roundDec(num, decimals = 2) {
583
- return Math.round(num * Math.pow(10, decimals)) / Math.pow(10, decimals)
584
- }
585
-
586
- /**
587
- * Computes quadratic extrapolation coefficients for each TWS entry and stores
588
- * them on the entry as `_beatExtrap` and `_runExtrap`.
589
- *
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).
592
- *
593
- * Beat angle model — f(u) = a·u² + b·u, u = twa − zeroAngle (pinchAngle = 25°):
594
- * f(d) = beatSpeed where d = beatAngle − zeroAngle
595
- * f'(d) = slope slope estimated from first data point above beatAngle
596
- * → a = (slope·d − beatSpeed) / d², b = slope − 2·a·d
597
- *
598
- * Run angle model — f(twa) = a·(twa − π)² + c (vertex = zero-slope at 180°):
599
- * f(runAngle) = runSpeed
600
- * f'(runAngle) = slope slope estimated from last two points at/before runAngle
601
- * → a = slope / (2·(runAngle − π)), c = runSpeed − a·(runAngle − π)²
602
- *
603
- * @private
604
- * @param {Array} polar - Polar table array (sorted, beat/run angles set)
605
- * @param {Object} app - Optional debug logger
606
- */
607
- _computeExtrapolationCoefficients(polar, app) {
608
- polar.forEach(entry => {
609
- if (!entry.twa || entry.twa.length < 2) return
610
-
611
- const beatAngle = entry['Beat angle']
612
- const runAngle = entry['Run angle']
613
-
614
- // ── Beat angle quadratic ───────────────────────────────────────────────
615
- // Only meaningful when beatAngle > pinchAngle so the zone is non-degenerate.
616
- if (beatAngle && beatAngle > this.pinchAngle) {
617
- const beatIdx = entry.twa.findIndex(p => Math.abs(p.twa - beatAngle) < 1e-9)
618
- if (beatIdx >= 0) {
619
- const beatSpeed = entry.twa[beatIdx].tbs
620
- // Slope from beat angle to the next tabulated point (bearing away upwind)
621
- let slope = 0
622
- if (beatIdx + 1 < entry.twa.length) {
623
- const p0 = entry.twa[beatIdx]
624
- const p1 = entry.twa[beatIdx + 1]
625
- slope = (p1.tbs - p0.tbs) / (p1.twa - p0.twa)
626
- }
627
- const zeroAngle = this.pinchAngle
628
- const d = beatAngle - zeroAngle
629
- const a = (slope * d - beatSpeed) / (d * d)
630
- const b = slope - 2 * a * d
631
- entry._beatExtrap = { zeroAngle, a, b }
632
- app && app.debug(
633
- 'Beat extrap TWS %.0fkn: beatAngle=%.1f° zeroAt=%.1f° slope=%.4f m/s/rad a=%.4f b=%.4f',
634
- SI.toKnots(entry.tws), SI.toDegrees(beatAngle), SI.toDegrees(zeroAngle), slope, a, b
635
- )
636
- }
637
- }
638
-
639
- // ── Run angle cosine-VMG model ────────────────────────────────────────
640
- // VMG(twa) = runVMG × cos(ω × (twa − runAngle))
641
- // where ω is chosen so that VMG = 0 at twa = 90° (π/2):
642
- // cos(ω × (π/2 − runAngle)) = 0 ⇒ ω × (runAngle − π/2) = π/2
643
- // ω = π / (2 × (runAngle − π/2))
644
- // bsp(twa) = VMG(twa) / cos(twa) (cos(twa) < 0 for twa > 90°, runVMG < 0 ⇒ bsp > 0)
645
- if (runAngle) {
646
- const runIdx = entry.twa.findIndex(p => Math.abs(p.twa - runAngle) < 1e-9)
647
- if (runIdx >= 0) {
648
- // Compute signed VMG: tbs × cos(runAngle). cos(runAngle) < 0 for runAngle > 90°
649
- // so runVMG is negative (downwind). The stored .vmg field uses Math.abs(cos) —
650
- // always positive — so we must not use it here.
651
- const runVMG = entry.twa[runIdx].tbs * Math.cos(runAngle)
652
- const denom = runAngle - Math.PI / 2
653
- if (Math.abs(denom) > 1e-6) {
654
- const omega = Math.PI / (2 * denom)
655
- // Extrapolation limit: start from the last tabulated angle (which may be
656
- // beyond runAngle for some polars) and extend by the same fractional width
657
- // as the beat pinch zone uses on the upwind side.
658
- const lastTwa = entry.twa[entry.twa.length - 1].twa
659
- const extrapLimit = lastTwa + (Math.PI - runAngle) * this.runExtrapFactor
660
- entry._runExtrap = { runVMG, omega, runAngle, extrapLimit }
661
- app && app.debug(
662
- 'Run extrap TWS %.0fkn: runAngle=%.1f° limit=%.1f° ω=%.4f',
663
- SI.toKnots(entry.tws), SI.toDegrees(runAngle), SI.toDegrees(extrapLimit), omega
664
- )
665
- }
666
- }
667
- }
668
- })
669
- }
670
-
671
- /**
672
- * Helper method to sort TWA arrays and find optimal beat/run angles.
673
- * Sorts all TWA data by angle and calculates best VMG angles if not already set.
674
- *
675
- * @private
676
- * @param {Array} polar - Polar table array to process
677
- * @param {Object} app - Optional debug logging object
678
- */
679
- _sortAndOptimizePolar(polar, app) {
680
- const halfPi = Math.PI / 2
681
-
682
- // Sort the twa arrays by angle
683
- polar.forEach(twsEntry => {
684
- if (twsEntry.twa && twsEntry.twa.length > 0) {
685
- twsEntry.twa.sort((a, b) => a.twa - b.twa)
686
- }
687
- })
688
-
689
- // Find beat/run angles if not already set
690
- polar.forEach((twsEntry, index) => {
691
- // Skip entries without twa data
692
- if (!twsEntry.twa || twsEntry.twa.length === 0) {
693
- app && app.debug('Skipping TWS %s - no angle data available', SI.toKnots(twsEntry.tws).toFixed(0))
694
- return
695
- }
696
-
697
- if (typeof twsEntry['Beat angle'] === 'undefined') {
698
- app && app.debug('Finding beat angle for TWS %s', SI.toKnots(twsEntry.tws).toFixed(0))
699
-
700
- let beatVMG = 0, beatElement = 0, runVMG = 0, runElement = 0
701
-
702
- twsEntry.twa.forEach((twaObj, element) => {
703
- if (twaObj.twa < halfPi && twaObj.vmg > beatVMG) {
704
- beatVMG = twaObj.vmg
705
- beatElement = element
706
- } else if (twaObj.twa >= halfPi && twaObj.vmg > runVMG) {
707
- runVMG = twaObj.vmg
708
- runElement = element
709
- }
710
- })
711
-
712
- app && app.debug(
713
- 'beatVMG for %s is %s (angle %s)',
714
- SI.toKnots(twsEntry.tws).toFixed(0),
715
- SI.toKnots(twsEntry.twa[beatElement].vmg).toFixed(2),
716
- SI.toDegrees(twsEntry.twa[beatElement].twa).toFixed(1)
717
- )
718
- app && app.debug(
719
- 'runVMG for %s is %s (angle %s)',
720
- SI.toKnots(twsEntry.tws).toFixed(0),
721
- SI.toKnots(twsEntry.twa[runElement].vmg).toFixed(2),
722
- SI.toDegrees(twsEntry.twa[runElement].twa).toFixed(1)
723
- )
724
-
725
- twsEntry['Beat angle'] = twsEntry.twa[beatElement].twa
726
- twsEntry['Beat VMG'] = twsEntry.twa[beatElement].vmg
727
- twsEntry['Run angle'] = twsEntry.twa[runElement].twa
728
- twsEntry['Run VMG'] = twsEntry.twa[runElement].vmg
729
- }
730
- })
731
- }
732
-
733
- /**
734
- * Helper method to add padding for interpolation at wind angle extremes.
735
- * Adds zero-wind-speed entry and pads TWA ranges from 0 to π radians.
736
- *
737
- * @private
738
- * @param {Array} polar - Polar table array to pad
739
- * @param {Object} app - Optional debug logging object
740
- */
741
- _addPolarPadding(polar, app) {
742
- // Add zero wind speed entry for low wind interpolation
743
- if (polar.length > 0 && polar[0].tws > 0 && polar[0].twa && polar[0].twa.length > 0) {
744
- app && app.debug('Add a 0 line to allow interpolation at very low wind speeds')
745
- const zeroEntry = {
746
- tws: 0.0001,
747
- 'Beat angle': polar[0]['Beat angle'],
748
- 'Beat VMG': 0,
749
- 'Run angle': polar[0]['Run angle'],
750
- 'Run VMG': 0,
751
- 'Max speed': 0,
752
- 'Max speed angle': polar[0]['Max speed angle'],
753
- twa: polar[0].twa.map(twaObj => ({ twa: twaObj.twa, tbs: 0, vmg: 0 }))
754
- }
755
- polar.unshift(zeroEntry)
756
- }
757
-
758
- // Add padding at low and high wind angles for each TWS
759
- polar.forEach(twsEntry => {
760
- // Skip entries without twa data
761
- if (!twsEntry.twa || twsEntry.twa.length === 0) {
762
- return
763
- }
764
-
765
- const twaArray = twsEntry.twa
766
- const lowTWA = twaArray[0].twa
767
-
768
- // Beat angle extrapolation is now handled analytically by _getSpeedFromEntry
769
- // using the quadratic coefficients in entry._beatExtrap. No padding point needed.
770
- })
771
- }
772
-
773
- /**
774
- * Loads polar table data from the canonical PolarResource representation.
775
- *
776
- * @param {Object} resource - Canonical polar resource with axes and boatSpeedMatrix in SI units.
777
- * @returns {PolarTable} Returns this instance for method chaining.
778
- */
779
- loadFromCanonical(resource) {
780
- const twsAxis = resource?.axes?.tws
781
- const twaAxis = resource?.axes?.twa
782
- const matrix = resource?.values?.boatSpeedMatrix
783
-
784
- if (!Array.isArray(twsAxis) || !Array.isArray(twaAxis) || !Array.isArray(matrix)) {
785
- throw new Error('Invalid canonical polar resource')
786
- }
787
- if (matrix.length !== twsAxis.length) {
788
- throw new Error('boatSpeedMatrix row count must match axes.tws length')
789
- }
790
-
791
- const derivedRows = Array.isArray(resource?.derived?.rows) ? resource.derived.rows : []
792
- const polar = twsAxis.map(tws => ({ tws, twa: [] }))
793
-
794
- for (let rowIndex = 0; rowIndex < twsAxis.length; rowIndex++) {
795
- const row = matrix[rowIndex]
796
- if (!Array.isArray(row) || row.length !== twaAxis.length) {
797
- throw new Error('boatSpeedMatrix column count must match axes.twa length')
798
- }
799
-
800
- for (let colIndex = 0; colIndex < twaAxis.length; colIndex++) {
801
- const angle = twaAxis[colIndex]
802
- const tbs = row[colIndex]
803
- if (!Number.isFinite(angle) || angle < 0 || angle > Math.PI) {
804
- throw new Error('Invalid TWA axis value in canonical resource')
805
- }
806
- if (!Number.isFinite(tbs) || tbs <= 0) continue
807
- const vmg = tbs * Math.abs(Math.cos(angle))
808
- this._addSpeedData(polar[rowIndex], angle, tbs, vmg, null, null, null)
809
- }
810
-
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)
814
-
815
- if (derivedRow?.beat && Number.isFinite(derivedRow.beat.twa) && Number.isFinite(derivedRow.beat.tbs)) {
816
- const beatVmg = Number.isFinite(derivedRow.beat.vmg)
817
- ? derivedRow.beat.vmg
818
- : derivedRow.beat.tbs * Math.abs(Math.cos(derivedRow.beat.twa))
819
- this._addSpeedData(
820
- polar[rowIndex],
821
- derivedRow.beat.twa,
822
- derivedRow.beat.tbs,
823
- beatVmg,
824
- 'Beat angle',
825
- 'Beat VMG',
826
- null
827
- )
828
- }
829
-
830
- if (derivedRow?.run && Number.isFinite(derivedRow.run.twa) && Number.isFinite(derivedRow.run.tbs)) {
831
- const runVmg = Number.isFinite(derivedRow.run.vmg)
832
- ? derivedRow.run.vmg
833
- : derivedRow.run.tbs * Math.abs(Math.cos(derivedRow.run.twa))
834
- this._addSpeedData(
835
- polar[rowIndex],
836
- derivedRow.run.twa,
837
- derivedRow.run.tbs,
838
- runVmg,
839
- 'Run angle',
840
- 'Run VMG',
841
- null
842
- )
843
- }
844
-
845
- if (polar[rowIndex].twa.length === 0) {
846
- throw new Error('Each canonical TWS row must contain at least one positive boat speed')
847
- }
848
- }
849
-
850
- this._sortAndOptimizePolar(polar, null)
851
- this._computeExtrapolationCoefficients(polar, null)
852
- this._addPolarPadding(polar, null)
853
- this.table = polar
854
- return this
855
- }
856
- }
857
-
858
- /**
859
- * Internal structure of this.table after loading polar data:
860
- *
861
- * @typedef {Object} PolarEntry
862
- * @property {number} tws - True Wind Speed in m/s (converted from knots)
863
- * @property {number} Beat angle - Optimal beating angle in radians
864
- * @property {number} Beat VMG - Velocity Made Good when beating in m/s
865
- * @property {number} Run angle - Optimal running angle in radians
866
- * @property {number} Run VMG - Velocity Made Good when running in m/s
867
- * @property {number} Max speed - Maximum boat speed for this TWS in m/s
868
- * @property {number} Max speed angle - Angle at which max speed occurs in radians
869
- * @property {Array<Object>} twa - Array of True Wind Angle data points
870
- * @property {number} twa[].twa - True wind angle in radians
871
- * @property {number} twa[].tbs - True boat speed in m/s
872
- * @property {number} twa[].vmg - Velocity made good in m/s
873
- *
874
- * @example
875
- * // Structure example:
876
- * this.table = [
877
- * {
878
- * tws: 2.57, // True Wind Speed in m/s (converted from knots)
879
- * 'Beat angle': 0.785, // Optimal beating angle in radians
880
- * 'Beat VMG': 1.2, // Velocity Made Good when beating
881
- * 'Run angle': 2.356, // Optimal running angle in radians
882
- * 'Run VMG': 1.8, // Velocity Made Good when running
883
- * 'Max speed': 3.5, // Maximum boat speed for this TWS
884
- * 'Max speed angle': 1.57, // Angle at which max speed occurs
885
- * twa: [ // Array of True Wind Angle data points
886
- * { twa: 0, tbs: 0, vmg: 0 }, // Padded start values
887
- * { twa: 0.087, tbs: 0.5, vmg: 0.49 }, // Angle in rad, boat speed, VMG
888
- * { twa: 0.174, tbs: 1.2, vmg: 1.18 },
889
- * // ... more data points covering 0 to π radians
890
- * { twa: 3.14, tbs: 2.1, vmg: -2.1 } // Padded end values
891
- * ]
892
- * },
893
- * // ... more TWS objects for different wind speeds
894
- * ]
895
- */
896
-
897
- module.exports = { PolarTable };