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.
- package/CHANGELOG.md +26 -0
- package/README.md +64 -84
- package/docs/developer-reference.md +34 -137
- package/openApi.json +12 -798
- package/package.json +14 -8
- package/plugin/index.js +298 -684
- package/public/app.js +53 -638
- package/public/index.html +0 -2
- package/public/plotter.html +20 -106
- package/test/lifecycle.test.js +90 -1
- package/test/outputs.test.js +304 -0
- package/test/routePolicyShim.js +42 -0
- 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 -924
- 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 -464
- package/test/api.test.js +0 -507
- package/test/migration.test.js +0 -170
package/plugin/PolarTable.js
DELETED
|
@@ -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 };
|