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.
- package/CHANGELOG.md +25 -1
- package/README.md +41 -86
- package/docs/developer-reference.md +22 -130
- package/openApi.json +12 -798
- package/package.json +13 -7
- package/plugin/index.js +178 -521
- package/public/app.js +53 -638
- package/public/index.html +0 -2
- package/public/plotter.html +20 -106
- package/test/lifecycle.test.js +6 -1
- package/docs/import.png +0 -0
- package/docs/polar.png +0 -0
- package/docs/polar_management.png +0 -0
- package/plugin/PolarFileStore.js +0 -133
- package/plugin/PolarTable.js +0 -897
- package/plugin/SI.js +0 -21
- package/plugin/import/ExpeditionImporter.js +0 -152
- package/plugin/import/FormatRegistry.js +0 -21
- package/plugin/import/ImportService.js +0 -199
- package/plugin/import/JieterImporter.js +0 -18
- package/plugin/import/OrcSource.js +0 -396
- package/plugin/import/SourceRegistry.js +0 -17
- package/plugin/import/canonical.js +0 -73
- package/plugin/import/matrixText.js +0 -133
- package/plugin/index.js.bak +0 -1003
- package/test/PolarFileStore.test.js +0 -216
- package/test/PolarTable.test.js +0 -349
- package/test/api.test.js +0 -502
- package/test/migration.test.js +0 -164
package/plugin/PolarTable.js
DELETED
|
@@ -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 };
|