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/plugin/index.js CHANGED
@@ -1,11 +1,8 @@
1
1
  'use strict'
2
2
 
3
- const { PolarTable } = require('./PolarTable')
4
- const PolarFileStore = require('./PolarFileStore')
5
- const { ImportService, ImportError, createTimestampedId } = require('./import/ImportService')
6
- const canonical = require('./import/canonical')
7
- const { parseMatrixPolarText } = require('./import/matrixText')
3
+ const { Polar } = require('polar-math')
8
4
  const {
5
+ MessageHandler,
9
6
  createSmoothedPolar,
10
7
  createSmoothedHandler,
11
8
  SmoothedAngle,
@@ -15,14 +12,10 @@ const {
15
12
  KalmanSmoother
16
13
  } = require('signalkutilities')
17
14
 
18
- const CURRENT_SETTINGS_VERSION = 1
19
-
20
- const STALE_RESUBSCRIBE_PERIOD = 60000 // ms — idle period before live input subscriptions are re-established
15
+ const CURRENT_SETTINGS_VERSION = 2
21
16
 
22
17
  const DEFAULT_SETTINGS = {
23
18
  settingsVersion: CURRENT_SETTINGS_VERSION,
24
- activePolar: '',
25
- perfAdjust: 1,
26
19
  showAllTwsLines: true,
27
20
  smootherType: 'Exponential',
28
21
  smootherParamExponential: 1,
@@ -47,9 +40,17 @@ module.exports = (app) => {
47
40
  let changedOptions = {} // staged but not yet applied
48
41
  let hasPendingChanges = false
49
42
  let isRunning = false
50
- let polarTable = null
51
- let store = null
52
- let importService = null
43
+
44
+ // Active polar — sourced from the `polars.activePolar` / `polars.performanceFactor`
45
+ // SK paths (published by a 'polars' resource-provider plugin, e.g. signalk-polar-management).
46
+ // This plugin no longer stores or manages polar files itself.
47
+ let polar = null // polar-math Polar instance, or null when unavailable
48
+ let activePolarId = null
49
+ let activePolarDoc = null // raw canonical resource, for descriptive metadata
50
+ let performanceFactor = 1
51
+ let activePolarHandler = null
52
+ let performanceFactorHandler = null
53
+
53
54
  let windSmoother = null
54
55
  let bspSmoother = null
55
56
  let hdgSmoother = null
@@ -82,7 +83,7 @@ module.exports = (app) => {
82
83
  function _setLifecycleWarning(id, status, path) {
83
84
  const safePath = path || 'unknown path'
84
85
  const message = status === 'idle'
85
- ? `Input ${id} is idle on ${safePath}; resubscribing`
86
+ ? `Input ${id} is idle on ${safePath}`
86
87
  : `Input ${id} is stale on ${safePath}`
87
88
  lifecycleWarningMap.set(id, {
88
89
  id,
@@ -102,9 +103,8 @@ module.exports = (app) => {
102
103
  .sort((a, b) => b.updatedAt - a.updatedAt)
103
104
  }
104
105
 
105
- function _wireHandlerWatchdog({ id, getPath, unsubscribe, subscribe }) {
106
+ function _wireHandlerWatchdog({ id, getPath }) {
106
107
  return {
107
- idlePeriod: STALE_RESUBSCRIBE_PERIOD,
108
108
  onDelta: () => {
109
109
  _clearLifecycleWarning(id)
110
110
  },
@@ -117,14 +117,36 @@ module.exports = (app) => {
117
117
  onIdle: () => {
118
118
  if (!isRunning) return
119
119
  const path = getPath()
120
- app.debug(`[${plugin.id}] idle input ${id} on ${path}; resubscribing`)
120
+ app.debug(`[${plugin.id}] idle input ${id} on ${path}`)
121
121
  _setLifecycleWarning(id, 'idle', path)
122
- unsubscribe()
123
- subscribe()
124
122
  }
125
123
  }
126
124
  }
127
125
 
126
+ // Ratios below this denominator (m/s) are numerically meaningless (near-zero wind).
127
+ const MIN_RATIO_DENOMINATOR = 0.01
128
+
129
+ function finiteOrNull(value) {
130
+ return Number.isFinite(value) ? value : null
131
+ }
132
+
133
+ function signed(angle, port) {
134
+ return angle === null ? null : angle * port
135
+ }
136
+
137
+ function ratio(numerator, denominator) {
138
+ if (!Number.isFinite(numerator) || !Number.isFinite(denominator)) return null
139
+ return denominator > MIN_RATIO_DENOMINATOR ? numerator / denominator : null
140
+ }
141
+
142
+ function publishNulls(paths) {
143
+ if (paths.length === 0) return
144
+ paths.forEach(path => { lastOutputs[path] = null })
145
+ app.handleMessage(plugin.id, {
146
+ updates: [{ values: paths.map(path => ({ path, value: null })) }]
147
+ })
148
+ }
149
+
128
150
  function getSmootherClass(type) {
129
151
  switch (type) {
130
152
  case 'None': return BaseSmoother
@@ -158,30 +180,21 @@ module.exports = (app) => {
158
180
  delete s.useTWSsource
159
181
  delete s.useSOGsource
160
182
  delete s.trueWindSpeedPath
161
-
162
- // Migrate embedded CSV polar to a canonical polar file.
163
- // Only advance settingsVersion if the CSV is successfully written —
164
- // if it fails, keep csvTable so the migration retries on next start.
165
- if (s.csvTable && s.csvTable.trim()) {
166
- try {
167
- const { resource } = parseMatrixPolarText(s.csvTable)
168
- store.saveCanonical('migrated-polar', resource)
169
- s.activePolar = 'migrated-polar'
170
- delete s.csvTable
171
- app.debug('Legacy csvTable migrated to migrated-polar.json')
172
- } catch (e) {
173
- app.setPluginError('Migration failed — could not save legacy CSV: ' + e.message)
174
- app.debug('CSV migration error: %s', e.message)
175
- return s // abort; csvTable kept so next start retries
176
- }
177
- } else {
178
- delete s.csvTable
179
- }
180
-
183
+ delete s.csvTable
181
184
  s.settingsVersion = 1
182
185
  app.debug('Settings migrated from v0 to v1')
183
186
  }
184
187
 
188
+ if (s.settingsVersion < 2) {
189
+ // v1 → v2: polar selection and performance adjustment moved to the
190
+ // `polars.activePolar` / `polars.performanceFactor` SK paths, published
191
+ // by a 'polars' resource-provider plugin (e.g. signalk-polar-management).
192
+ delete s.activePolar
193
+ delete s.perfAdjust
194
+ s.settingsVersion = 2
195
+ app.debug('Settings migrated from v1 to v2')
196
+ }
197
+
185
198
  // Persist if any migration ran, so migrations don't repeat on next start.
186
199
  // Signal K requires a callback; omitting it throws TypeError and aborts start().
187
200
  if ((s.settingsVersion ?? 0) > version) {
@@ -197,6 +210,62 @@ module.exports = (app) => {
197
210
  return s
198
211
  }
199
212
 
213
+ // ---------------------------------------------------------------------------
214
+ // Active polar — sourced from SK paths published by a 'polars' resource provider
215
+ // ---------------------------------------------------------------------------
216
+
217
+ // Extracts the resource id from an `href` of the shape `/resources/polars/<id>`.
218
+ function hrefToId(href) {
219
+ if (typeof href !== 'string') return null
220
+ const match = href.match(/\/resources\/polars\/([^/]+)$/)
221
+ return match ? match[1] : null
222
+ }
223
+
224
+ async function checkPolarProvider() {
225
+ if (!app.resourcesApi || typeof app.resourcesApi.listResources !== 'function') return false
226
+ try {
227
+ await app.resourcesApi.listResources('polars', {})
228
+ return true
229
+ } catch (_e) {
230
+ return false
231
+ }
232
+ }
233
+
234
+ function handleActivePolarDelta() {
235
+ const value = activePolarHandler.value
236
+ const href = value?.href
237
+ if (!href) {
238
+ polar = null
239
+ activePolarId = null
240
+ activePolarDoc = null
241
+ nullifyOutputs()
242
+ app.setPluginStatus('No active polar selected — select one in the polar management webapp')
243
+ return
244
+ }
245
+
246
+ const id = hrefToId(href)
247
+ if (!id) {
248
+ app.setPluginError(`Cannot parse active polar href: ${href}`)
249
+ return
250
+ }
251
+
252
+ app.resourcesApi.getResource('polars', id).then((doc) => {
253
+ try {
254
+ polar = Polar.fromTable(doc)
255
+ activePolarId = id
256
+ activePolarDoc = doc
257
+ app.setPluginStatus(`Polar '${doc.name || id}' loaded`)
258
+ } catch (e) {
259
+ // Keep the last valid polar (if any) rather than dropping outputs on a bad update.
260
+ app.setPluginError(`Invalid active polar '${id}': ${e.message}`)
261
+ if (!polar) nullifyOutputs()
262
+ }
263
+ }).catch((e) => {
264
+ app.setPluginError(`Cannot load active polar '${id}': ${e.message}`)
265
+ if (!polar) nullifyOutputs()
266
+ })
267
+ }
268
+
200
269
  // ---------------------------------------------------------------------------
201
270
  // Hot-apply runtime option changes
202
271
  // ---------------------------------------------------------------------------
@@ -211,25 +280,6 @@ module.exports = (app) => {
211
280
  changedOptions = {}
212
281
  hasPendingChanges = false
213
282
 
214
- // Active polar — load new table
215
- if ('activePolar' in settings || 'perfAdjust' in settings) {
216
- if (keys.includes('activePolar') && settings.activePolar) {
217
- try {
218
- polarTable = store.load(settings.activePolar)
219
- polarTable.setPerformanceAdjustment(settings.perfAdjust || 1)
220
- app.setPluginStatus(`Polar '${settings.activePolar}' loaded`)
221
- } catch (e) {
222
- app.setPluginError(`Cannot load polar '${settings.activePolar}': ${e.message}`)
223
- }
224
- } else if (keys.includes('activePolar') && !settings.activePolar) {
225
- polarTable = null
226
- nullifyOutputs()
227
- app.setPluginStatus('No polar configured — set activePolar or upload a CSV file')
228
- } else if (keys.includes('perfAdjust') && polarTable) {
229
- polarTable.setPerformanceAdjustment(settings.perfAdjust)
230
- }
231
- }
232
-
233
283
  // Smoother type or parameter changes — update all running smoothers in-place
234
284
  const SMOOTHER_KEYS = ['smootherType', 'smootherParamExponential', 'smootherParamMovingAverage', 'smootherParamKalman']
235
285
  if (keys.some(k => SMOOTHER_KEYS.includes(k))) {
@@ -259,9 +309,8 @@ module.exports = (app) => {
259
309
  SmootherClass: SC,
260
310
  smootherOptions: so,
261
311
  ..._wireHandlerWatchdog({
262
- get path() { return hdgSmoother?.handler?.path ?? 'navigation.headingTrue' },
263
- unsubscribe: () => hdgSmoother?.unsubscribe(),
264
- subscribe: () => hdgSmoother?.subscribe(false, true),
312
+ id: 'hdg.smoothed',
313
+ getPath: () => hdgSmoother?.handler?.path ?? 'navigation.headingTrue',
265
314
  })
266
315
  })
267
316
  } else if (!settings.tackTrue && hdgSmoother) {
@@ -278,11 +327,7 @@ module.exports = (app) => {
278
327
  const disabledPaths = keys
279
328
  .filter(k => OUTPUT_PATHS[k] && !settings[k])
280
329
  .flatMap(k => OUTPUT_PATHS[k])
281
- if (disabledPaths.length) {
282
- app.handleMessage(plugin.id, {
283
- updates: [{ values: disabledPaths.map(path => ({ path, value: null })) }]
284
- })
285
- }
330
+ publishNulls(disabledPaths)
286
331
  }
287
332
 
288
333
  // ---------------------------------------------------------------------------
@@ -291,10 +336,7 @@ module.exports = (app) => {
291
336
  // ---------------------------------------------------------------------------
292
337
 
293
338
  function nullifyOutputs() {
294
- const allPaths = Object.values(OUTPUT_PATHS).flat()
295
- app.handleMessage(plugin.id, {
296
- updates: [{ values: allPaths.map(path => ({ path, value: null })) }]
297
- })
339
+ publishNulls(Object.values(OUTPUT_PATHS).flat())
298
340
  }
299
341
 
300
342
  // ---------------------------------------------------------------------------
@@ -303,168 +345,132 @@ module.exports = (app) => {
303
345
 
304
346
  function computeAndSend() {
305
347
  if (hasPendingChanges) applyOptionChanges()
306
- if (!polarTable) return
348
+ if (!polar) return
307
349
 
308
350
  const wind = windSmoother.polarValue
309
351
  const TWS = wind.magnitude
310
352
  const TWAsigned = wind.angle
311
- if (!Number.isFinite(TWS) || !Number.isFinite(TWAsigned)) return
353
+ const windValid = Number.isFinite(TWS) && Number.isFinite(TWAsigned)
312
354
 
313
355
  // TWA is always positive for polar lookups; sign is tracked via `port`
314
- const TWA = Math.abs(TWAsigned)
356
+ const TWA = windValid ? Math.abs(TWAsigned) : null
315
357
  const port = TWAsigned < 0 ? -1 : 1
316
- const BSP = bspSmoother ? bspSmoother.value : null
317
- const HDG = hdgSmoother ? hdgSmoother.value : null
358
+ const BSP = finiteOrNull(bspSmoother ? bspSmoother.value : null)
359
+ const HDG = finiteOrNull(hdgSmoother ? hdgSmoother.value : null)
318
360
 
319
361
  const values = []
320
362
  const metas = []
321
363
 
322
- function add(skPath, value, unit, description) {
323
- if (!Number.isFinite(value)) return
324
- values.push({ path: skPath, value })
325
- if (!metaSentPaths.has(skPath)) {
326
- metas.push({ path: skPath, value: { units: unit, description } })
327
- metaSentPaths.add(skPath)
328
- }
329
- }
330
-
331
- // Always emit smoothed inputs
332
- if (settings.smoothedInputs) {
333
- add('environment.wind.angleTrueWaterDamped', TWAsigned, 'rad',
334
- 'True Wind Angle after smoothing, negative to port.')
335
- if (Number.isFinite(BSP)) {
336
- add('performance.boatSpeedDamped', BSP, 'm/s', 'Boat speed after smoothing.')
364
+ // Each enabled output is either published or explicitly cleared, so
365
+ // consumers never keep seeing a frozen value once its inputs disappear.
366
+ // Nulls are only sent on the transition to unavailable to limit bus traffic.
367
+ function output(settingKey, skPath, value, unit, description) {
368
+ if (!settings[settingKey]) return
369
+ if (Number.isFinite(value)) {
370
+ values.push({ path: skPath, value })
371
+ if (!metaSentPaths.has(skPath)) {
372
+ metas.push({ path: skPath, value: { units: unit, description } })
373
+ metaSentPaths.add(skPath)
374
+ }
375
+ } else if (lastOutputs[skPath] !== null) {
376
+ values.push({ path: skPath, value: null })
337
377
  }
338
378
  }
339
379
 
340
- // Polar lookups
341
- const isUpwind = TWA < Math.PI / 2
342
- const beatAngle = polarTable.getBeatAngle(TWS)
343
- const runAngle = polarTable.getRunAngle(TWS)
344
- const beatVMG = polarTable.getBeatVMG(TWS)
345
- const runVMG = polarTable.getRunVMG(TWS)
380
+ // Output classes:
381
+ // - measured values (actual VMG, damped inputs) are published whenever their inputs are valid;
382
+ // - steering advice (target angle, optimum wind angle, tack heading) is always published,
383
+ // including in irons, where it says how far to bear away;
384
+ // - all other polar values and polar ratios are cleared in irons, where the polar says nothing
385
+ // about the boat's current state.
386
+ const isUpwind = windValid && TWA < Math.PI / 2
387
+ const speedResult = windValid ? polar.speedAt({ tws: TWS, twa: TWA, performanceFactor }) : null
388
+ const inIrons = speedResult?.state?.twa === 'in_irons'
389
+ const fromPolar = value => (inIrons ? null : finiteOrNull(value))
390
+
391
+ const targets = windValid ? polar.targetsAt({ tws: TWS, performanceFactor }).value : null
392
+ const adviceBeatAngle = finiteOrNull(targets?.beat?.twa)
393
+ const adviceRunAngle = finiteOrNull(targets?.run?.twa)
394
+ const adviceAngle = isUpwind ? adviceBeatAngle : adviceRunAngle
395
+
396
+ const beatAngle = fromPolar(adviceBeatAngle)
397
+ const runAngle = fromPolar(adviceRunAngle)
398
+ const beatVMG = fromPolar(targets?.beat?.vmg)
399
+ const runVMG = fromPolar(targets?.run?.vmg)
346
400
  const targetAngle = isUpwind ? beatAngle : runAngle
347
401
  const targetVMG = isUpwind ? beatVMG : runVMG
402
+ const maxSpeed = fromPolar(targets?.maxSpeed?.speed)
403
+ const maxSpeedAngle = maxSpeed === null ? null : finiteOrNull(targets?.maxSpeed?.twa)
348
404
 
349
- if (Number.isFinite(beatAngle)) {
350
- if (settings.beatAngle) {
351
- add('performance.beatAngle', beatAngle * port, 'rad',
352
- 'Optimal beat angle for current TWS, negative to port.')
353
- }
354
- if (settings.targetTWA && isUpwind) {
355
- add('performance.targetAngle', beatAngle * port, 'rad',
356
- 'Target TWA — auto-switches between beat and run, negative to port.')
357
- }
358
- }
359
-
360
- if (Number.isFinite(runAngle)) {
361
- if (settings.beatAngle) {
362
- add('performance.gybeAngle', runAngle * port, 'rad',
363
- 'Optimal run/gybe angle for current TWS, negative to port.')
364
- }
365
- if (settings.targetTWA && !isUpwind) {
366
- add('performance.targetAngle', runAngle * port, 'rad',
367
- 'Target TWA — auto-switches between beat and run, negative to port.')
368
- }
369
- }
370
-
371
- if (Number.isFinite(beatVMG)) {
372
- if (settings.beatVMG) {
373
- add('performance.beatAngleVelocityMadeGood', beatVMG, 'm/s',
374
- 'Optimal beat VMG for current TWS.')
375
- }
376
- if (settings.targetTWA && isUpwind) {
377
- add('performance.targetVelocityMadeGood', beatVMG, 'm/s',
378
- 'Target VMG — auto-switches between beat and run.')
379
- }
380
- }
381
-
382
- if (Number.isFinite(runVMG)) {
383
- if (settings.beatVMG) {
384
- add('performance.gybeAngleVelocityMadeGood', runVMG, 'm/s',
385
- 'Optimal run VMG for current TWS.')
386
- }
387
- if (settings.targetTWA && !isUpwind) {
388
- add('performance.targetVelocityMadeGood', runVMG, 'm/s',
389
- 'Target VMG — auto-switches between beat and run.')
390
- }
391
- }
405
+ // Polar speed at the current TWA is also unavailable outside the polar's angle range
406
+ const speedAtTwa = speedResult?.value
407
+ const polarSpeed = Number.isFinite(speedAtTwa) && speedAtTwa > 0 ? speedAtTwa : null
392
408
 
393
- // Optimum wind angle: angular difference between current TWA and optimal angle
394
- if (settings.optimumWindAngle) {
395
- if (isUpwind && Number.isFinite(beatAngle)) {
396
- add('performance.optimumWindAngle', (TWA - beatAngle) * port, 'rad',
397
- 'Difference between TWA and beat angle, negative to port.')
398
- } else if (!isUpwind && Number.isFinite(runAngle)) {
399
- add('performance.optimumWindAngle', (runAngle - TWA) * port * -1, 'rad',
400
- 'Difference between TWA and run angle, negative to port.')
401
- }
402
- }
409
+ const targetCos = targetAngle === null ? 0 : Math.abs(Math.cos(targetAngle))
410
+ const targetSpeed = targetVMG !== null && targetVMG >= 0 && targetCos > 0.01
411
+ ? targetVMG / targetCos
412
+ : null
403
413
 
404
- // Polar speed and performance ratios
405
- const polarSpeed = polarTable.getBoatSpeed(TWS, TWA)
406
- if (Number.isFinite(polarSpeed) && polarSpeed > 0) {
407
- if (settings.polarSpeed) {
408
- add('performance.polarSpeed', polarSpeed, 'm/s',
409
- 'Polar chart boat speed for current TWS and TWA.')
410
-
411
- // Target speed: only meaningful when sailing within the polar range
412
- if (Number.isFinite(targetAngle) && Number.isFinite(targetVMG)) {
413
- const cosTarget = Math.abs(Math.cos(targetAngle))
414
- if (cosTarget > 0.01) {
415
- add('performance.targetSpeed', targetVMG / cosTarget, 'm/s',
416
- 'Boat speed needed to achieve target VMG at the optimal angle.')
417
- }
418
- }
414
+ const vmg = windValid && BSP !== null ? BSP * Math.cos(TWA) : null
415
+ const vmgRatio = ratio(vmg === null ? null : Math.abs(vmg), targetVMG)
419
416
 
420
- if (Number.isFinite(BSP)) {
421
- add('performance.polarSpeedRatio', BSP / polarSpeed, 'ratio',
422
- 'Actual boat speed divided by polar speed.')
423
- }
424
- }
417
+ let optimumWindAngle = null
418
+ if (isUpwind && adviceBeatAngle !== null) optimumWindAngle = (TWA - adviceBeatAngle) * port
419
+ else if (windValid && !isUpwind && adviceRunAngle !== null) optimumWindAngle = (adviceRunAngle - TWA) * port * -1
425
420
 
426
- if (Number.isFinite(BSP)) {
427
- if (settings.VMG && Number.isFinite(targetVMG) && targetVMG > 0) {
428
- const vmg = BSP * Math.cos(TWA)
429
- add('performance.velocityMadeGood', vmg, 'm/s',
430
- 'Actual VMG based on current boat speed and TWA.')
431
- add('performance.polarVelocityMadeGood', targetVMG, 'm/s',
432
- 'Polar VMG for current TWS.')
433
- if (Number.isFinite(vmg)) {
434
- add('performance.polarVelocityMadeGoodRatio', Math.abs(vmg) / targetVMG, 'ratio',
435
- 'Actual VMG divided by polar VMG.')
436
- }
437
- }
438
- }
439
- } else {
440
- // Clear these paths so no stale non-zero value remains on the SK bus
441
- if (settings.polarSpeed) {
442
- values.push({ path: 'performance.polarSpeed', value: null })
443
- values.push({ path: 'performance.polarSpeedRatio', value: null })
444
- values.push({ path: 'performance.targetSpeed', value: null })
445
- }
446
- }
447
-
448
- // Max speed for current TWS
449
- if (settings.maxSpeed) {
450
- const maxSpeed = polarTable.getMaxSpeed(TWS)
451
- const maxSpeedAngle = polarTable.getMaxSpeedAngle(TWS)
452
- if (Number.isFinite(maxSpeed)) {
453
- add('performance.maxSpeed', maxSpeed, 'm/s',
454
- 'Maximum polar boat speed for current TWS.')
455
- add('performance.maxSpeedAngle', maxSpeedAngle * port, 'rad',
456
- 'TWA at which maximum speed is achieved, negative to port.')
457
- }
458
- }
459
-
460
- // Opposite tack heading
461
- if (settings.tackTrue && Number.isFinite(HDG) && Number.isFinite(targetAngle)) {
462
- let tack = port < 0 ? HDG - targetAngle : HDG + targetAngle
421
+ let tack = null
422
+ if (HDG !== null && adviceAngle !== null) {
423
+ tack = port < 0 ? HDG - adviceAngle : HDG + adviceAngle
463
424
  tack = ((tack % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
464
- add('performance.tackTrue', tack, 'rad',
465
- 'Opposite tack heading relative to true north.')
466
425
  }
467
426
 
427
+ output('smoothedInputs', 'environment.wind.angleTrueWaterDamped', windValid ? TWAsigned : null, 'rad',
428
+ 'True Wind Angle after smoothing, negative to port.')
429
+ output('smoothedInputs', 'performance.boatSpeedDamped', BSP, 'm/s',
430
+ 'Boat speed after smoothing.')
431
+
432
+ output('beatAngle', 'performance.beatAngle', signed(beatAngle, port), 'rad',
433
+ 'Optimal beat angle for current TWS, negative to port.')
434
+ output('beatAngle', 'performance.gybeAngle', signed(runAngle, port), 'rad',
435
+ 'Optimal run/gybe angle for current TWS, negative to port.')
436
+
437
+ output('beatVMG', 'performance.beatAngleVelocityMadeGood', beatVMG, 'm/s',
438
+ 'Optimal beat VMG for current TWS.')
439
+ output('beatVMG', 'performance.gybeAngleVelocityMadeGood', runVMG, 'm/s',
440
+ 'Optimal run VMG for current TWS.')
441
+
442
+ output('targetTWA', 'performance.targetAngle', signed(adviceAngle, port), 'rad',
443
+ 'Target TWA — auto-switches between beat and run, negative to port.')
444
+ output('targetTWA', 'performance.targetVelocityMadeGood', targetVMG, 'm/s',
445
+ 'Target VMG — auto-switches between beat and run.')
446
+
447
+ output('optimumWindAngle', 'performance.optimumWindAngle', optimumWindAngle, 'rad',
448
+ isUpwind
449
+ ? '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.'
450
+ : '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.')
451
+
452
+ output('polarSpeed', 'performance.polarSpeed', polarSpeed, 'm/s',
453
+ 'Polar chart boat speed for current TWS and TWA.')
454
+ output('polarSpeed', 'performance.targetSpeed', targetSpeed, 'm/s',
455
+ 'Boat speed needed to achieve target VMG at the optimal angle.')
456
+ output('polarSpeed', 'performance.polarSpeedRatio', ratio(BSP, polarSpeed), 'ratio',
457
+ 'Actual boat speed divided by polar speed.')
458
+
459
+ output('VMG', 'performance.velocityMadeGood', vmg, 'm/s',
460
+ 'Actual VMG based on current boat speed and TWA.')
461
+ output('VMG', 'performance.polarVelocityMadeGood', targetVMG, 'm/s',
462
+ "Polar VMG for current TWS. The VMG predicted by the polar, not the vessel's actual VMG.")
463
+ output('VMG', 'performance.polarVelocityMadeGoodRatio', vmgRatio, 'ratio',
464
+ 'Actual VMG divided by polar VMG.')
465
+
466
+ output('maxSpeed', 'performance.maxSpeed', maxSpeed, 'm/s',
467
+ 'Maximum polar boat speed for current TWS.')
468
+ output('maxSpeed', 'performance.maxSpeedAngle', signed(maxSpeedAngle, port), 'rad',
469
+ 'TWA at which maximum speed is achieved, negative to port.')
470
+
471
+ output('tackTrue', 'performance.tackTrue', tack, 'rad',
472
+ 'Opposite tack heading relative to true north.')
473
+
468
474
  if (values.length === 0) return
469
475
 
470
476
  if (metas.length > 0) {
@@ -473,11 +479,8 @@ module.exports = (app) => {
473
479
 
474
480
  app.handleMessage(plugin.id, { updates: [{ values }] })
475
481
 
476
- // Snapshot enabled outputs for /status endpoint
477
- // Each output key maps to an array of { path, value } computed this cycle
478
- const outputSnapshot = {}
479
- values.forEach(({ path, value }) => { outputSnapshot[path] = value })
480
- Object.assign(lastOutputs, outputSnapshot)
482
+ // Snapshot outputs for the /status endpoint and null-transition tracking
483
+ values.forEach(({ path, value }) => { lastOutputs[path] = value })
481
484
  }
482
485
 
483
486
  // ---------------------------------------------------------------------------
@@ -491,7 +494,7 @@ module.exports = (app) => {
491
494
 
492
495
  schema: () => ({
493
496
  type: "object",
494
- description: "The plugin is configured through its webapp. Open it from the Signal K app list (Webapps → Advanced Wind) to set sources, enable corrections and adjust parameters.",
497
+ description: "The plugin is configured through its webapp. Open it from the Signal K app list (Webapps → Polar Performance) to set sources, enable corrections and adjust parameters. Select the active polar and performance factor in the Polar Management webapp.",
495
498
  properties: {}
496
499
  }),
497
500
 
@@ -503,76 +506,24 @@ module.exports = (app) => {
503
506
  registerWithRouter(router) {
504
507
  app.debug('registerWithRouter')
505
508
 
506
- function getStore() {
507
- if (!store) {
508
- store = new PolarFileStore(app.getDataDirPath())
509
- }
510
- return store
511
- }
512
-
513
- function loadStoredPolar(id) {
514
- return getStore().load(id)
515
- }
516
-
517
- // Size-1 cache: avoids reloading from disk when consecutive query requests
518
- // target the same polar ID (e.g. the N parallel curve fetches on startup).
519
- // perfAdjust is applied fresh on every use so it is never part of the key.
520
- let cachedPolar = null // { id, table } | null
521
-
522
- function loadPolarCached(id) {
523
- if (!cachedPolar || cachedPolar.id !== id) {
524
- cachedPolar = { id, table: loadStoredPolar(id) }
525
- }
526
- cachedPolar.table.setPerformanceAdjustment(settings.perfAdjust || 1)
527
- return cachedPolar.table
528
- }
529
-
530
- function invalidatePolarCache(id) {
531
- if (cachedPolar && cachedPolar.id === id) cachedPolar = null
532
- }
533
-
534
- function getImportService() {
535
- if (!importService) {
536
- importService = new ImportService(getStore())
537
- }
538
- return importService
539
- }
540
-
541
- // Lightweight internet connectivity probe.
542
- // Attempts a HEAD request to Cloudflare's public DNS (1.1.1.1) with a
543
- // 3-second timeout. Returns true if any HTTP response is received,
544
- // false on network error or timeout.
545
- async function checkInternet() {
546
- const TIMEOUT_MS = 3000
547
- try {
548
- const controller = new AbortController()
549
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
550
- const response = await fetch('https://1.1.1.1', { method: 'HEAD', signal: controller.signal })
551
- .finally(() => clearTimeout(timer))
552
- return response.status < 600
553
- } catch (_) {
554
- return false
555
- }
556
- }
557
-
558
509
  function errorStatus(error) {
559
- return /Polar not found/i.test(error.message) ? 404 : 500
510
+ return /No active polar/i.test(error.message) ? 404 : 500
560
511
  }
561
512
 
562
513
  function toFixedNumber(value, digits) {
563
514
  return Number.isFinite(value) ? parseFloat(value.toFixed(digits)) : null
564
515
  }
565
516
 
566
- function getPolarTwsValues(table) {
567
- return table.table
517
+ function getPolarTwsValues() {
518
+ return polar.entries
568
519
  .filter(entry => entry.tws > 0.001)
569
520
  .map(entry => parseFloat(entry.tws.toFixed(4)))
570
521
  }
571
522
 
572
- function buildCurveResult(table, tws, stepRad) {
523
+ function buildCurveResult(tws, stepRad) {
573
524
  const points = []
574
525
  for (let twa = 0; twa <= Math.PI + 1e-9; twa += stepRad) {
575
- const tbs = table.getBoatSpeed(tws, twa)
526
+ const { value: tbs } = polar.speedAt({ tws, twa, performanceFactor })
576
527
  if (Number.isFinite(tbs) && tbs > 0) {
577
528
  points.push({
578
529
  twa: toFixedNumber(twa, 5),
@@ -581,325 +532,47 @@ module.exports = (app) => {
581
532
  }
582
533
  }
583
534
 
584
- const beatAngle = table.getBeatAngle(tws)
585
- const beatVMG = table.getBeatVMG(tws)
586
- const runAngle = table.getRunAngle(tws)
587
- const runVMG = table.getRunVMG(tws)
588
- const beatTbs = Number.isFinite(beatAngle) ? table.getBoatSpeed(tws, beatAngle) : null
589
- const runTbs = Number.isFinite(runAngle) ? table.getBoatSpeed(tws, runAngle) : null
590
-
535
+ const { value: targets } = polar.targetsAt({ tws, performanceFactor })
591
536
  return {
592
537
  tws,
593
538
  points,
594
- beat: (Number.isFinite(beatAngle) && Number.isFinite(beatTbs)) ? {
595
- twa: toFixedNumber(beatAngle, 5),
596
- tbs: toFixedNumber(beatTbs, 4),
597
- vmg: toFixedNumber(beatVMG, 4)
539
+ beat: targets?.beat ? {
540
+ twa: toFixedNumber(targets.beat.twa, 5),
541
+ tbs: toFixedNumber(targets.beat.speed, 4),
542
+ vmg: toFixedNumber(targets.beat.vmg, 4)
598
543
  } : null,
599
- run: (Number.isFinite(runAngle) && Number.isFinite(runTbs)) ? {
600
- twa: toFixedNumber(runAngle, 5),
601
- tbs: toFixedNumber(runTbs, 4),
602
- vmg: toFixedNumber(runVMG, 4)
544
+ run: targets?.run ? {
545
+ twa: toFixedNumber(targets.run.twa, 5),
546
+ tbs: toFixedNumber(targets.run.speed, 4),
547
+ vmg: toFixedNumber(targets.run.vmg, 4)
603
548
  } : null
604
549
  }
605
550
  }
606
551
 
607
- function buildSpeedResult(table, tws, twa) {
608
- const tbs = table.getBoatSpeed(tws, twa)
609
- return {
610
- tws,
611
- twa,
612
- tbs: toFixedNumber(tbs, 4),
613
- state: table.getInterpolationState(tws, twa)
614
- }
615
- }
616
-
617
- function buildTargetsResult(table, tws) {
618
- const beatAngle = table.getBeatAngle(tws)
619
- const runAngle = table.getRunAngle(tws)
620
- const beatVMG = table.getBeatVMG(tws)
621
- const runVMG = table.getRunVMG(tws)
622
- const beatTbs = Number.isFinite(beatAngle) ? table.getBoatSpeed(tws, beatAngle) : null
623
- const runTbs = Number.isFinite(runAngle) ? table.getBoatSpeed(tws, runAngle) : null
624
-
625
- return {
626
- tws,
627
- beat: (Number.isFinite(beatAngle) && Number.isFinite(beatTbs)) ? {
628
- twa: toFixedNumber(beatAngle, 5),
629
- tbs: toFixedNumber(beatTbs, 4),
630
- vmg: toFixedNumber(beatVMG, 4)
631
- } : null,
632
- run: (Number.isFinite(runAngle) && Number.isFinite(runTbs)) ? {
633
- twa: toFixedNumber(runAngle, 5),
634
- tbs: toFixedNumber(runTbs, 4),
635
- vmg: toFixedNumber(runVMG, 4)
636
- } : null
637
- }
638
- }
639
-
640
- function buildPerformanceMetric(polarValue, actualValue) {
641
- if (!Number.isFinite(polarValue) || polarValue <= 0 || !Number.isFinite(actualValue)) {
642
- return null
643
- }
644
- return {
645
- polar: toFixedNumber(polarValue, 4),
646
- actual: toFixedNumber(actualValue, 4),
647
- ratio: toFixedNumber(actualValue / polarValue, 5)
648
- }
649
- }
650
-
651
- function buildPerformanceResult(table, tws, twa, bsp) {
652
- const polarSpeed = table.getBoatSpeed(tws, twa)
653
- const upwindLimit = Math.PI / 3
654
- const downwindLimit = 2 * Math.PI / 3
655
-
656
- let direction = 'reaching'
657
- let polarVmg = null
658
- let actualVmg = null
659
- if (twa < upwindLimit) {
660
- direction = 'upwind'
661
- polarVmg = table.getBeatVMG(tws)
662
- actualVmg = bsp * Math.cos(twa)
663
- } else if (twa > downwindLimit) {
664
- direction = 'downwind'
665
- polarVmg = table.getRunVMG(tws)
666
- actualVmg = Math.abs(bsp * Math.cos(twa))
667
- }
668
-
669
- return {
670
- tws,
671
- twa,
672
- bsp,
673
- direction,
674
- speed: buildPerformanceMetric(polarSpeed, bsp),
675
- vmg: direction === 'reaching' ? null : buildPerformanceMetric(polarVmg, actualVmg)
676
- }
677
- }
678
-
679
- function validatePolarResourceBody(resource) {
680
- return canonical.validateCanonicalPolarResourceBody(resource)
681
- }
682
-
683
- function generateCanonicalPolarId(resource) {
684
- return createTimestampedId(
685
- resource?.sailnumber || resource?.name || 'polar',
686
- 'polar',
687
- (candidate) => getStore().exists(candidate),
688
- Date.now
689
- )
690
- }
691
-
692
552
  router.use((req, res, next) => {
693
553
  res.set('Origin-Agent-Cluster', '?1')
694
554
  next()
695
555
  })
696
556
 
697
- router.get('/polars/active', (req, res) => {
698
- if (!settings.activePolar) {
699
- return res.status(404).json({ error: 'No polar is currently active' })
700
- }
701
- res.json({ id: settings.activePolar })
702
- })
703
-
704
- router.put('/polars/active', (req, res) => {
705
- const id = req.body?.id
706
- if (typeof id !== 'string') {
707
- return res.status(400).json({ error: "Expected JSON body with string 'id'" })
708
- }
709
-
710
- if (!id) {
711
- polarTable = null
712
- settings.activePolar = ''
713
- app.savePluginOptions(settings, (err) => {
714
- if (err) app.error('Failed to save settings: ' + err.message)
715
- })
716
- nullifyOutputs()
717
- app.setPluginStatus('No polar configured — set activePolar or upload a CSV file')
718
- return res.json({ id: '' })
719
- }
720
-
721
- try {
722
- polarTable = loadStoredPolar(id)
723
- polarTable.setPerformanceAdjustment(settings.perfAdjust || 1)
724
- settings.activePolar = id
725
- app.savePluginOptions(settings, (err) => {
726
- if (err) app.error('Failed to save settings: ' + err.message)
727
- })
728
- app.setPluginStatus(`Polar '${id}' loaded`)
729
- res.json({ id })
730
- } catch (e) {
731
- res.status(errorStatus(e)).json({ error: e.message })
732
- }
733
- })
734
-
735
- router.delete('/polars/active', (_req, res) => {
736
- polarTable = null
737
- settings.activePolar = ''
738
- app.savePluginOptions(settings, (err) => {
739
- if (err) app.error('Failed to save settings: ' + err.message)
740
- })
741
- nullifyOutputs()
742
- app.setPluginStatus('No polar configured — set activePolar or upload a CSV file')
743
- res.json({ id: '' })
744
- })
745
-
746
- router.get('/imports/formats', (_req, res) => {
747
- res.json(getImportService().listFormats())
748
- })
749
-
750
- router.get('/internet', async (_req, res) => {
751
- res.json({ online: await checkInternet() })
752
- })
753
-
754
- router.get('/imports/sources', (_req, res) => {
755
- res.json(getImportService().listSources())
756
- })
757
-
758
- router.post('/polars', (req, res) => {
759
- const validationError = validatePolarResourceBody(req.body)
760
- if (validationError) {
761
- return res.status(400).json({ error: validationError })
762
- }
763
-
764
- try {
765
- const id = generateCanonicalPolarId(req.body)
766
- getStore().saveCanonical(id, {
767
- ...req.body,
768
- name: req.body.name || id
769
- })
770
- res.status(201).json({ id })
771
- } catch (e) {
772
- res.status(500).json({ error: e.message })
773
- }
774
- })
775
-
776
- router.post('/imports/text/:format', (req, res) => {
777
- try {
778
- const result = getImportService().importText(req.params.format, req.body)
779
- res.status(201).json({ id: result.id })
780
- } catch (error) {
781
- if (error instanceof ImportError) {
782
- return res.status(error.status).json({ error: error.message })
783
- }
784
- res.status(500).json({ error: error.message })
785
- }
786
- })
557
+ // ---- Active polar curve queries (read-only — for the webapp's polar canvas) --
787
558
 
788
- router.get('/imports/sources/:source/search', async (req, res) => {
789
- if (!await checkInternet()) {
790
- return res.status(503).json({ error: 'No internet connection — external source imports are unavailable' })
791
- }
792
- try {
793
- const results = await getImportService().searchSource(req.params.source, req.query?.q)
794
- res.json(results)
795
- } catch (error) {
796
- if (error instanceof ImportError) {
797
- return res.status(error.status).json({ error: error.message })
798
- }
799
- res.status(500).json({ error: error.message })
800
- }
559
+ router.access('readonly').get('/polar/axes/tws', (req, res) => {
560
+ if (!polar) return res.status(404).json({ error: 'No active polar selected' })
561
+ res.json(getPolarTwsValues())
801
562
  })
802
563
 
803
- router.post('/imports/sources/:source/items/:externalId', async (req, res) => {
804
- if (!await checkInternet()) {
805
- return res.status(503).json({ error: 'No internet connection — external source imports are unavailable' })
806
- }
564
+ router.access('readonly').get('/polar/queries/curve', (req, res) => {
565
+ if (!polar) return res.status(404).json({ error: 'No active polar selected' })
807
566
  try {
808
- const result = await getImportService().importSource(req.params.source, req.params.externalId, req.body)
809
- res.status(201).json({ id: result.id })
810
- } catch (error) {
811
- if (error instanceof ImportError) {
812
- return res.status(error.status).json({ error: error.message })
813
- }
814
- res.status(500).json({ error: error.message })
815
- }
816
- })
817
-
818
- router.get('/polars/:id/axes/tws', (req, res) => {
819
- try {
820
- const table = loadStoredPolar(req.params.id)
821
- res.json(getPolarTwsValues(table))
822
- } catch (e) {
823
- res.status(errorStatus(e)).json({ error: e.message })
824
- }
825
- })
826
-
827
- router.get('/polars/:id/queries/curve', (req, res) => {
828
- try {
829
- const table = loadPolarCached(req.params.id)
830
567
  const tws = parseFloat(req.query.tws)
831
568
  if (!Number.isFinite(tws) || tws < 0) {
832
569
  return res.status(400).json({ error: "'tws' query parameter required (m/s)" })
833
570
  }
834
571
  const stepRad = parseFloat(req.query.step) || (2 * Math.PI / 180)
835
- if (!Number.isFinite(stepRad) || stepRad <= 0 || stepRad > Math.PI / 2) {
836
- return res.status(400).json({ error: "'step' must be between 0 and π/2 radians" })
837
- }
838
- res.json(buildCurveResult(table, tws, stepRad))
839
- } catch (e) {
840
- res.status(errorStatus(e)).json({ error: e.message })
841
- }
842
- })
843
-
844
- router.get('/polars/:id/queries/speed', (req, res) => {
845
- try {
846
- const table = loadPolarCached(req.params.id)
847
- const tws = parseFloat(req.query.tws)
848
- const twa = parseFloat(req.query.twa)
849
- if (!Number.isFinite(tws) || tws < 0 || !Number.isFinite(twa) || twa < 0) {
850
- return res.status(400).json({ error: "'tws' and 'twa' query parameters are required" })
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" })
851
574
  }
852
- res.json(buildSpeedResult(table, tws, twa))
853
- } catch (e) {
854
- res.status(errorStatus(e)).json({ error: e.message })
855
- }
856
- })
857
-
858
- router.get('/polars/:id/queries/targets', (req, res) => {
859
- try {
860
- const table = loadPolarCached(req.params.id)
861
- const tws = parseFloat(req.query.tws)
862
- if (!Number.isFinite(tws) || tws < 0) {
863
- return res.status(400).json({ error: "'tws' query parameter required (m/s)" })
864
- }
865
- res.json(buildTargetsResult(table, tws))
866
- } catch (e) {
867
- res.status(errorStatus(e)).json({ error: e.message })
868
- }
869
- })
870
-
871
- router.get('/polars/:id/queries/performance', (req, res) => {
872
- try {
873
- const table = loadPolarCached(req.params.id)
874
- const tws = parseFloat(req.query.tws)
875
- const twa = parseFloat(req.query.twa)
876
- const bsp = parseFloat(req.query.bsp)
877
- if (!Number.isFinite(tws) || tws < 0 || !Number.isFinite(twa) || twa < 0 || !Number.isFinite(bsp) || bsp < 0) {
878
- return res.status(400).json({ error: "'tws', 'twa', and 'bsp' query parameters are required" })
879
- }
880
- res.json(buildPerformanceResult(table, tws, twa, bsp))
881
- } catch (e) {
882
- res.status(errorStatus(e)).json({ error: e.message })
883
- }
884
- })
885
-
886
- router.get('/polars/:id/meta', (req, res) => {
887
- try {
888
- const polarStore = getStore()
889
- const table = polarStore.load(req.params.id)
890
- const meta = polarStore.readMeta(req.params.id)
891
- const twsValues = getPolarTwsValues(table)
892
- res.json({
893
- id: req.params.id,
894
- name: meta.boatName || req.params.id,
895
- sailnumber: meta.sailnumber,
896
- boatType: meta.boatType,
897
- year: meta.year,
898
- source: meta.source,
899
- notes: meta.notes,
900
- twsMin: twsValues.length ? twsValues[0] : null,
901
- twsMax: twsValues.length ? twsValues[twsValues.length - 1] : null
902
- })
575
+ res.json(buildCurveResult(tws, stepRad))
903
576
  } catch (e) {
904
577
  res.status(errorStatus(e)).json({ error: e.message })
905
578
  }
@@ -909,16 +582,17 @@ module.exports = (app) => {
909
582
  // tws/bsp/polarSpeed in m/s; twa in rad (positive = starboard, negative = port).
910
583
  // Returns null for any field not yet available (plugin not running,
911
584
  // no BSP source, polar not loaded, or boat in irons).
912
- router.get('/live', (req, res) => {
585
+ router.access('readonly').get('/live', (req, res) => {
913
586
  const wind = windSmoother?.ready ? windSmoother.polarValue : null
914
587
  const TWS = wind ? wind.magnitude : null
915
588
  const TWAsigned = wind ? wind.angle : null
916
589
  const TWA = Number.isFinite(TWAsigned) ? Math.abs(TWAsigned) : null
917
590
  const BSP = bspSmoother ? bspSmoother.value : null
918
591
 
919
- const polarSpeed = (polarTable && Number.isFinite(TWS) && Number.isFinite(TWA))
920
- ? polarTable.getBoatSpeed(TWS, TWA)
592
+ const polarResult = (polar && Number.isFinite(TWS) && Number.isFinite(TWA))
593
+ ? polar.speedAt({ tws: TWS, twa: TWA, performanceFactor })
921
594
  : null
595
+ const polarSpeed = polarResult ? polarResult.value : null
922
596
 
923
597
  const performance = (Number.isFinite(BSP) && Number.isFinite(polarSpeed) && polarSpeed > 0)
924
598
  ? BSP / polarSpeed
@@ -926,17 +600,13 @@ module.exports = (app) => {
926
600
 
927
601
  const si = v => Number.isFinite(v) ? parseFloat(v.toFixed(5)) : null
928
602
 
929
- const polarState = (polarTable && Number.isFinite(TWS) && Number.isFinite(TWA))
930
- ? polarTable.getInterpolationState(TWS, TWA)
931
- : null
932
-
933
603
  res.json({
934
604
  tws: si(TWS),
935
605
  twa: si(TWAsigned),
936
606
  bsp: si(BSP),
937
607
  polarSpeed: si(polarSpeed),
938
608
  performance: Number.isFinite(performance) ? parseFloat(performance.toFixed(5)) : null,
939
- polarState
609
+ polarState: polarResult ? polarResult.state : null
940
610
  })
941
611
  })
942
612
 
@@ -947,7 +617,7 @@ module.exports = (app) => {
947
617
  // inputs.smoothed.* — value the plugin actually used for computation
948
618
  // outputs.* — only present for enabled settings; null if polar not ready
949
619
  // polarState — same as /live
950
- router.get('/status', (req, res) => {
620
+ router.access('readonly').get('/status', (req, res) => {
951
621
  const si = v => (Number.isFinite(v) ? parseFloat(v.toFixed(5)) : null)
952
622
 
953
623
  // Raw inputs: read directly from the smoother handlers
@@ -964,8 +634,8 @@ module.exports = (app) => {
964
634
 
965
635
  const bspPath = settings.useSOG ? 'navigation.speedOverGround' : 'navigation.speedThroughWater'
966
636
 
967
- const polarState = (polarTable && Number.isFinite(TWS) && Number.isFinite(TWAsigned))
968
- ? polarTable.getInterpolationState(TWS, Math.abs(TWAsigned))
637
+ const polarState = (polar && Number.isFinite(TWS) && Number.isFinite(TWAsigned))
638
+ ? polar.speedAt({ tws: TWS, twa: Math.abs(TWAsigned), performanceFactor }).state
969
639
  : null
970
640
 
971
641
  // Build outputs object: only include paths that are enabled and were
@@ -1007,10 +677,10 @@ module.exports = (app) => {
1007
677
  })
1008
678
 
1009
679
  // Metadata describing the units and display preferences for each field
1010
- // returned by /live and the canonical curve query endpoints. displayUnits are fetched from the
1011
- // SK server's path metadata so user unit preferences (kn vs m/s etc.) are
1012
- // respected. Falls back to safe defaults when SK metadata is unavailable.
1013
- router.get('/meta', (req, res) => {
680
+ // returned by /live and the canonical curve query endpoints, plus a
681
+ // read-only summary of the active polar and performance factor
682
+ // (both sourced from the `polars.*` SK paths — not editable here).
683
+ router.access('readonly').get('/meta', (req, res) => {
1014
684
  const speed = { formula: 'value * 1.943844', symbol: 'kn', displayFormat: '0.0' }
1015
685
  const angle = { formula: 'value * 57.29577951308231', symbol: '\u00b0', displayFormat: '0.0' }
1016
686
  const ratio = { formula: 'value * 100', symbol: '%', displayFormat: '0.1' }
@@ -1023,7 +693,17 @@ module.exports = (app) => {
1023
693
  performance: { units: 'ratio', displayUnits: ratio },
1024
694
  'curve.tbs': { units: 'm/s', displayUnits: speed },
1025
695
  'curve.vmg': { units: 'm/s', displayUnits: speed },
1026
- 'curve.twa': { units: 'rad', displayUnits: angle }
696
+ 'curve.twa': { units: 'rad', displayUnits: angle },
697
+ activePolar: activePolarDoc ? {
698
+ id: activePolarId,
699
+ name: activePolarDoc.name || activePolarId,
700
+ boatType: activePolarDoc.boatType ?? null,
701
+ sailnumber: activePolarDoc.sailnumber ?? null,
702
+ year: activePolarDoc.year ?? null,
703
+ source: activePolarDoc.source ?? null,
704
+ notes: activePolarDoc.notes ?? null
705
+ } : null,
706
+ performanceFactor
1027
707
  })
1028
708
  })
1029
709
 
@@ -1043,115 +723,50 @@ module.exports = (app) => {
1043
723
  // wind update (or immediately below if the plugin is already running).
1044
724
  Object.assign(changedOptions, req.body)
1045
725
  hasPendingChanges = true
1046
- // Drain immediately so source/polar changes take effect even when the
726
+ // Drain immediately so source changes take effect even when the
1047
727
  // wind data stream is idle.
1048
728
  if (isRunning) applyOptionChanges()
1049
729
  res.json({ ...settings, ...changedOptions, _defaults: DEFAULT_SETTINGS })
1050
730
  })
1051
-
1052
- // ---- Polar file operations -------------------------------------------
1053
-
1054
- router.get('/polars', (req, res) => {
1055
- try {
1056
- const polarStore = getStore()
1057
- const polars = polarStore.list().map((id) => {
1058
- const meta = polarStore.readMeta(id)
1059
- return {
1060
- id,
1061
- name: meta.boatName || id,
1062
- sailnumber: meta.sailnumber,
1063
- boatType: meta.boatType,
1064
- year: meta.year,
1065
- source: meta.source
1066
- }
1067
- })
1068
- res.json(polars)
1069
- } catch (e) {
1070
- res.status(500).json({ error: e.message })
1071
- }
1072
- })
1073
-
1074
- router.get('/polars/:id', (req, res) => {
1075
- try {
1076
- const stored = getStore().readObject(req.params.id)
1077
- res.json({ ...stored, id: req.params.id })
1078
- } catch (e) {
1079
- res.status(404).json({ error: e.message })
1080
- }
1081
- })
1082
-
1083
- router.put('/polars/:id', (req, res) => {
1084
- const validationError = validatePolarResourceBody(req.body)
1085
- if (validationError) {
1086
- return res.status(400).json({ error: validationError })
1087
- }
1088
-
1089
- try {
1090
- getStore().saveCanonical(req.params.id, {
1091
- ...req.body,
1092
- name: req.body.name || req.params.id
1093
- })
1094
- invalidatePolarCache(req.params.id)
1095
- if (settings.activePolar === req.params.id) {
1096
- polarTable = loadStoredPolar(req.params.id)
1097
- polarTable.setPerformanceAdjustment(settings.perfAdjust || 1)
1098
- }
1099
- res.json({ id: req.params.id })
1100
- } catch (e) {
1101
- res.status(500).json({ error: e.message })
1102
- }
1103
- })
1104
-
1105
- router.delete('/polars/:id', (req, res) => {
1106
- try {
1107
- getStore().delete(req.params.id)
1108
- invalidatePolarCache(req.params.id)
1109
- if (settings.activePolar === req.params.id) {
1110
- settings.activePolar = ''
1111
- polarTable = null
1112
- nullifyOutputs()
1113
- app.savePluginOptions(settings, (err) => {
1114
- if (err) app.error('Failed to save settings: ' + err.message)
1115
- })
1116
- }
1117
- res.json({ id: req.params.id })
1118
- } catch (e) {
1119
- res.status(errorStatus(e)).json({ error: e.message })
1120
- }
1121
- })
1122
731
  },
1123
732
 
1124
733
  start(options) {
734
+ app.debug('Starting')
1125
735
  metaSentPaths = new Set() // reset so metadata is re-emitted after restart
1126
736
  lifecycleWarningMap = new Map()
1127
737
  lifecycleWarnings = []
1128
-
1129
- store = new PolarFileStore(app.getDataDirPath())
1130
- importService = new ImportService(store)
738
+ polar = null
739
+ activePolarId = null
740
+ activePolarDoc = null
741
+ performanceFactor = 1
1131
742
 
1132
743
  settings = migrateSettings({ ...DEFAULT_SETTINGS, ...options, settingsVersion: options.settingsVersion ?? 0 })
1133
744
 
1134
- const SmootherClass = getSmootherClass(settings.smootherType)
1135
- const smootherOptions = getSmootherOptions(settings.smootherType, settings)
1136
-
1137
- // Load the active polar table
1138
- if (settings.activePolar) {
1139
- try {
1140
- polarTable = store.load(settings.activePolar)
1141
- polarTable.setPerformanceAdjustment(settings.perfAdjust || 1)
1142
- app.setPluginStatus(`Polar '${settings.activePolar}' loaded`)
1143
- } catch (e) {
1144
- // File missing or corrupt — run without a polar rather than crashing.
1145
- // Keep settings.activePolar so the user can see which file is broken
1146
- // and re-upload it without reconfiguring. Nullify any stale SK values.
1147
- polarTable = null
1148
- nullifyOutputs()
1149
- app.setPluginError(`Polar '${settings.activePolar}' could not be loaded: ${e.message}`)
1150
- app.debug('Polar load error: %s', e.message)
745
+ checkPolarProvider().then((available) => {
746
+ app.debug(`checkPolarProvider: ${available ? 'polars resource provider found' : 'no polars resource provider'}`)
747
+ if (!available) {
748
+ app.setPluginStatus("No 'polars' resource provider registered — install signalk-polar-management (or another plugin providing the 'polars' resource type) to select an active polar.")
1151
749
  }
1152
- } else {
1153
- app.setPluginStatus('No polar configured — set activePolar or upload a CSV file')
750
+ })
751
+
752
+ // Active polar / performance factor — read-only, published by a 'polars' resource provider.
753
+ // These are event-driven (change only on user action), so sensor idle warnings
754
+ // do not apply here: absence of deltas is the normal state.
755
+ activePolarHandler = new MessageHandler(app, plugin.id, 'activePolar')
756
+ activePolarHandler.path = 'polars.activePolar'
757
+ activePolarHandler.onDelta = () => handleActivePolarDelta()
758
+ activePolarHandler.subscribe()
759
+
760
+ performanceFactorHandler = new MessageHandler(app, plugin.id, 'performanceFactor')
761
+ performanceFactorHandler.path = 'polars.performanceFactor'
762
+ performanceFactorHandler.onDelta = () => {
763
+ const value = performanceFactorHandler.value
764
+ performanceFactor = Number.isFinite(value) ? value : 1
1154
765
  }
766
+ performanceFactorHandler.subscribe()
767
+
768
+ const SmootherClass = getSmootherClass(settings.smootherType)
769
+ const smootherOptions = getSmootherOptions(settings.smootherType, settings)
1155
770
 
1156
771
  // Wind vector smoother (TWS + TWA combined as a Cartesian vector —
1157
772
  // avoids ±π wraparound discontinuity during smoothing)
@@ -1167,8 +782,6 @@ module.exports = (app) => {
1167
782
  ..._wireHandlerWatchdog({
1168
783
  id: 'wind.smoothed',
1169
784
  getPath: () => `${windSmoother?.polar?.pathMagnitude ?? 'environment.wind.speedTrue'}, ${windSmoother?.polar?.pathAngle ?? 'environment.wind.angleTrueWater'}`,
1170
- unsubscribe: () => windSmoother?.unsubscribe(),
1171
- subscribe: () => windSmoother?.subscribe(true, true),
1172
785
  }),
1173
786
  onDelta: () => {
1174
787
  _clearLifecycleWarning('wind.smoothed')
@@ -1190,8 +803,6 @@ module.exports = (app) => {
1190
803
  ..._wireHandlerWatchdog({
1191
804
  id: 'bsp.smoothed',
1192
805
  getPath: () => bspSmoother?.handler?.path ?? (settings.useSOG ? 'navigation.speedOverGround' : 'navigation.speedThroughWater'),
1193
- unsubscribe: () => bspSmoother?.unsubscribe(),
1194
- subscribe: () => bspSmoother?.subscribe(),
1195
806
  })
1196
807
  })
1197
808
 
@@ -1206,8 +817,6 @@ module.exports = (app) => {
1206
817
  ..._wireHandlerWatchdog({
1207
818
  id: 'hdg.smoothed',
1208
819
  getPath: () => hdgSmoother?.handler?.path ?? 'navigation.headingTrue',
1209
- unsubscribe: () => hdgSmoother?.unsubscribe(),
1210
- subscribe: () => hdgSmoother?.subscribe(false, true),
1211
820
  })
1212
821
  })
1213
822
  }
@@ -1217,12 +826,17 @@ module.exports = (app) => {
1217
826
  },
1218
827
 
1219
828
  stop() {
829
+ app.debug('Stopping')
1220
830
  isRunning = false
1221
- importService = null
1222
831
  nullifyOutputs()
1223
832
  if (windSmoother) { windSmoother.terminate(); windSmoother = null }
1224
833
  if (bspSmoother) { bspSmoother.terminate(); bspSmoother = null }
1225
834
  if (hdgSmoother) { hdgSmoother.terminate(); hdgSmoother = null }
835
+ if (activePolarHandler) { activePolarHandler.unsubscribe(); activePolarHandler = null }
836
+ if (performanceFactorHandler) { performanceFactorHandler.unsubscribe(); performanceFactorHandler = null }
837
+ polar = null
838
+ activePolarId = null
839
+ activePolarDoc = null
1226
840
  lifecycleWarningMap = new Map()
1227
841
  lifecycleWarnings = []
1228
842
  app.debug('Plugin stopped')