signalk-polar-performance-plugin 1.4.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +64 -84
- package/docs/developer-reference.md +34 -137
- package/openApi.json +12 -798
- package/package.json +14 -8
- package/plugin/index.js +298 -684
- package/public/app.js +53 -638
- package/public/index.html +0 -2
- package/public/plotter.html +20 -106
- package/test/lifecycle.test.js +90 -1
- package/test/outputs.test.js +304 -0
- package/test/routePolicyShim.js +42 -0
- package/docs/import.png +0 -0
- package/docs/polar.png +0 -0
- package/docs/polar_management.png +0 -0
- package/plugin/PolarFileStore.js +0 -133
- package/plugin/PolarTable.js +0 -924
- package/plugin/SI.js +0 -21
- package/plugin/import/ExpeditionImporter.js +0 -152
- package/plugin/import/FormatRegistry.js +0 -21
- package/plugin/import/ImportService.js +0 -199
- package/plugin/import/JieterImporter.js +0 -18
- package/plugin/import/OrcSource.js +0 -396
- package/plugin/import/SourceRegistry.js +0 -17
- package/plugin/import/canonical.js +0 -73
- package/plugin/import/matrixText.js +0 -133
- package/plugin/index.js.bak +0 -1003
- package/test/PolarFileStore.test.js +0 -216
- package/test/PolarTable.test.js +0 -464
- package/test/api.test.js +0 -507
- package/test/migration.test.js +0 -170
package/plugin/index.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const {
|
|
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 =
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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}
|
|
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
|
|
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}
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
405
|
-
const
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
477
|
-
|
|
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 →
|
|
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 /
|
|
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(
|
|
567
|
-
return
|
|
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(
|
|
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 =
|
|
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
|
|
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:
|
|
595
|
-
twa: toFixedNumber(
|
|
596
|
-
tbs: toFixedNumber(
|
|
597
|
-
vmg: toFixedNumber(
|
|
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:
|
|
600
|
-
twa: toFixedNumber(
|
|
601
|
-
tbs: toFixedNumber(
|
|
602
|
-
vmg: toFixedNumber(
|
|
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
|
-
|
|
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('/
|
|
789
|
-
if (!
|
|
790
|
-
|
|
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.
|
|
804
|
-
if (!
|
|
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
|
|
836
|
-
return res.status(400).json({ error: "'step' must be between
|
|
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(
|
|
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
|
|
920
|
-
?
|
|
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 = (
|
|
968
|
-
?
|
|
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
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
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
|
|
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
|
-
|
|
1130
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
-
}
|
|
1153
|
-
|
|
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')
|