signalk-polar-performance-plugin 2.0.0 → 2.2.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 +34 -1
- package/README.md +38 -10
- package/docs/developer-reference.md +45 -21
- package/package.json +8 -11
- package/plugin/index.js +245 -294
- package/public/app.js +11 -1
- package/test/lifecycle.test.js +91 -1
- package/test/outputs.test.js +419 -0
- package/test/routePolicyShim.js +42 -0
package/plugin/index.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { Polar } = require('polar-math')
|
|
4
3
|
const {
|
|
5
|
-
MessageHandler,
|
|
6
4
|
createSmoothedPolar,
|
|
7
5
|
createSmoothedHandler,
|
|
8
6
|
SmoothedAngle,
|
|
@@ -14,8 +12,6 @@ const {
|
|
|
14
12
|
|
|
15
13
|
const CURRENT_SETTINGS_VERSION = 2
|
|
16
14
|
|
|
17
|
-
const STALE_RESUBSCRIBE_PERIOD = 60000 // ms — idle period before live input subscriptions are re-established
|
|
18
|
-
|
|
19
15
|
const DEFAULT_SETTINGS = {
|
|
20
16
|
settingsVersion: CURRENT_SETTINGS_VERSION,
|
|
21
17
|
showAllTwsLines: true,
|
|
@@ -43,15 +39,16 @@ module.exports = (app) => {
|
|
|
43
39
|
let hasPendingChanges = false
|
|
44
40
|
let isRunning = false
|
|
45
41
|
|
|
46
|
-
// Active polar —
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
let
|
|
53
|
-
let
|
|
54
|
-
let
|
|
42
|
+
// Active polar — provided entirely by signalk-polar-management's in-process
|
|
43
|
+
// service, discovered through the `polarManagement` PropertyValue. The active
|
|
44
|
+
// handle mirrors the shared active polar, sailplan, performance factor and
|
|
45
|
+
// wave-correction toggle on every calculation, so this plugin applies none of
|
|
46
|
+
// them itself. Descriptive metadata and the plotter's TWS axis come from the
|
|
47
|
+
// handle's describe() snapshot — no resource lookups or `polars.*` paths.
|
|
48
|
+
let polarHandle = null // live activePolar() calculation handle, or null
|
|
49
|
+
let polarDescription = null // latest handle.describe() result, or null
|
|
50
|
+
let unsubscribeDiscovery = null // PropertyValues discovery subscription
|
|
51
|
+
let unsubscribeChanges = null // polarHandle.onChange subscription
|
|
55
52
|
|
|
56
53
|
let windSmoother = null
|
|
57
54
|
let bspSmoother = null
|
|
@@ -85,7 +82,7 @@ module.exports = (app) => {
|
|
|
85
82
|
function _setLifecycleWarning(id, status, path) {
|
|
86
83
|
const safePath = path || 'unknown path'
|
|
87
84
|
const message = status === 'idle'
|
|
88
|
-
? `Input ${id} is idle on ${safePath}
|
|
85
|
+
? `Input ${id} is idle on ${safePath}`
|
|
89
86
|
: `Input ${id} is stale on ${safePath}`
|
|
90
87
|
lifecycleWarningMap.set(id, {
|
|
91
88
|
id,
|
|
@@ -105,9 +102,8 @@ module.exports = (app) => {
|
|
|
105
102
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
106
103
|
}
|
|
107
104
|
|
|
108
|
-
function _wireHandlerWatchdog({ id, getPath
|
|
105
|
+
function _wireHandlerWatchdog({ id, getPath }) {
|
|
109
106
|
return {
|
|
110
|
-
idlePeriod: STALE_RESUBSCRIBE_PERIOD,
|
|
111
107
|
onDelta: () => {
|
|
112
108
|
_clearLifecycleWarning(id)
|
|
113
109
|
},
|
|
@@ -120,14 +116,36 @@ module.exports = (app) => {
|
|
|
120
116
|
onIdle: () => {
|
|
121
117
|
if (!isRunning) return
|
|
122
118
|
const path = getPath()
|
|
123
|
-
app.debug(`[${plugin.id}] idle input ${id} on ${path}
|
|
119
|
+
app.debug(`[${plugin.id}] idle input ${id} on ${path}`)
|
|
124
120
|
_setLifecycleWarning(id, 'idle', path)
|
|
125
|
-
unsubscribe()
|
|
126
|
-
subscribe()
|
|
127
121
|
}
|
|
128
122
|
}
|
|
129
123
|
}
|
|
130
124
|
|
|
125
|
+
// Ratios below this denominator (m/s) are numerically meaningless (near-zero wind).
|
|
126
|
+
const MIN_RATIO_DENOMINATOR = 0.01
|
|
127
|
+
|
|
128
|
+
function finiteOrNull(value) {
|
|
129
|
+
return Number.isFinite(value) ? value : null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function signed(angle, port) {
|
|
133
|
+
return angle === null ? null : angle * port
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function ratio(numerator, denominator) {
|
|
137
|
+
if (!Number.isFinite(numerator) || !Number.isFinite(denominator)) return null
|
|
138
|
+
return denominator > MIN_RATIO_DENOMINATOR ? numerator / denominator : null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function publishNulls(paths) {
|
|
142
|
+
if (paths.length === 0) return
|
|
143
|
+
paths.forEach(path => { lastOutputs[path] = null })
|
|
144
|
+
app.handleMessage(plugin.id, {
|
|
145
|
+
updates: [{ values: paths.map(path => ({ path, value: null })) }]
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
function getSmootherClass(type) {
|
|
132
150
|
switch (type) {
|
|
133
151
|
case 'None': return BaseSmoother
|
|
@@ -167,9 +185,8 @@ module.exports = (app) => {
|
|
|
167
185
|
}
|
|
168
186
|
|
|
169
187
|
if (s.settingsVersion < 2) {
|
|
170
|
-
// v1 → v2: polar selection and performance adjustment moved to
|
|
171
|
-
//
|
|
172
|
-
// by a 'polars' resource-provider plugin (e.g. signalk-polar-management).
|
|
188
|
+
// v1 → v2: polar selection and performance adjustment moved to
|
|
189
|
+
// signalk-polar-management, which owns the shared selection and factor.
|
|
173
190
|
delete s.activePolar
|
|
174
191
|
delete s.perfAdjust
|
|
175
192
|
s.settingsVersion = 2
|
|
@@ -192,59 +209,59 @@ module.exports = (app) => {
|
|
|
192
209
|
}
|
|
193
210
|
|
|
194
211
|
// ---------------------------------------------------------------------------
|
|
195
|
-
// Active polar —
|
|
212
|
+
// Active polar — calculations and descriptive metadata via the polar
|
|
213
|
+
// management in-process service
|
|
196
214
|
// ---------------------------------------------------------------------------
|
|
197
215
|
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
216
|
+
// Refreshes the cached describe() snapshot that feeds /meta and the plotter's
|
|
217
|
+
// TWS axis, clears outputs when the polar becomes unavailable, and keeps the
|
|
218
|
+
// plugin status in sync with the current selection.
|
|
219
|
+
function refreshDescription() {
|
|
220
|
+
if (!polarHandle) return
|
|
221
|
+
const hadPolar = polarDescription?.available === true
|
|
222
|
+
polarDescription = polarHandle.describe()
|
|
223
|
+
if (polarDescription.available) {
|
|
224
|
+
app.setPluginStatus(`Polar '${polarDescription.name || polarDescription.id}' active`)
|
|
225
|
+
} else {
|
|
226
|
+
if (hadPolar) nullifyOutputs()
|
|
227
|
+
app.setPluginStatus(polarDescription.reason === 'no_active_polar'
|
|
228
|
+
? 'No active polar selected — select one in the polar management webapp'
|
|
229
|
+
: `Polar calculations unavailable (${String(polarDescription.reason).replace(/_/g, ' ')})`)
|
|
212
230
|
}
|
|
213
231
|
}
|
|
214
232
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
233
|
+
// Discovery of the polar calculation service announced by
|
|
234
|
+
// signalk-polar-management through the `polarManagement` PropertyValue.
|
|
235
|
+
// Only the latest record counts: an `available: false` record means the
|
|
236
|
+
// provider stopped and old handles are permanently unusable.
|
|
237
|
+
function handleServiceDiscovery(history) {
|
|
238
|
+
const record = history.findLast(entry => entry?.setter === 'signalk-polar-management')
|
|
239
|
+
const service = record?.value
|
|
240
|
+
unsubscribeChanges?.()
|
|
241
|
+
unsubscribeChanges = null
|
|
242
|
+
const hadPolar = polarDescription?.available === true
|
|
243
|
+
polarHandle = null
|
|
244
|
+
polarDescription = null
|
|
245
|
+
if (service?.apiVersion !== 1 || service.available !== true ||
|
|
246
|
+
typeof service.activePolar !== 'function' || typeof service.polar !== 'function') {
|
|
247
|
+
if (hadPolar) nullifyOutputs()
|
|
248
|
+
app.setPluginStatus('Polar calculation service not available — install or enable signalk-polar-management')
|
|
224
249
|
return
|
|
225
250
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
app.setPluginError(
|
|
251
|
+
const handle = service.activePolar()
|
|
252
|
+
if (typeof handle.describe !== 'function') {
|
|
253
|
+
if (hadPolar) nullifyOutputs()
|
|
254
|
+
app.setPluginError('signalk-polar-management is too old — update it to a version whose polar handles provide describe()')
|
|
230
255
|
return
|
|
231
256
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
app.setPluginStatus(`Polar '${doc.name || id}' loaded`)
|
|
239
|
-
} catch (e) {
|
|
240
|
-
// Keep the last valid polar (if any) rather than dropping outputs on a bad update.
|
|
241
|
-
app.setPluginError(`Invalid active polar '${id}': ${e.message}`)
|
|
242
|
-
if (!polar) nullifyOutputs()
|
|
243
|
-
}
|
|
244
|
-
}).catch((e) => {
|
|
245
|
-
app.setPluginError(`Cannot load active polar '${id}': ${e.message}`)
|
|
246
|
-
if (!polar) nullifyOutputs()
|
|
257
|
+
polarHandle = handle
|
|
258
|
+
// Every change kind can affect the description (selection, edits, factor).
|
|
259
|
+
// Callbacks must stay short, so the snapshot refresh is deferred.
|
|
260
|
+
unsubscribeChanges = polarHandle.onChange((change) => {
|
|
261
|
+
app.debug('Polar configuration changed: %o', change)
|
|
262
|
+
setImmediate(() => { if (polarHandle === handle) refreshDescription() })
|
|
247
263
|
})
|
|
264
|
+
refreshDescription()
|
|
248
265
|
}
|
|
249
266
|
|
|
250
267
|
// ---------------------------------------------------------------------------
|
|
@@ -290,9 +307,8 @@ module.exports = (app) => {
|
|
|
290
307
|
SmootherClass: SC,
|
|
291
308
|
smootherOptions: so,
|
|
292
309
|
..._wireHandlerWatchdog({
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
subscribe: () => hdgSmoother?.subscribe(false, true),
|
|
310
|
+
id: 'hdg.smoothed',
|
|
311
|
+
getPath: () => hdgSmoother?.handler?.path ?? 'navigation.headingTrue',
|
|
296
312
|
})
|
|
297
313
|
})
|
|
298
314
|
} else if (!settings.tackTrue && hdgSmoother) {
|
|
@@ -309,11 +325,7 @@ module.exports = (app) => {
|
|
|
309
325
|
const disabledPaths = keys
|
|
310
326
|
.filter(k => OUTPUT_PATHS[k] && !settings[k])
|
|
311
327
|
.flatMap(k => OUTPUT_PATHS[k])
|
|
312
|
-
|
|
313
|
-
app.handleMessage(plugin.id, {
|
|
314
|
-
updates: [{ values: disabledPaths.map(path => ({ path, value: null })) }]
|
|
315
|
-
})
|
|
316
|
-
}
|
|
328
|
+
publishNulls(disabledPaths)
|
|
317
329
|
}
|
|
318
330
|
|
|
319
331
|
// ---------------------------------------------------------------------------
|
|
@@ -322,10 +334,7 @@ module.exports = (app) => {
|
|
|
322
334
|
// ---------------------------------------------------------------------------
|
|
323
335
|
|
|
324
336
|
function nullifyOutputs() {
|
|
325
|
-
|
|
326
|
-
app.handleMessage(plugin.id, {
|
|
327
|
-
updates: [{ values: allPaths.map(path => ({ path, value: null })) }]
|
|
328
|
-
})
|
|
337
|
+
publishNulls(Object.values(OUTPUT_PATHS).flat())
|
|
329
338
|
}
|
|
330
339
|
|
|
331
340
|
// ---------------------------------------------------------------------------
|
|
@@ -334,169 +343,132 @@ module.exports = (app) => {
|
|
|
334
343
|
|
|
335
344
|
function computeAndSend() {
|
|
336
345
|
if (hasPendingChanges) applyOptionChanges()
|
|
337
|
-
if (!
|
|
346
|
+
if (!polarHandle) return
|
|
338
347
|
|
|
339
348
|
const wind = windSmoother.polarValue
|
|
340
349
|
const TWS = wind.magnitude
|
|
341
350
|
const TWAsigned = wind.angle
|
|
342
|
-
|
|
351
|
+
const windValid = Number.isFinite(TWS) && Number.isFinite(TWAsigned)
|
|
343
352
|
|
|
344
353
|
// TWA is always positive for polar lookups; sign is tracked via `port`
|
|
345
|
-
const TWA = Math.abs(TWAsigned)
|
|
354
|
+
const TWA = windValid ? Math.abs(TWAsigned) : null
|
|
346
355
|
const port = TWAsigned < 0 ? -1 : 1
|
|
347
|
-
const BSP = bspSmoother ? bspSmoother.value : null
|
|
348
|
-
const HDG = hdgSmoother ? hdgSmoother.value : null
|
|
356
|
+
const BSP = finiteOrNull(bspSmoother ? bspSmoother.value : null)
|
|
357
|
+
const HDG = finiteOrNull(hdgSmoother ? hdgSmoother.value : null)
|
|
349
358
|
|
|
350
359
|
const values = []
|
|
351
360
|
const metas = []
|
|
352
361
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (settings.smoothedInputs) {
|
|
364
|
-
add('environment.wind.angleTrueWaterDamped', TWAsigned, 'rad',
|
|
365
|
-
'True Wind Angle after smoothing, negative to port.')
|
|
366
|
-
if (Number.isFinite(BSP)) {
|
|
367
|
-
add('performance.boatSpeedDamped', BSP, 'm/s', 'Boat speed after smoothing.')
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// Polar lookups
|
|
372
|
-
const isUpwind = TWA < Math.PI / 2
|
|
373
|
-
const { value: targets } = polar.targetsAt({ tws: TWS, performanceFactor })
|
|
374
|
-
const beatAngle = targets?.beat?.twa ?? null
|
|
375
|
-
const runAngle = targets?.run?.twa ?? null
|
|
376
|
-
const beatVMG = targets?.beat?.vmg ?? null
|
|
377
|
-
const runVMG = targets?.run?.vmg ?? null
|
|
378
|
-
const targetAngle = isUpwind ? beatAngle : runAngle
|
|
379
|
-
const targetVMG = isUpwind ? beatVMG : runVMG
|
|
380
|
-
|
|
381
|
-
if (Number.isFinite(beatAngle)) {
|
|
382
|
-
if (settings.beatAngle) {
|
|
383
|
-
add('performance.beatAngle', beatAngle * port, 'rad',
|
|
384
|
-
'Optimal beat angle for current TWS, negative to port.')
|
|
385
|
-
}
|
|
386
|
-
if (settings.targetTWA && isUpwind) {
|
|
387
|
-
add('performance.targetAngle', beatAngle * port, 'rad',
|
|
388
|
-
'Target TWA — auto-switches between beat and run, negative to port.')
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
if (Number.isFinite(runAngle)) {
|
|
393
|
-
if (settings.beatAngle) {
|
|
394
|
-
add('performance.gybeAngle', runAngle * port, 'rad',
|
|
395
|
-
'Optimal run/gybe angle for current TWS, negative to port.')
|
|
396
|
-
}
|
|
397
|
-
if (settings.targetTWA && !isUpwind) {
|
|
398
|
-
add('performance.targetAngle', runAngle * port, 'rad',
|
|
399
|
-
'Target TWA — auto-switches between beat and run, negative to port.')
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (Number.isFinite(beatVMG)) {
|
|
404
|
-
if (settings.beatVMG) {
|
|
405
|
-
add('performance.beatAngleVelocityMadeGood', beatVMG, 'm/s',
|
|
406
|
-
'Optimal beat VMG for current TWS.')
|
|
407
|
-
}
|
|
408
|
-
if (settings.targetTWA && isUpwind) {
|
|
409
|
-
add('performance.targetVelocityMadeGood', beatVMG, 'm/s',
|
|
410
|
-
'Target VMG — auto-switches between beat and run.')
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (Number.isFinite(runVMG)) {
|
|
415
|
-
if (settings.beatVMG) {
|
|
416
|
-
add('performance.gybeAngleVelocityMadeGood', runVMG, 'm/s',
|
|
417
|
-
'Optimal run VMG for current TWS.')
|
|
418
|
-
}
|
|
419
|
-
if (settings.targetTWA && !isUpwind) {
|
|
420
|
-
add('performance.targetVelocityMadeGood', runVMG, 'm/s',
|
|
421
|
-
'Target VMG — auto-switches between beat and run.')
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Optimum wind angle: angular difference between current TWA and optimal angle
|
|
426
|
-
if (settings.optimumWindAngle) {
|
|
427
|
-
if (isUpwind && Number.isFinite(beatAngle)) {
|
|
428
|
-
add('performance.optimumWindAngle', (TWA - beatAngle) * port, 'rad',
|
|
429
|
-
'Difference between TWA and beat angle, negative to port.')
|
|
430
|
-
} else if (!isUpwind && Number.isFinite(runAngle)) {
|
|
431
|
-
add('performance.optimumWindAngle', (runAngle - TWA) * port * -1, 'rad',
|
|
432
|
-
'Difference between TWA and run angle, negative to port.')
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Polar speed and performance ratios
|
|
437
|
-
const { value: polarSpeed } = polar.speedAt({ tws: TWS, twa: TWA, performanceFactor })
|
|
438
|
-
if (Number.isFinite(polarSpeed) && polarSpeed > 0) {
|
|
439
|
-
if (settings.polarSpeed) {
|
|
440
|
-
add('performance.polarSpeed', polarSpeed, 'm/s',
|
|
441
|
-
'Polar chart boat speed for current TWS and TWA.')
|
|
442
|
-
|
|
443
|
-
// Target speed: only meaningful when sailing within the polar range
|
|
444
|
-
if (Number.isFinite(targetAngle) && Number.isFinite(targetVMG)) {
|
|
445
|
-
const cosTarget = Math.abs(Math.cos(targetAngle))
|
|
446
|
-
if (cosTarget > 0.01) {
|
|
447
|
-
add('performance.targetSpeed', targetVMG / cosTarget, 'm/s',
|
|
448
|
-
'Boat speed needed to achieve target VMG at the optimal angle.')
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
if (Number.isFinite(BSP)) {
|
|
453
|
-
add('performance.polarSpeedRatio', BSP / polarSpeed, 'ratio',
|
|
454
|
-
'Actual boat speed divided by polar speed.')
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
if (Number.isFinite(BSP)) {
|
|
459
|
-
if (settings.VMG && Number.isFinite(targetVMG) && targetVMG > 0) {
|
|
460
|
-
const vmg = BSP * Math.cos(TWA)
|
|
461
|
-
add('performance.velocityMadeGood', vmg, 'm/s',
|
|
462
|
-
'Actual VMG based on current boat speed and TWA.')
|
|
463
|
-
add('performance.polarVelocityMadeGood', targetVMG, 'm/s',
|
|
464
|
-
'Polar VMG for current TWS.')
|
|
465
|
-
if (Number.isFinite(vmg)) {
|
|
466
|
-
add('performance.polarVelocityMadeGoodRatio', Math.abs(vmg) / targetVMG, 'ratio',
|
|
467
|
-
'Actual VMG divided by polar VMG.')
|
|
468
|
-
}
|
|
362
|
+
// Each enabled output is either published or explicitly cleared, so
|
|
363
|
+
// consumers never keep seeing a frozen value once its inputs disappear.
|
|
364
|
+
// Nulls are only sent on the transition to unavailable to limit bus traffic.
|
|
365
|
+
function output(settingKey, skPath, value, unit, description) {
|
|
366
|
+
if (!settings[settingKey]) return
|
|
367
|
+
if (Number.isFinite(value)) {
|
|
368
|
+
values.push({ path: skPath, value })
|
|
369
|
+
if (!metaSentPaths.has(skPath)) {
|
|
370
|
+
metas.push({ path: skPath, value: { units: unit, description } })
|
|
371
|
+
metaSentPaths.add(skPath)
|
|
469
372
|
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Clear these paths so no stale non-zero value remains on the SK bus
|
|
473
|
-
if (settings.polarSpeed) {
|
|
474
|
-
values.push({ path: 'performance.polarSpeed', value: null })
|
|
475
|
-
values.push({ path: 'performance.polarSpeedRatio', value: null })
|
|
476
|
-
values.push({ path: 'performance.targetSpeed', value: null })
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// Max speed for current TWS
|
|
481
|
-
if (settings.maxSpeed) {
|
|
482
|
-
const maxSpeed = targets?.maxSpeed?.speed ?? null
|
|
483
|
-
const maxSpeedAngle = targets?.maxSpeed?.twa ?? null
|
|
484
|
-
if (Number.isFinite(maxSpeed)) {
|
|
485
|
-
add('performance.maxSpeed', maxSpeed, 'm/s',
|
|
486
|
-
'Maximum polar boat speed for current TWS.')
|
|
487
|
-
add('performance.maxSpeedAngle', maxSpeedAngle * port, 'rad',
|
|
488
|
-
'TWA at which maximum speed is achieved, negative to port.')
|
|
373
|
+
} else if (lastOutputs[skPath] !== null) {
|
|
374
|
+
values.push({ path: skPath, value: null })
|
|
489
375
|
}
|
|
490
376
|
}
|
|
491
377
|
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
378
|
+
// Output classes:
|
|
379
|
+
// - measured values (actual VMG, damped inputs) are published whenever their inputs are valid;
|
|
380
|
+
// - steering advice (target angle, optimum wind angle, tack heading) is always published,
|
|
381
|
+
// including in irons, where it says how far to bear away;
|
|
382
|
+
// - all other polar values and polar ratios are cleared in irons, where the polar says nothing
|
|
383
|
+
// about the boat's current state.
|
|
384
|
+
const isUpwind = windValid && TWA < Math.PI / 2
|
|
385
|
+
const speedResult = windValid ? polarHandle.speedAt({ tws: TWS, twa: TWA }) : null
|
|
386
|
+
const inIrons = speedResult?.state?.twa === 'in_irons'
|
|
387
|
+
const fromPolar = value => (inIrons ? null : finiteOrNull(value))
|
|
388
|
+
|
|
389
|
+
const targets = windValid ? polarHandle.targetsAt({ tws: TWS }).value : null
|
|
390
|
+
const adviceBeatAngle = finiteOrNull(targets?.beat?.twa)
|
|
391
|
+
const adviceRunAngle = finiteOrNull(targets?.run?.twa)
|
|
392
|
+
const adviceAngle = isUpwind ? adviceBeatAngle : adviceRunAngle
|
|
393
|
+
|
|
394
|
+
const beatAngle = fromPolar(adviceBeatAngle)
|
|
395
|
+
const runAngle = fromPolar(adviceRunAngle)
|
|
396
|
+
const beatVMG = fromPolar(targets?.beat?.vmg)
|
|
397
|
+
const runVMG = fromPolar(targets?.run?.vmg)
|
|
398
|
+
const targetVMG = finiteOrNull(isUpwind ? targets?.beat?.vmg : targets?.run?.vmg)
|
|
399
|
+
const polarTargetVMG = fromPolar(targetVMG)
|
|
400
|
+
const maxSpeed = fromPolar(targets?.maxSpeed?.speed)
|
|
401
|
+
const maxSpeedAngle = maxSpeed === null ? null : finiteOrNull(targets?.maxSpeed?.twa)
|
|
402
|
+
|
|
403
|
+
// Polar speed at the current TWA is also unavailable outside the polar's angle range
|
|
404
|
+
const speedAtTwa = speedResult?.value
|
|
405
|
+
const polarSpeed = Number.isFinite(speedAtTwa) && speedAtTwa > 0 ? speedAtTwa : null
|
|
406
|
+
|
|
407
|
+
const targetCos = adviceAngle === null ? 0 : Math.abs(Math.cos(adviceAngle))
|
|
408
|
+
const targetSpeed = targetVMG !== null && targetVMG >= 0 && targetCos > 0.01
|
|
409
|
+
? targetVMG / targetCos
|
|
410
|
+
: null
|
|
411
|
+
|
|
412
|
+
const vmg = windValid && BSP !== null ? BSP * Math.cos(TWA) : null
|
|
413
|
+
const vmgRatio = ratio(vmg === null ? null : Math.abs(vmg), polarTargetVMG)
|
|
414
|
+
|
|
415
|
+
let optimumWindAngle = null
|
|
416
|
+
if (isUpwind && adviceBeatAngle !== null) optimumWindAngle = (TWA - adviceBeatAngle) * port
|
|
417
|
+
else if (windValid && !isUpwind && adviceRunAngle !== null) optimumWindAngle = (adviceRunAngle - TWA) * port * -1
|
|
418
|
+
|
|
419
|
+
let tack = null
|
|
420
|
+
if (HDG !== null && adviceAngle !== null) {
|
|
421
|
+
tack = port < 0 ? HDG - adviceAngle : HDG + adviceAngle
|
|
495
422
|
tack = ((tack % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
|
|
496
|
-
add('performance.tackTrue', tack, 'rad',
|
|
497
|
-
'Opposite tack heading relative to true north.')
|
|
498
423
|
}
|
|
499
424
|
|
|
425
|
+
output('smoothedInputs', 'environment.wind.angleTrueWaterDamped', windValid ? TWAsigned : null, 'rad',
|
|
426
|
+
'True Wind Angle after smoothing, negative to port.')
|
|
427
|
+
output('smoothedInputs', 'performance.boatSpeedDamped', BSP, 'm/s',
|
|
428
|
+
'Boat speed after smoothing.')
|
|
429
|
+
|
|
430
|
+
output('beatAngle', 'performance.beatAngle', signed(beatAngle, port), 'rad',
|
|
431
|
+
'Optimal beat angle for current TWS, negative to port.')
|
|
432
|
+
output('beatAngle', 'performance.gybeAngle', signed(runAngle, port), 'rad',
|
|
433
|
+
'Optimal run/gybe angle for current TWS, negative to port.')
|
|
434
|
+
|
|
435
|
+
output('beatVMG', 'performance.beatAngleVelocityMadeGood', beatVMG, 'm/s',
|
|
436
|
+
'Optimal beat VMG for current TWS.')
|
|
437
|
+
output('beatVMG', 'performance.gybeAngleVelocityMadeGood', runVMG, 'm/s',
|
|
438
|
+
'Optimal run VMG for current TWS.')
|
|
439
|
+
|
|
440
|
+
output('targetTWA', 'performance.targetAngle', signed(adviceAngle, port), 'rad',
|
|
441
|
+
'Target TWA — auto-switches between beat and run, negative to port.')
|
|
442
|
+
output('targetTWA', 'performance.targetVelocityMadeGood', targetVMG, 'm/s',
|
|
443
|
+
'Target VMG — auto-switches between beat and run.')
|
|
444
|
+
|
|
445
|
+
output('optimumWindAngle', 'performance.optimumWindAngle', optimumWindAngle, 'rad',
|
|
446
|
+
isUpwind
|
|
447
|
+
? 'Difference between TWA and beat angle, negative to port. The optimal angle comes from the polar for the current true wind speed; zero means sailing at the optimal angle.'
|
|
448
|
+
: 'Difference between TWA and run angle, negative to port. The optimal angle comes from the polar for the current true wind speed; zero means sailing at the optimal angle.')
|
|
449
|
+
|
|
450
|
+
output('polarSpeed', 'performance.polarSpeed', polarSpeed, 'm/s',
|
|
451
|
+
'Polar chart boat speed for current TWS and TWA.')
|
|
452
|
+
output('polarSpeed', 'performance.targetSpeed', targetSpeed, 'm/s',
|
|
453
|
+
'Boat speed needed to achieve target VMG at the optimal angle.')
|
|
454
|
+
output('polarSpeed', 'performance.polarSpeedRatio', ratio(BSP, polarSpeed), 'ratio',
|
|
455
|
+
'Actual boat speed divided by polar speed.')
|
|
456
|
+
|
|
457
|
+
output('VMG', 'performance.velocityMadeGood', vmg, 'm/s',
|
|
458
|
+
'Actual VMG based on current boat speed and TWA.')
|
|
459
|
+
output('VMG', 'performance.polarVelocityMadeGood', polarTargetVMG, 'm/s',
|
|
460
|
+
"Polar VMG for current TWS. The VMG predicted by the polar, not the vessel's actual VMG.")
|
|
461
|
+
output('VMG', 'performance.polarVelocityMadeGoodRatio', vmgRatio, 'ratio',
|
|
462
|
+
'Actual VMG divided by polar VMG.')
|
|
463
|
+
|
|
464
|
+
output('maxSpeed', 'performance.maxSpeed', maxSpeed, 'm/s',
|
|
465
|
+
'Maximum polar boat speed for current TWS.')
|
|
466
|
+
output('maxSpeed', 'performance.maxSpeedAngle', signed(maxSpeedAngle, port), 'rad',
|
|
467
|
+
'TWA at which maximum speed is achieved, negative to port.')
|
|
468
|
+
|
|
469
|
+
output('tackTrue', 'performance.tackTrue', tack, 'rad',
|
|
470
|
+
'Opposite tack heading relative to true north.')
|
|
471
|
+
|
|
500
472
|
if (values.length === 0) return
|
|
501
473
|
|
|
502
474
|
if (metas.length > 0) {
|
|
@@ -505,11 +477,8 @@ module.exports = (app) => {
|
|
|
505
477
|
|
|
506
478
|
app.handleMessage(plugin.id, { updates: [{ values }] })
|
|
507
479
|
|
|
508
|
-
// Snapshot
|
|
509
|
-
|
|
510
|
-
const outputSnapshot = {}
|
|
511
|
-
values.forEach(({ path, value }) => { outputSnapshot[path] = value })
|
|
512
|
-
Object.assign(lastOutputs, outputSnapshot)
|
|
480
|
+
// Snapshot outputs for the /status endpoint and null-transition tracking
|
|
481
|
+
values.forEach(({ path, value }) => { lastOutputs[path] = value })
|
|
513
482
|
}
|
|
514
483
|
|
|
515
484
|
// ---------------------------------------------------------------------------
|
|
@@ -543,16 +512,18 @@ module.exports = (app) => {
|
|
|
543
512
|
return Number.isFinite(value) ? parseFloat(value.toFixed(digits)) : null
|
|
544
513
|
}
|
|
545
514
|
|
|
515
|
+
// TWS axis for the webapp plotter — from the handle's description, which
|
|
516
|
+
// already reflects the active selection (v1 table, or v2 best/sailplan).
|
|
546
517
|
function getPolarTwsValues() {
|
|
547
|
-
return
|
|
548
|
-
.filter(
|
|
549
|
-
.map(
|
|
518
|
+
return polarDescription.twsAxis
|
|
519
|
+
.filter(tws => Number.isFinite(tws) && tws > 0.001)
|
|
520
|
+
.map(tws => parseFloat(tws.toFixed(4)))
|
|
550
521
|
}
|
|
551
522
|
|
|
552
523
|
function buildCurveResult(tws, stepRad) {
|
|
553
524
|
const points = []
|
|
554
525
|
for (let twa = 0; twa <= Math.PI + 1e-9; twa += stepRad) {
|
|
555
|
-
const { value: tbs } =
|
|
526
|
+
const { value: tbs } = polarHandle.speedAt({ tws, twa })
|
|
556
527
|
if (Number.isFinite(tbs) && tbs > 0) {
|
|
557
528
|
points.push({
|
|
558
529
|
twa: toFixedNumber(twa, 5),
|
|
@@ -561,7 +532,7 @@ module.exports = (app) => {
|
|
|
561
532
|
}
|
|
562
533
|
}
|
|
563
534
|
|
|
564
|
-
const { value: targets } =
|
|
535
|
+
const { value: targets } = polarHandle.targetsAt({ tws })
|
|
565
536
|
return {
|
|
566
537
|
tws,
|
|
567
538
|
points,
|
|
@@ -585,21 +556,21 @@ module.exports = (app) => {
|
|
|
585
556
|
|
|
586
557
|
// ---- Active polar curve queries (read-only — for the webapp's polar canvas) --
|
|
587
558
|
|
|
588
|
-
router.get('/polar/axes/tws', (req, res) => {
|
|
589
|
-
if (!
|
|
559
|
+
router.access('readonly').get('/polar/axes/tws', (req, res) => {
|
|
560
|
+
if (!polarDescription?.available) return res.status(404).json({ error: 'No active polar selected' })
|
|
590
561
|
res.json(getPolarTwsValues())
|
|
591
562
|
})
|
|
592
563
|
|
|
593
|
-
router.get('/polar/queries/curve', (req, res) => {
|
|
594
|
-
if (!
|
|
564
|
+
router.access('readonly').get('/polar/queries/curve', (req, res) => {
|
|
565
|
+
if (!polarHandle || !polarDescription?.available) return res.status(404).json({ error: 'No active polar selected' })
|
|
595
566
|
try {
|
|
596
567
|
const tws = parseFloat(req.query.tws)
|
|
597
568
|
if (!Number.isFinite(tws) || tws < 0) {
|
|
598
569
|
return res.status(400).json({ error: "'tws' query parameter required (m/s)" })
|
|
599
570
|
}
|
|
600
571
|
const stepRad = parseFloat(req.query.step) || (2 * Math.PI / 180)
|
|
601
|
-
if (!Number.isFinite(stepRad) || stepRad
|
|
602
|
-
return res.status(400).json({ error: "'step' must be between
|
|
572
|
+
if (!Number.isFinite(stepRad) || stepRad < Math.PI / 180 || stepRad > Math.PI / 2) {
|
|
573
|
+
return res.status(400).json({ error: "'step' must be between 1° and π/2 radians" })
|
|
603
574
|
}
|
|
604
575
|
res.json(buildCurveResult(tws, stepRad))
|
|
605
576
|
} catch (e) {
|
|
@@ -611,15 +582,15 @@ module.exports = (app) => {
|
|
|
611
582
|
// tws/bsp/polarSpeed in m/s; twa in rad (positive = starboard, negative = port).
|
|
612
583
|
// Returns null for any field not yet available (plugin not running,
|
|
613
584
|
// no BSP source, polar not loaded, or boat in irons).
|
|
614
|
-
router.get('/live', (req, res) => {
|
|
585
|
+
router.access('readonly').get('/live', (req, res) => {
|
|
615
586
|
const wind = windSmoother?.ready ? windSmoother.polarValue : null
|
|
616
587
|
const TWS = wind ? wind.magnitude : null
|
|
617
588
|
const TWAsigned = wind ? wind.angle : null
|
|
618
589
|
const TWA = Number.isFinite(TWAsigned) ? Math.abs(TWAsigned) : null
|
|
619
590
|
const BSP = bspSmoother ? bspSmoother.value : null
|
|
620
591
|
|
|
621
|
-
const polarResult = (
|
|
622
|
-
?
|
|
592
|
+
const polarResult = (polarHandle && Number.isFinite(TWS) && Number.isFinite(TWA))
|
|
593
|
+
? polarHandle.speedAt({ tws: TWS, twa: TWA })
|
|
623
594
|
: null
|
|
624
595
|
const polarSpeed = polarResult ? polarResult.value : null
|
|
625
596
|
|
|
@@ -646,7 +617,7 @@ module.exports = (app) => {
|
|
|
646
617
|
// inputs.smoothed.* — value the plugin actually used for computation
|
|
647
618
|
// outputs.* — only present for enabled settings; null if polar not ready
|
|
648
619
|
// polarState — same as /live
|
|
649
|
-
router.get('/status', (req, res) => {
|
|
620
|
+
router.access('readonly').get('/status', (req, res) => {
|
|
650
621
|
const si = v => (Number.isFinite(v) ? parseFloat(v.toFixed(5)) : null)
|
|
651
622
|
|
|
652
623
|
// Raw inputs: read directly from the smoother handlers
|
|
@@ -663,8 +634,8 @@ module.exports = (app) => {
|
|
|
663
634
|
|
|
664
635
|
const bspPath = settings.useSOG ? 'navigation.speedOverGround' : 'navigation.speedThroughWater'
|
|
665
636
|
|
|
666
|
-
const polarState = (
|
|
667
|
-
?
|
|
637
|
+
const polarState = (polarHandle && Number.isFinite(TWS) && Number.isFinite(TWAsigned))
|
|
638
|
+
? polarHandle.speedAt({ tws: TWS, twa: Math.abs(TWAsigned) }).state
|
|
668
639
|
: null
|
|
669
640
|
|
|
670
641
|
// Build outputs object: only include paths that are enabled and were
|
|
@@ -707,9 +678,11 @@ module.exports = (app) => {
|
|
|
707
678
|
|
|
708
679
|
// Metadata describing the units and display preferences for each field
|
|
709
680
|
// returned by /live and the canonical curve query endpoints, plus a
|
|
710
|
-
// read-only summary of the active polar and performance factor
|
|
711
|
-
//
|
|
712
|
-
|
|
681
|
+
// read-only summary of the active polar and performance factor, taken
|
|
682
|
+
// from the management handle's describe() snapshot. The factor is
|
|
683
|
+
// informational: the handle applies it in every calculation, so this
|
|
684
|
+
// plugin never multiplies by it.
|
|
685
|
+
router.access('readonly').get('/meta', (req, res) => {
|
|
713
686
|
const speed = { formula: 'value * 1.943844', symbol: 'kn', displayFormat: '0.0' }
|
|
714
687
|
const angle = { formula: 'value * 57.29577951308231', symbol: '\u00b0', displayFormat: '0.0' }
|
|
715
688
|
const ratio = { formula: 'value * 100', symbol: '%', displayFormat: '0.1' }
|
|
@@ -723,16 +696,17 @@ module.exports = (app) => {
|
|
|
723
696
|
'curve.tbs': { units: 'm/s', displayUnits: speed },
|
|
724
697
|
'curve.vmg': { units: 'm/s', displayUnits: speed },
|
|
725
698
|
'curve.twa': { units: 'rad', displayUnits: angle },
|
|
726
|
-
activePolar:
|
|
727
|
-
id:
|
|
728
|
-
name:
|
|
729
|
-
boatType:
|
|
730
|
-
sailnumber:
|
|
731
|
-
year:
|
|
732
|
-
source:
|
|
733
|
-
notes:
|
|
699
|
+
activePolar: polarDescription?.available ? {
|
|
700
|
+
id: polarDescription.id,
|
|
701
|
+
name: polarDescription.name || polarDescription.id,
|
|
702
|
+
boatType: polarDescription.boatType,
|
|
703
|
+
sailnumber: polarDescription.sailnumber,
|
|
704
|
+
year: polarDescription.year,
|
|
705
|
+
source: polarDescription.source,
|
|
706
|
+
notes: polarDescription.notes,
|
|
707
|
+
sailplanId: polarDescription.sailplanId
|
|
734
708
|
} : null,
|
|
735
|
-
performanceFactor
|
|
709
|
+
performanceFactor: polarDescription?.available ? polarDescription.performanceFactor : null
|
|
736
710
|
})
|
|
737
711
|
})
|
|
738
712
|
|
|
@@ -764,37 +738,21 @@ module.exports = (app) => {
|
|
|
764
738
|
metaSentPaths = new Set() // reset so metadata is re-emitted after restart
|
|
765
739
|
lifecycleWarningMap = new Map()
|
|
766
740
|
lifecycleWarnings = []
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
activePolarDoc = null
|
|
770
|
-
performanceFactor = 1
|
|
741
|
+
polarHandle = null
|
|
742
|
+
polarDescription = null
|
|
771
743
|
|
|
772
744
|
settings = migrateSettings({ ...DEFAULT_SETTINGS, ...options, settingsVersion: options.settingsVersion ?? 0 })
|
|
773
745
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
// unsubscribe/resubscribe used for sensor handlers below does not apply here: absence
|
|
784
|
-
// of deltas is the normal state, not a sign of a broken subscription, and periodically
|
|
785
|
-
// forcing a resubscribe would just be churn (and possible side effects) for no reason.
|
|
786
|
-
activePolarHandler = new MessageHandler(app, plugin.id, 'activePolar')
|
|
787
|
-
activePolarHandler.path = 'polars.activePolar'
|
|
788
|
-
activePolarHandler.onDelta = () => handleActivePolarDelta()
|
|
789
|
-
activePolarHandler.subscribe()
|
|
790
|
-
|
|
791
|
-
performanceFactorHandler = new MessageHandler(app, plugin.id, 'performanceFactor')
|
|
792
|
-
performanceFactorHandler.path = 'polars.performanceFactor'
|
|
793
|
-
performanceFactorHandler.onDelta = () => {
|
|
794
|
-
const value = performanceFactorHandler.value
|
|
795
|
-
performanceFactor = Number.isFinite(value) ? value : 1
|
|
746
|
+
// Discover the in-process polar calculation service. The callback fires
|
|
747
|
+
// immediately with the retained PropertyValues history, so startup order
|
|
748
|
+
// relative to signalk-polar-management does not matter. The handle also
|
|
749
|
+
// provides the active selection's metadata through describe(), so no
|
|
750
|
+
// `polars.*` subscriptions or resource lookups are needed.
|
|
751
|
+
if (typeof app.onPropertyValues === 'function') {
|
|
752
|
+
unsubscribeDiscovery = app.onPropertyValues('polarManagement', handleServiceDiscovery)
|
|
753
|
+
} else {
|
|
754
|
+
app.setPluginError('Signal K server does not support PropertyValues — polar calculations unavailable')
|
|
796
755
|
}
|
|
797
|
-
performanceFactorHandler.subscribe()
|
|
798
756
|
|
|
799
757
|
const SmootherClass = getSmootherClass(settings.smootherType)
|
|
800
758
|
const smootherOptions = getSmootherOptions(settings.smootherType, settings)
|
|
@@ -813,8 +771,6 @@ module.exports = (app) => {
|
|
|
813
771
|
..._wireHandlerWatchdog({
|
|
814
772
|
id: 'wind.smoothed',
|
|
815
773
|
getPath: () => `${windSmoother?.polar?.pathMagnitude ?? 'environment.wind.speedTrue'}, ${windSmoother?.polar?.pathAngle ?? 'environment.wind.angleTrueWater'}`,
|
|
816
|
-
unsubscribe: () => windSmoother?.unsubscribe(),
|
|
817
|
-
subscribe: () => windSmoother?.subscribe(true, true),
|
|
818
774
|
}),
|
|
819
775
|
onDelta: () => {
|
|
820
776
|
_clearLifecycleWarning('wind.smoothed')
|
|
@@ -836,8 +792,6 @@ module.exports = (app) => {
|
|
|
836
792
|
..._wireHandlerWatchdog({
|
|
837
793
|
id: 'bsp.smoothed',
|
|
838
794
|
getPath: () => bspSmoother?.handler?.path ?? (settings.useSOG ? 'navigation.speedOverGround' : 'navigation.speedThroughWater'),
|
|
839
|
-
unsubscribe: () => bspSmoother?.unsubscribe(),
|
|
840
|
-
subscribe: () => bspSmoother?.subscribe(),
|
|
841
795
|
})
|
|
842
796
|
})
|
|
843
797
|
|
|
@@ -852,8 +806,6 @@ module.exports = (app) => {
|
|
|
852
806
|
..._wireHandlerWatchdog({
|
|
853
807
|
id: 'hdg.smoothed',
|
|
854
808
|
getPath: () => hdgSmoother?.handler?.path ?? 'navigation.headingTrue',
|
|
855
|
-
unsubscribe: () => hdgSmoother?.unsubscribe(),
|
|
856
|
-
subscribe: () => hdgSmoother?.subscribe(false, true),
|
|
857
809
|
})
|
|
858
810
|
})
|
|
859
811
|
}
|
|
@@ -869,11 +821,10 @@ module.exports = (app) => {
|
|
|
869
821
|
if (windSmoother) { windSmoother.terminate(); windSmoother = null }
|
|
870
822
|
if (bspSmoother) { bspSmoother.terminate(); bspSmoother = null }
|
|
871
823
|
if (hdgSmoother) { hdgSmoother.terminate(); hdgSmoother = null }
|
|
872
|
-
if (
|
|
873
|
-
if (
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
activePolarDoc = null
|
|
824
|
+
if (unsubscribeDiscovery) { unsubscribeDiscovery(); unsubscribeDiscovery = null }
|
|
825
|
+
if (unsubscribeChanges) { unsubscribeChanges(); unsubscribeChanges = null }
|
|
826
|
+
polarHandle = null
|
|
827
|
+
polarDescription = null
|
|
877
828
|
lifecycleWarningMap = new Map()
|
|
878
829
|
lifecycleWarnings = []
|
|
879
830
|
app.debug('Plugin stopped')
|