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/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 — sourced from the `polars.activePolar` / `polars.performanceFactor`
47
- // SK paths (published by a 'polars' resource-provider plugin, e.g. signalk-polar-management).
48
- // This plugin no longer stores or manages polar files itself.
49
- let polar = null // polar-math Polar instance, or null when unavailable
50
- let activePolarId = null
51
- let activePolarDoc = null // raw canonical resource, for descriptive metadata
52
- let performanceFactor = 1
53
- let activePolarHandler = null
54
- let performanceFactorHandler = null
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}; resubscribing`
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, unsubscribe, subscribe }) {
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}; resubscribing`)
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 the
171
- // `polars.activePolar` / `polars.performanceFactor` SK paths, published
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 — sourced from SK paths published by a 'polars' resource provider
212
+ // Active polar — calculations and descriptive metadata via the polar
213
+ // management in-process service
196
214
  // ---------------------------------------------------------------------------
197
215
 
198
- // Extracts the resource id from an `href` of the shape `/resources/polars/<id>`.
199
- function hrefToId(href) {
200
- if (typeof href !== 'string') return null
201
- const match = href.match(/\/resources\/polars\/([^/]+)$/)
202
- return match ? match[1] : null
203
- }
204
-
205
- async function checkPolarProvider() {
206
- if (!app.resourcesApi || typeof app.resourcesApi.listResources !== 'function') return false
207
- try {
208
- await app.resourcesApi.listResources('polars', {})
209
- return true
210
- } catch (_e) {
211
- return false
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
- function handleActivePolarDelta() {
216
- const value = activePolarHandler.value
217
- const href = value?.href
218
- if (!href) {
219
- polar = null
220
- activePolarId = null
221
- activePolarDoc = null
222
- nullifyOutputs()
223
- app.setPluginStatus('No active polar selected — select one in the polar management webapp')
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
- const id = hrefToId(href)
228
- if (!id) {
229
- app.setPluginError(`Cannot parse active polar href: ${href}`)
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
- app.resourcesApi.getResource('polars', id).then((doc) => {
234
- try {
235
- polar = Polar.fromTable(doc)
236
- activePolarId = id
237
- activePolarDoc = doc
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
- get path() { return hdgSmoother?.handler?.path ?? 'navigation.headingTrue' },
294
- unsubscribe: () => hdgSmoother?.unsubscribe(),
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
- if (disabledPaths.length) {
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
- const allPaths = Object.values(OUTPUT_PATHS).flat()
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 (!polar) return
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
- if (!Number.isFinite(TWS) || !Number.isFinite(TWAsigned)) return
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
- function add(skPath, value, unit, description) {
354
- if (!Number.isFinite(value)) return
355
- values.push({ path: skPath, value })
356
- if (!metaSentPaths.has(skPath)) {
357
- metas.push({ path: skPath, value: { units: unit, description } })
358
- metaSentPaths.add(skPath)
359
- }
360
- }
361
-
362
- // Always emit smoothed inputs
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
- } else {
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
- // Opposite tack heading
493
- if (settings.tackTrue && Number.isFinite(HDG) && Number.isFinite(targetAngle)) {
494
- let tack = port < 0 ? HDG - targetAngle : HDG + targetAngle
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 enabled outputs for /status endpoint
509
- // Each output key maps to an array of { path, value } computed this cycle
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 polar.entries
548
- .filter(entry => entry.tws > 0.001)
549
- .map(entry => parseFloat(entry.tws.toFixed(4)))
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 } = polar.speedAt({ tws, twa, performanceFactor })
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 } = polar.targetsAt({ tws, performanceFactor })
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 (!polar) return res.status(404).json({ error: 'No active polar selected' })
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 (!polar) return res.status(404).json({ error: 'No active polar selected' })
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 <= 0 || stepRad > Math.PI / 2) {
602
- return res.status(400).json({ error: "'step' must be between 0 and π/2 radians" })
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 = (polar && Number.isFinite(TWS) && Number.isFinite(TWA))
622
- ? polar.speedAt({ tws: TWS, twa: TWA, performanceFactor })
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 = (polar && Number.isFinite(TWS) && Number.isFinite(TWAsigned))
667
- ? polar.speedAt({ tws: TWS, twa: Math.abs(TWAsigned), performanceFactor }).state
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
- // (both sourced from the `polars.*` SK paths — not editable here).
712
- router.get('/meta', (req, res) => {
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: activePolarDoc ? {
727
- id: activePolarId,
728
- name: activePolarDoc.name || activePolarId,
729
- boatType: activePolarDoc.boatType ?? null,
730
- sailnumber: activePolarDoc.sailnumber ?? null,
731
- year: activePolarDoc.year ?? null,
732
- source: activePolarDoc.source ?? null,
733
- notes: activePolarDoc.notes ?? null
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
- polar = null
768
- activePolarId = null
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
- checkPolarProvider().then((available) => {
775
- app.debug(`checkPolarProvider: ${available ? 'polars resource provider found' : 'no polars resource provider'}`)
776
- if (!available) {
777
- app.setPluginStatus("No 'polars' resource provider registered — install signalk-polar-management (or another plugin providing the 'polars' resource type) to select an active polar.")
778
- }
779
- })
780
-
781
- // Active polar / performance factor — read-only, published by a 'polars' resource provider.
782
- // These are event-driven (change only on user action), so the idle-watchdog
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 (activePolarHandler) { activePolarHandler.unsubscribe(); activePolarHandler = null }
873
- if (performanceFactorHandler) { performanceFactorHandler.unsubscribe(); performanceFactorHandler = null }
874
- polar = null
875
- activePolarId = null
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')