signalk-polar-performance-plugin 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -1
- package/README.md +38 -10
- package/docs/developer-reference.md +45 -21
- package/package.json +8 -11
- package/plugin/index.js +245 -294
- package/public/app.js +11 -1
- package/test/lifecycle.test.js +91 -1
- package/test/outputs.test.js +419 -0
- package/test/routePolicyShim.js +42 -0
package/public/app.js
CHANGED
|
@@ -349,6 +349,13 @@ function polarStateWarnings(d) {
|
|
|
349
349
|
const s = d?.polarState
|
|
350
350
|
if (!s) return []
|
|
351
351
|
const msgs = []
|
|
352
|
+
// Unavailable results from the management calculation service carry a reason
|
|
353
|
+
// instead of interpolation details.
|
|
354
|
+
if (s.available === false) {
|
|
355
|
+
if (s.reason === 'no_active_polar') msgs.push('No active polar — select one in the polar management webapp')
|
|
356
|
+
else if (s.reason) msgs.push('Polar calculations unavailable (' + s.reason.replace(/_/g, ' ') + ')')
|
|
357
|
+
return msgs
|
|
358
|
+
}
|
|
352
359
|
if (s.tws === 'below_range') msgs.push('Wind speed is below the polar table range — values are extrapolated')
|
|
353
360
|
if (s.tws === 'above_range') msgs.push('Wind speed is above the polar table range — values are extrapolated')
|
|
354
361
|
if (s.twa === 'in_irons') msgs.push('Sailing in irons — too close to the wind for polar data')
|
|
@@ -471,6 +478,7 @@ function _tickOverview() {
|
|
|
471
478
|
{ label: 'Sail number', id: 'ov-polar-sailnumber' },
|
|
472
479
|
{ label: 'Year', id: 'ov-polar-year' },
|
|
473
480
|
{ label: 'Source', id: 'ov-polar-source' },
|
|
481
|
+
{ label: 'Sailplan', id: 'ov-polar-sailplan' },
|
|
474
482
|
{ label: 'Performance factor', id: 'ov-polar-perf' },
|
|
475
483
|
]))
|
|
476
484
|
}
|
|
@@ -480,6 +488,7 @@ function _tickOverview() {
|
|
|
480
488
|
setVal('ov-polar-sailnumber', p.sailnumber || '—')
|
|
481
489
|
setVal('ov-polar-year', p.year ? String(p.year) : '—')
|
|
482
490
|
setVal('ov-polar-source', p.source || '—')
|
|
491
|
+
setVal('ov-polar-sailplan', p.sailplanId || '—')
|
|
483
492
|
setVal('ov-polar-perf', fmtVal(meta?.performanceFactor, 'performance', RATIO_DEFAULT))
|
|
484
493
|
}
|
|
485
494
|
}
|
|
@@ -512,7 +521,7 @@ function _tickOverview() {
|
|
|
512
521
|
if (d?.tws == null) warns.push('True wind speed — no data (environment.wind.speedTrue)')
|
|
513
522
|
if (d?.twa == null) warns.push('True wind angle — no data (environment.wind.angleTrueWater)')
|
|
514
523
|
if (d?.bsp == null) warns.push('Boat speed — no data')
|
|
515
|
-
if (d?.tws != null && d?.polarState == null) warns.push('
|
|
524
|
+
if (d?.tws != null && d?.polarState == null) warns.push('Polar calculation service not available — install or enable signalk-polar-management')
|
|
516
525
|
warns.push(...polarStateWarnings(d))
|
|
517
526
|
updateWarnings(document.getElementById('ov-warnings'), warns)
|
|
518
527
|
}
|
|
@@ -720,6 +729,7 @@ function switchPage(page) {
|
|
|
720
729
|
function startPolling() {
|
|
721
730
|
setInterval(refreshLive, 1000)
|
|
722
731
|
setInterval(async () => {
|
|
732
|
+
await loadMeta()
|
|
723
733
|
await refreshLibrary()
|
|
724
734
|
if (activePage === 'overview' && polar) polar.setLibraryData(twsList, curves, liveCurve)
|
|
725
735
|
}, 5000)
|
package/test/lifecycle.test.js
CHANGED
|
@@ -5,6 +5,7 @@ const assert = require('node:assert/strict')
|
|
|
5
5
|
const fs = require('fs')
|
|
6
6
|
const path = require('path')
|
|
7
7
|
const os = require('os')
|
|
8
|
+
const { withRoutePolicies, wrapPluginRoutes } = require('./routePolicyShim')
|
|
8
9
|
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
// Minimal app shim
|
|
@@ -24,6 +25,7 @@ function makeApp(dataDir) {
|
|
|
24
25
|
},
|
|
25
26
|
getDataDirPath: () => dataDir,
|
|
26
27
|
handleMessage: () => {},
|
|
28
|
+
onPropertyValues: () => () => {},
|
|
27
29
|
config: { port: 3000 },
|
|
28
30
|
subscriptionmanager: {
|
|
29
31
|
subscribe: (_sub, unsubscribes, _onErr, _onDelta) => {
|
|
@@ -39,7 +41,7 @@ function makeApp(dataDir) {
|
|
|
39
41
|
|
|
40
42
|
function freshPlugin(app) {
|
|
41
43
|
delete require.cache[require.resolve('../plugin/index.js')]
|
|
42
|
-
return require('../plugin/index.js')(app)
|
|
44
|
+
return wrapPluginRoutes(require('../plugin/index.js')(app))
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
// ---------------------------------------------------------------------------
|
|
@@ -106,6 +108,25 @@ describe('Plugin lifecycle — start() → stop() → start()', () => {
|
|
|
106
108
|
assert.ok(routes.some(r => r.startsWith('GET')), 'no GET routes registered')
|
|
107
109
|
})
|
|
108
110
|
|
|
111
|
+
it('applies readonly access only to safe live and polar metadata routes', () => {
|
|
112
|
+
const router = {
|
|
113
|
+
use: () => {},
|
|
114
|
+
get: () => {},
|
|
115
|
+
put: () => {},
|
|
116
|
+
}
|
|
117
|
+
plugin.registerWithRouter(withRoutePolicies(router))
|
|
118
|
+
|
|
119
|
+
assert.deepEqual(Object.fromEntries(router.routePolicies), {
|
|
120
|
+
'GET /polar/axes/tws': 'readonly',
|
|
121
|
+
'GET /polar/queries/curve': 'readonly',
|
|
122
|
+
'GET /live': 'readonly',
|
|
123
|
+
'GET /status': 'readonly',
|
|
124
|
+
'GET /meta': 'readonly',
|
|
125
|
+
'GET /settings': 'admin',
|
|
126
|
+
'PUT /settings': 'admin',
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
109
130
|
it('registerWithRouter + start() + stop() + start() does not throw', () => {
|
|
110
131
|
const router = {
|
|
111
132
|
use: () => {},
|
|
@@ -116,4 +137,73 @@ describe('Plugin lifecycle — start() → stop() → start()', () => {
|
|
|
116
137
|
plugin.stop()
|
|
117
138
|
assert.doesNotThrow(() => plugin.start({}))
|
|
118
139
|
})
|
|
140
|
+
|
|
141
|
+
it('keeps subscriptions through silence and receives resumed wind without replay', t => {
|
|
142
|
+
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] })
|
|
143
|
+
const subscribers = new Map()
|
|
144
|
+
let subscriptions = 0
|
|
145
|
+
let unsubscriptions = 0
|
|
146
|
+
let messages = 0
|
|
147
|
+
app.handleMessage = () => { messages++ }
|
|
148
|
+
app.subscriptionmanager.subscribe = (msg, unsubscribes, _err, callback) => {
|
|
149
|
+
subscriptions++
|
|
150
|
+
for (const { path } of msg.subscribe) {
|
|
151
|
+
if (!subscribers.has(path)) subscribers.set(path, new Set())
|
|
152
|
+
subscribers.get(path).add(callback)
|
|
153
|
+
}
|
|
154
|
+
unsubscribes.push(() => {
|
|
155
|
+
unsubscriptions++
|
|
156
|
+
for (const { path } of msg.subscribe) subscribers.get(path).delete(callback)
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
const routes = {}
|
|
160
|
+
plugin.registerWithRouter({
|
|
161
|
+
use: () => {}, get: (p, cb) => { routes[p] = cb },
|
|
162
|
+
put: (p, cb) => { routes[`PUT ${p}`] = cb }, post: () => {}, delete: () => {}
|
|
163
|
+
})
|
|
164
|
+
plugin.start({ tackTrue: true })
|
|
165
|
+
const deliver = (path, value) => {
|
|
166
|
+
for (const callback of subscribers.get(path)) {
|
|
167
|
+
callback({ updates: [{ values: [{ path, value }] }] })
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
deliver('environment.wind.speedTrue', 5)
|
|
171
|
+
deliver('environment.wind.angleTrueWater', 0.4)
|
|
172
|
+
const initialSubscriptions = subscriptions
|
|
173
|
+
const initialMessages = messages
|
|
174
|
+
t.mock.timers.tick(120000)
|
|
175
|
+
assert.equal(subscriptions, initialSubscriptions)
|
|
176
|
+
assert.equal(unsubscriptions, 0)
|
|
177
|
+
assert.equal(messages, initialMessages, 'silence must not publish cached inputs')
|
|
178
|
+
let status
|
|
179
|
+
routes['/status']({}, { json: value => { status = value } })
|
|
180
|
+
for (const id of ['wind.smoothed', 'bsp.smoothed', 'hdg.smoothed']) {
|
|
181
|
+
const warning = status.lifecycleWarnings.find(w => w.id === id)
|
|
182
|
+
assert.equal(warning?.status, 'idle', id)
|
|
183
|
+
assert.doesNotMatch(warning.message, /resubscrib/i)
|
|
184
|
+
}
|
|
185
|
+
assert.equal(status.inputs.smoothed.tws, null)
|
|
186
|
+
deliver('environment.wind.speedTrue', 6)
|
|
187
|
+
deliver('environment.wind.angleTrueWater', 0.5)
|
|
188
|
+
routes['/status']({}, { json: value => { status = value } })
|
|
189
|
+
assert.equal(status.lifecycleWarnings.some(w => w.id === 'wind.smoothed'), false)
|
|
190
|
+
assert.equal(status.inputs.smoothed.tws, 6)
|
|
191
|
+
assert.equal(subscriptions, initialSubscriptions)
|
|
192
|
+
|
|
193
|
+
routes['PUT /settings']({ body: { useSOG: true } }, { json: () => {} })
|
|
194
|
+
assert.equal(subscriptions, initialSubscriptions + 1, 'a source change must still resubscribe')
|
|
195
|
+
assert.equal(unsubscriptions, 1)
|
|
196
|
+
assert.equal(subscribers.get('navigation.speedThroughWater').size, 0)
|
|
197
|
+
assert.equal(subscribers.get('navigation.speedOverGround').size, 1)
|
|
198
|
+
|
|
199
|
+
routes['PUT /settings']({ body: { tackTrue: false } }, { json: () => {} })
|
|
200
|
+
routes['PUT /settings']({ body: { tackTrue: true } }, { json: () => {} })
|
|
201
|
+
const configuredSubscriptions = subscriptions
|
|
202
|
+
const configuredUnsubscriptions = unsubscriptions
|
|
203
|
+
t.mock.timers.tick(120000)
|
|
204
|
+
routes['/status']({}, { json: value => { status = value } })
|
|
205
|
+
assert.equal(status.lifecycleWarnings.find(w => w.id === 'hdg.smoothed')?.status, 'idle')
|
|
206
|
+
assert.equal(subscriptions, configuredSubscriptions)
|
|
207
|
+
assert.equal(unsubscriptions, configuredUnsubscriptions)
|
|
208
|
+
})
|
|
119
209
|
})
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
4
|
+
const assert = require('node:assert/strict')
|
|
5
|
+
const { Polar } = require('polar-math')
|
|
6
|
+
const { wrapPluginRoutes } = require('./routePolicyShim')
|
|
7
|
+
|
|
8
|
+
const KNOTS = 1 / 1.94384
|
|
9
|
+
const rad = degrees => degrees * Math.PI / 180
|
|
10
|
+
const target = (degrees, knots) => {
|
|
11
|
+
const twa = rad(degrees)
|
|
12
|
+
const tbs = knots * KNOTS
|
|
13
|
+
return { twa, tbs, vmg: tbs * Math.abs(Math.cos(twa)) }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TABLE = {
|
|
17
|
+
kind: 'polarTable', schemaVersion: '1.0.0', name: 'Reference Polar',
|
|
18
|
+
units: { tws: 'm/s', twa: 'rad', boatSpeed: 'm/s' },
|
|
19
|
+
symmetry: { portStarboardSymmetric: true },
|
|
20
|
+
axes: { tws: [4, 12, 24].map(k => k * KNOTS), twa: [52, 60, 75, 90, 110, 120, 135, 150].map(rad) },
|
|
21
|
+
values: { boatSpeedMatrix: [
|
|
22
|
+
[3.66, 3.88, 4, 3.9, 3.7, 3.54, 3.12, 2.56],
|
|
23
|
+
[6.42, 6.58, 6.72, 6.82, 6.89, 6.83, 6.62, 6.25],
|
|
24
|
+
[6.69, 6.91, 7.27, 7.61, 8.04, 8.44, 8.54, 8.18]
|
|
25
|
+
].map(row => row.map(k => k * KNOTS)) },
|
|
26
|
+
derived: { rows: [
|
|
27
|
+
{ tws: 4 * KNOTS, beat: target(43.2, 3.24), run: target(147.5, 2.63) },
|
|
28
|
+
{ tws: 12 * KNOTS, beat: target(39.6, 5.84), run: target(162.4, 5.83) },
|
|
29
|
+
{ tws: 24 * KNOTS, beat: target(40.6, 6.11), run: target(178.6, 7.63) }
|
|
30
|
+
] }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Downwind-only polar: no beat targets exist, so upwind targets are unavailable.
|
|
34
|
+
const DOWNWIND_ONLY = {
|
|
35
|
+
kind: 'polarTable', schemaVersion: '1.0.0', name: 'Downwind Only',
|
|
36
|
+
units: { tws: 'm/s', twa: 'rad', boatSpeed: 'm/s' },
|
|
37
|
+
symmetry: { portStarboardSymmetric: true },
|
|
38
|
+
axes: { tws: [12 * KNOTS], twa: [90, 120, 150].map(rad) },
|
|
39
|
+
values: { boatSpeedMatrix: [[6.8, 6.8, 6.2].map(k => k * KNOTS)] }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// settingsVersion skips migration, which would otherwise force Exponential smoothing
|
|
43
|
+
const ALL_OUTPUTS = {
|
|
44
|
+
settingsVersion: 2, smootherType: 'None',
|
|
45
|
+
beatAngle: true, beatVMG: true, targetTWA: true, optimumWindAngle: true,
|
|
46
|
+
VMG: true, maxSpeed: true, polarSpeed: true, tackTrue: true, smoothedInputs: true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const TWS = 12 * KNOTS
|
|
50
|
+
const reference = Polar.fromTable(TABLE)
|
|
51
|
+
const targets = reference.targetsAt({ tws: TWS }).value
|
|
52
|
+
const close = (actual, expected, msg) =>
|
|
53
|
+
assert.ok(Number.isFinite(actual) && Math.abs(actual - expected) < 1e-9, `${msg}: ${actual} != ${expected}`)
|
|
54
|
+
|
|
55
|
+
function harness(table = TABLE, { withService = true, description = {} } = {}) {
|
|
56
|
+
const subscribers = new Map()
|
|
57
|
+
const bus = new Map() // latest value per path written by the plugin
|
|
58
|
+
const writes = [] // every { path, value } written, in order
|
|
59
|
+
const calls = [] // every calculation call made on the service handle
|
|
60
|
+
const changeListeners = new Set()
|
|
61
|
+
|
|
62
|
+
// Fake in-process calculation service, as announced by signalk-polar-management
|
|
63
|
+
// through the `polarManagement` PropertyValue. Wraps a real polar-math Polar
|
|
64
|
+
// so numeric expectations stay exact. The handle owns the performance factor;
|
|
65
|
+
// recorded calls let tests assert the plugin never supplies one. `state`
|
|
66
|
+
// drives describe(), mirroring how real active handles follow shared settings.
|
|
67
|
+
const polar = Polar.fromTable(table)
|
|
68
|
+
const state = {
|
|
69
|
+
active: true,
|
|
70
|
+
id: 'ref',
|
|
71
|
+
sailplanId: null,
|
|
72
|
+
performanceFactor: 1,
|
|
73
|
+
name: table.name ?? null,
|
|
74
|
+
twsAxis: table.axes?.tws ?? [],
|
|
75
|
+
...description
|
|
76
|
+
}
|
|
77
|
+
const handle = {
|
|
78
|
+
speedAt: options => { calls.push({ method: 'speedAt', options }); return polar.speedAt(options) },
|
|
79
|
+
vmgAt: options => { calls.push({ method: 'vmgAt', options }); return polar.vmgAt(options) },
|
|
80
|
+
targetsAt: options => { calls.push({ method: 'targetsAt', options }); return polar.targetsAt(options) },
|
|
81
|
+
rangeAt: options => { calls.push({ method: 'rangeAt', options }); return polar.rangeAt(options) },
|
|
82
|
+
describe: () => state.active
|
|
83
|
+
? {
|
|
84
|
+
available: true,
|
|
85
|
+
id: state.id,
|
|
86
|
+
sailplanId: state.sailplanId,
|
|
87
|
+
performanceFactor: state.performanceFactor,
|
|
88
|
+
name: state.name,
|
|
89
|
+
boatType: null, sailnumber: null, year: null, source: null, notes: null,
|
|
90
|
+
twsAxis: state.twsAxis
|
|
91
|
+
}
|
|
92
|
+
: { available: false, reason: 'no_active_polar' },
|
|
93
|
+
onChange: callback => { changeListeners.add(callback); return () => changeListeners.delete(callback) }
|
|
94
|
+
}
|
|
95
|
+
const service = {
|
|
96
|
+
apiVersion: 1,
|
|
97
|
+
available: true,
|
|
98
|
+
activePolar: () => handle,
|
|
99
|
+
polar: () => handle
|
|
100
|
+
}
|
|
101
|
+
const notifyChange = (change = { type: 'selection', ids: [] }) => {
|
|
102
|
+
for (const callback of [...changeListeners]) callback(change)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const app = {
|
|
106
|
+
debug: () => {}, error: () => {},
|
|
107
|
+
setPluginStatus: () => {}, setPluginError: () => {},
|
|
108
|
+
savePluginOptions: (_o, cb) => cb(null),
|
|
109
|
+
getDataDirPath: () => '.',
|
|
110
|
+
handleMessage: (_id, delta) => {
|
|
111
|
+
for (const update of delta.updates) {
|
|
112
|
+
for (const { path, value } of update.values || []) {
|
|
113
|
+
bus.set(path, value)
|
|
114
|
+
writes.push({ path, value })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
onPropertyValues: (name, callback) => {
|
|
119
|
+
if (name === 'polarManagement' && withService) {
|
|
120
|
+
callback([{ setter: 'signalk-polar-management', value: service }])
|
|
121
|
+
}
|
|
122
|
+
return () => {}
|
|
123
|
+
},
|
|
124
|
+
subscriptionmanager: {
|
|
125
|
+
subscribe: (msg, unsubscribes, _err, callback) => {
|
|
126
|
+
for (const { path } of msg.subscribe) {
|
|
127
|
+
if (!subscribers.has(path)) subscribers.set(path, new Set())
|
|
128
|
+
subscribers.get(path).add(callback)
|
|
129
|
+
}
|
|
130
|
+
unsubscribes.push(() => {
|
|
131
|
+
for (const { path } of msg.subscribe) subscribers.get(path).delete(callback)
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
delete require.cache[require.resolve('../plugin/index.js')]
|
|
137
|
+
const plugin = wrapPluginRoutes(require('../plugin/index.js')(app))
|
|
138
|
+
const routes = {}
|
|
139
|
+
plugin.registerWithRouter({
|
|
140
|
+
use: () => {}, get: (p, cb) => { routes[p] = cb },
|
|
141
|
+
put: (p, cb) => { routes[`PUT ${p}`] = cb }, post: () => {}, delete: () => {}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const deliver = (path, value) => {
|
|
145
|
+
for (const callback of subscribers.get(path) || []) {
|
|
146
|
+
callback({ updates: [{ $source: 'test', values: [{ path, value }] }] })
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
app, plugin, bus, writes, calls, routes, deliver, state, notifyChange,
|
|
151
|
+
async start(options = ALL_OUTPUTS) {
|
|
152
|
+
plugin.start(options)
|
|
153
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
154
|
+
},
|
|
155
|
+
sail({ tws = TWS, twa, bsp, hdg }) {
|
|
156
|
+
if (bsp !== undefined) deliver('navigation.speedThroughWater', bsp)
|
|
157
|
+
if (hdg !== undefined) deliver('navigation.headingTrue', hdg)
|
|
158
|
+
deliver('environment.wind.speedTrue', tws)
|
|
159
|
+
deliver('environment.wind.angleTrueWater', twa)
|
|
160
|
+
},
|
|
161
|
+
status() {
|
|
162
|
+
let result
|
|
163
|
+
routes['/status']({}, { json: value => { result = value } })
|
|
164
|
+
return result
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
describe('computeAndSend output validity', () => {
|
|
170
|
+
let h
|
|
171
|
+
|
|
172
|
+
beforeEach(() => { h = harness() })
|
|
173
|
+
afterEach(() => {
|
|
174
|
+
try { h.plugin.stop() } catch (_) {}
|
|
175
|
+
delete require.cache[require.resolve('../plugin/index.js')]
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('publishes every output while sailing normally upwind on starboard', async () => {
|
|
179
|
+
await h.start()
|
|
180
|
+
const twa = rad(50)
|
|
181
|
+
const bsp = 6 * KNOTS
|
|
182
|
+
h.sail({ twa, bsp, hdg: 1 })
|
|
183
|
+
const polarSpeed = reference.speedAt({ tws: TWS, twa }).value
|
|
184
|
+
const vmg = bsp * Math.cos(twa)
|
|
185
|
+
close(h.bus.get('performance.polarSpeed'), polarSpeed, 'polarSpeed')
|
|
186
|
+
close(h.bus.get('performance.polarSpeedRatio'), bsp / polarSpeed, 'polarSpeedRatio')
|
|
187
|
+
close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed')
|
|
188
|
+
close(h.bus.get('performance.targetAngle'), targets.beat.twa, 'targetAngle')
|
|
189
|
+
close(h.bus.get('performance.targetVelocityMadeGood'), targets.beat.vmg, 'targetVMG')
|
|
190
|
+
close(h.bus.get('performance.velocityMadeGood'), vmg, 'VMG')
|
|
191
|
+
close(h.bus.get('performance.polarVelocityMadeGood'), targets.beat.vmg, 'polarVMG')
|
|
192
|
+
close(h.bus.get('performance.polarVelocityMadeGoodRatio'), vmg / targets.beat.vmg, 'VMG ratio')
|
|
193
|
+
close(h.bus.get('performance.beatAngle'), targets.beat.twa, 'beatAngle')
|
|
194
|
+
close(h.bus.get('performance.gybeAngle'), targets.run.twa, 'gybeAngle')
|
|
195
|
+
close(h.bus.get('performance.optimumWindAngle'), twa - targets.beat.twa, 'optimumWindAngle')
|
|
196
|
+
close(h.bus.get('performance.tackTrue'), 1 + targets.beat.twa, 'tackTrue')
|
|
197
|
+
close(h.bus.get('environment.wind.angleTrueWaterDamped'), twa, 'damped TWA')
|
|
198
|
+
close(h.bus.get('performance.boatSpeedDamped'), bsp, 'damped BSP')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('in irons clears polar values, keeps steering advice and VMG live, and restores them on leaving', async () => {
|
|
202
|
+
await h.start()
|
|
203
|
+
const bsp = 4 * KNOTS
|
|
204
|
+
h.sail({ twa: rad(50), bsp, hdg: 1 })
|
|
205
|
+
assert.ok(h.bus.get('performance.polarSpeed') > 0)
|
|
206
|
+
|
|
207
|
+
const ironsTwa = rad(20)
|
|
208
|
+
assert.equal(reference.speedAt({ tws: TWS, twa: ironsTwa }).state.twa, 'in_irons')
|
|
209
|
+
h.sail({ twa: ironsTwa, bsp, hdg: 1 })
|
|
210
|
+
for (const path of ['performance.polarSpeed', 'performance.polarSpeedRatio', 'performance.polarVelocityMadeGood',
|
|
211
|
+
'performance.polarVelocityMadeGoodRatio', 'performance.beatAngle', 'performance.gybeAngle',
|
|
212
|
+
'performance.beatAngleVelocityMadeGood', 'performance.gybeAngleVelocityMadeGood',
|
|
213
|
+
'performance.maxSpeed', 'performance.maxSpeedAngle']) {
|
|
214
|
+
assert.equal(h.bus.get(path), null, path)
|
|
215
|
+
}
|
|
216
|
+
close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed in irons')
|
|
217
|
+
close(h.bus.get('performance.targetVelocityMadeGood'), targets.beat.vmg, 'targetVMG in irons')
|
|
218
|
+
close(h.bus.get('performance.targetAngle'), targets.beat.twa, 'targetAngle in irons')
|
|
219
|
+
close(h.bus.get('performance.optimumWindAngle'), ironsTwa - targets.beat.twa, 'optimumWindAngle in irons')
|
|
220
|
+
close(h.bus.get('performance.tackTrue'), 1 + targets.beat.twa, 'tackTrue in irons')
|
|
221
|
+
close(h.bus.get('performance.velocityMadeGood'), bsp * Math.cos(ironsTwa), 'VMG in irons')
|
|
222
|
+
|
|
223
|
+
// VMG must keep updating while in irons, not freeze
|
|
224
|
+
h.sail({ twa: ironsTwa, bsp: 2 * KNOTS })
|
|
225
|
+
close(h.bus.get('performance.velocityMadeGood'), 2 * KNOTS * Math.cos(ironsTwa), 'VMG updates in irons')
|
|
226
|
+
|
|
227
|
+
const status = h.status()
|
|
228
|
+
assert.equal(status.outputs['performance.polarSpeed'], null)
|
|
229
|
+
assert.ok(Math.abs(status.outputs['performance.targetSpeed'] - targets.beat.vmg / Math.cos(targets.beat.twa)) < 0.001)
|
|
230
|
+
assert.ok(Math.abs(status.outputs['performance.targetVelocityMadeGood'] - targets.beat.vmg) < 0.001)
|
|
231
|
+
|
|
232
|
+
// Pinching is not in irons, so nothing is cleared
|
|
233
|
+
const pinchTwa = rad(38)
|
|
234
|
+
assert.equal(reference.speedAt({ tws: TWS, twa: pinchTwa }).state.twa, 'pinching')
|
|
235
|
+
h.sail({ twa: pinchTwa, bsp })
|
|
236
|
+
close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed pinching')
|
|
237
|
+
close(h.bus.get('performance.targetVelocityMadeGood'), targets.beat.vmg, 'targetVMG pinching')
|
|
238
|
+
close(h.bus.get('performance.polarVelocityMadeGood'), targets.beat.vmg, 'polarVMG pinching')
|
|
239
|
+
close(h.bus.get('performance.polarVelocityMadeGoodRatio'), bsp * Math.cos(pinchTwa) / targets.beat.vmg, 'ratio pinching')
|
|
240
|
+
close(h.bus.get('performance.beatAngle'), targets.beat.twa, 'beatAngle pinching')
|
|
241
|
+
assert.ok(h.bus.get('performance.maxSpeed') > 0)
|
|
242
|
+
|
|
243
|
+
h.sail({ twa: rad(50), bsp })
|
|
244
|
+
const polarSpeed = reference.speedAt({ tws: TWS, twa: rad(50) }).value
|
|
245
|
+
close(h.bus.get('performance.polarSpeed'), polarSpeed, 'polarSpeed after irons')
|
|
246
|
+
close(h.bus.get('performance.polarSpeedRatio'), bsp / polarSpeed, 'ratio after irons')
|
|
247
|
+
close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed after irons')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('clears unavailable outputs only once per transition', async () => {
|
|
251
|
+
await h.start()
|
|
252
|
+
h.sail({ twa: rad(50), bsp: 4 * KNOTS })
|
|
253
|
+
h.sail({ twa: rad(20), bsp: 4 * KNOTS })
|
|
254
|
+
h.sail({ twa: rad(21), bsp: 4 * KNOTS })
|
|
255
|
+
const polarSpeedNulls = h.writes.filter(w => w.path === 'performance.polarSpeed' && w.value === null)
|
|
256
|
+
assert.equal(polarSpeedNulls.length, 1)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('mirrors signed angles and keeps magnitudes on port tack', async () => {
|
|
260
|
+
await h.start()
|
|
261
|
+
const twa = rad(-50)
|
|
262
|
+
const bsp = 6 * KNOTS
|
|
263
|
+
h.sail({ twa, bsp, hdg: 1 })
|
|
264
|
+
close(h.bus.get('performance.targetAngle'), -targets.beat.twa, 'targetAngle port')
|
|
265
|
+
close(h.bus.get('performance.beatAngle'), -targets.beat.twa, 'beatAngle port')
|
|
266
|
+
close(h.bus.get('performance.optimumWindAngle'), -(rad(50) - targets.beat.twa), 'optimumWindAngle port')
|
|
267
|
+
close(h.bus.get('performance.tackTrue'), 1 - targets.beat.twa, 'tackTrue port')
|
|
268
|
+
close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed port')
|
|
269
|
+
close(h.bus.get('performance.velocityMadeGood'), bsp * Math.cos(rad(50)), 'VMG port')
|
|
270
|
+
|
|
271
|
+
h.sail({ twa: rad(-20), bsp })
|
|
272
|
+
assert.equal(h.bus.get('performance.polarSpeed'), null)
|
|
273
|
+
close(h.bus.get('performance.targetSpeed'), targets.beat.vmg / Math.cos(targets.beat.twa), 'targetSpeed port irons')
|
|
274
|
+
close(h.bus.get('performance.targetVelocityMadeGood'), targets.beat.vmg, 'targetVMG port irons')
|
|
275
|
+
assert.equal(h.bus.get('performance.beatAngle'), null)
|
|
276
|
+
close(h.bus.get('performance.targetAngle'), -targets.beat.twa, 'targetAngle port irons')
|
|
277
|
+
close(h.bus.get('performance.optimumWindAngle'), -(rad(20) - targets.beat.twa), 'optimumWindAngle port irons')
|
|
278
|
+
close(h.bus.get('performance.tackTrue'), 1 - targets.beat.twa, 'tackTrue port irons')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('selects run targets when sailing downwind', async () => {
|
|
282
|
+
await h.start()
|
|
283
|
+
const twa = rad(150)
|
|
284
|
+
const bsp = 6 * KNOTS
|
|
285
|
+
h.sail({ twa, bsp, hdg: 1 })
|
|
286
|
+
const vmg = bsp * Math.cos(twa)
|
|
287
|
+
close(h.bus.get('performance.targetAngle'), targets.run.twa, 'run targetAngle')
|
|
288
|
+
close(h.bus.get('performance.targetVelocityMadeGood'), targets.run.vmg, 'run targetVMG')
|
|
289
|
+
close(h.bus.get('performance.targetSpeed'), targets.run.vmg / Math.abs(Math.cos(targets.run.twa)), 'run targetSpeed')
|
|
290
|
+
close(h.bus.get('performance.velocityMadeGood'), vmg, 'downwind VMG is negative')
|
|
291
|
+
assert.ok(vmg < 0)
|
|
292
|
+
close(h.bus.get('performance.polarVelocityMadeGoodRatio'), Math.abs(vmg) / targets.run.vmg, 'downwind ratio')
|
|
293
|
+
close(h.bus.get('performance.optimumWindAngle'), twa - targets.run.twa, 'downwind optimumWindAngle')
|
|
294
|
+
close(h.bus.get('performance.tackTrue'), 1 + targets.run.twa, 'downwind tackTrue')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('clears BSP-dependent outputs when boat speed is missing', async () => {
|
|
298
|
+
await h.start()
|
|
299
|
+
h.sail({ twa: rad(50), bsp: 6 * KNOTS })
|
|
300
|
+
assert.ok(h.bus.get('performance.velocityMadeGood') > 0)
|
|
301
|
+
h.sail({ twa: rad(50), bsp: null })
|
|
302
|
+
for (const path of ['performance.velocityMadeGood', 'performance.polarVelocityMadeGoodRatio',
|
|
303
|
+
'performance.polarSpeedRatio', 'performance.boatSpeedDamped']) {
|
|
304
|
+
assert.equal(h.bus.get(path), null, path)
|
|
305
|
+
}
|
|
306
|
+
assert.ok(h.bus.get('performance.polarSpeed') > 0)
|
|
307
|
+
assert.ok(h.bus.get('performance.targetSpeed') > 0)
|
|
308
|
+
close(h.bus.get('performance.polarVelocityMadeGood'), targets.beat.vmg, 'polarVMG without BSP')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('clears target-dependent outputs when polar targets are unavailable', async () => {
|
|
312
|
+
h = harness(DOWNWIND_ONLY)
|
|
313
|
+
await h.start()
|
|
314
|
+
const noBeat = Polar.fromTable(DOWNWIND_ONLY).targetsAt({ tws: TWS }).value
|
|
315
|
+
assert.equal(noBeat.beat, null)
|
|
316
|
+
|
|
317
|
+
h.sail({ twa: rad(150), bsp: 6 * KNOTS, hdg: 1 })
|
|
318
|
+
assert.ok(h.bus.get('performance.targetSpeed') > 0)
|
|
319
|
+
assert.ok(h.bus.get('performance.polarVelocityMadeGood') > 0)
|
|
320
|
+
|
|
321
|
+
h.sail({ twa: rad(60), bsp: 6 * KNOTS, hdg: 1 })
|
|
322
|
+
for (const path of ['performance.targetSpeed', 'performance.targetAngle',
|
|
323
|
+
'performance.targetVelocityMadeGood', 'performance.polarVelocityMadeGood',
|
|
324
|
+
'performance.polarVelocityMadeGoodRatio', 'performance.beatAngle',
|
|
325
|
+
'performance.beatAngleVelocityMadeGood', 'performance.optimumWindAngle', 'performance.tackTrue']) {
|
|
326
|
+
assert.equal(h.bus.get(path), null, path)
|
|
327
|
+
}
|
|
328
|
+
close(h.bus.get('performance.velocityMadeGood'), 6 * KNOTS * Math.cos(rad(60)), 'actual VMG without targets')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('does not publish disabled outputs and clears outputs toggled off or on stop', async () => {
|
|
332
|
+
await h.start({ settingsVersion: 2, smootherType: 'None', polarSpeed: true })
|
|
333
|
+
h.sail({ twa: rad(50), bsp: 4 * KNOTS })
|
|
334
|
+
const published = new Set(h.writes.map(w => w.path))
|
|
335
|
+
assert.deepEqual([...published].sort(),
|
|
336
|
+
['performance.polarSpeed', 'performance.polarSpeedRatio', 'performance.targetSpeed'])
|
|
337
|
+
assert.ok(h.bus.get('performance.targetSpeed') > 0)
|
|
338
|
+
|
|
339
|
+
h.routes['PUT /settings']({ body: { polarSpeed: false } }, { json: () => {} })
|
|
340
|
+
assert.equal(h.bus.get('performance.targetSpeed'), null)
|
|
341
|
+
const before = h.writes.length
|
|
342
|
+
h.sail({ twa: rad(50), bsp: 4 * KNOTS })
|
|
343
|
+
assert.equal(h.writes.length, before, 'disabled outputs must not be written')
|
|
344
|
+
|
|
345
|
+
h.routes['PUT /settings']({ body: { polarSpeed: true } }, { json: () => {} })
|
|
346
|
+
h.sail({ twa: rad(50), bsp: 4 * KNOTS })
|
|
347
|
+
assert.ok(h.bus.get('performance.polarSpeed') > 0)
|
|
348
|
+
h.plugin.stop()
|
|
349
|
+
for (const path of ['performance.polarSpeed', 'performance.targetSpeed', 'performance.polarSpeedRatio']) {
|
|
350
|
+
assert.equal(h.bus.get(path), null, path)
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('leaves the performance factor and sea state to the management handle', async () => {
|
|
355
|
+
await h.start()
|
|
356
|
+
h.sail({ twa: rad(50), bsp: 6 * KNOTS, hdg: 1 })
|
|
357
|
+
assert.ok(h.calls.length > 0, 'calculations must go through the service handle')
|
|
358
|
+
for (const { method, options } of h.calls) {
|
|
359
|
+
assert.ok(!('performanceFactor' in options), `${method} must not supply performanceFactor`)
|
|
360
|
+
assert.ok(!('seaState' in options), `${method} must not supply seaState`)
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('publishes nothing when the calculation service is not available', async () => {
|
|
365
|
+
h = harness(TABLE, { withService: false })
|
|
366
|
+
await h.start()
|
|
367
|
+
h.sail({ twa: rad(50), bsp: 6 * KNOTS, hdg: 1 })
|
|
368
|
+
assert.equal(h.writes.length, 0)
|
|
369
|
+
assert.equal(h.calls.length, 0)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('serves the TWS axis and metadata from the handle description, following selection changes', async () => {
|
|
373
|
+
await h.start()
|
|
374
|
+
let result
|
|
375
|
+
const res = { json: v => { result = v }, status: () => res }
|
|
376
|
+
|
|
377
|
+
h.routes['/polar/axes/tws']({}, res)
|
|
378
|
+
assert.deepEqual(result, TABLE.axes.tws.map(tws => parseFloat(tws.toFixed(4))))
|
|
379
|
+
|
|
380
|
+
h.routes['/meta']({}, res)
|
|
381
|
+
assert.equal(result.activePolar.id, 'ref')
|
|
382
|
+
assert.equal(result.activePolar.name, 'Reference Polar')
|
|
383
|
+
assert.equal(result.activePolar.sailplanId, null)
|
|
384
|
+
assert.equal(result.performanceFactor, 1)
|
|
385
|
+
|
|
386
|
+
// A selection change (e.g. picking a v2 sailplan) is reflected after the
|
|
387
|
+
// onChange notification, with no resource lookups or polars.* deltas.
|
|
388
|
+
h.state.sailplanId = 'jib'
|
|
389
|
+
h.state.twsAxis = [6, 12, 18]
|
|
390
|
+
h.state.performanceFactor = 0.9
|
|
391
|
+
h.notifyChange({ type: 'selection', ids: [] })
|
|
392
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
393
|
+
|
|
394
|
+
h.routes['/polar/axes/tws']({}, res)
|
|
395
|
+
assert.deepEqual(result, [6, 12, 18])
|
|
396
|
+
h.routes['/meta']({}, res)
|
|
397
|
+
assert.equal(result.activePolar.sailplanId, 'jib')
|
|
398
|
+
assert.equal(result.performanceFactor, 0.9)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('clears outputs and polar endpoints when the active polar is deselected', async () => {
|
|
402
|
+
await h.start()
|
|
403
|
+
h.sail({ twa: rad(50), bsp: 6 * KNOTS })
|
|
404
|
+
assert.ok(h.bus.get('performance.polarSpeed') > 0)
|
|
405
|
+
|
|
406
|
+
h.state.active = false
|
|
407
|
+
h.notifyChange({ type: 'selection', ids: [] })
|
|
408
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
409
|
+
|
|
410
|
+
assert.equal(h.bus.get('performance.polarSpeed'), null)
|
|
411
|
+
let result
|
|
412
|
+
const res = { json: v => { result = v }, status: code => ({ json: v => { result = { code, ...v } } }) }
|
|
413
|
+
h.routes['/polar/axes/tws']({}, res)
|
|
414
|
+
assert.equal(result.code, 404)
|
|
415
|
+
h.routes['/meta']({}, res)
|
|
416
|
+
assert.equal(result.activePolar, null)
|
|
417
|
+
assert.equal(result.performanceFactor, null)
|
|
418
|
+
})
|
|
419
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
function withRoutePolicies(router) {
|
|
4
|
+
if (router.routePolicies instanceof Map) return router
|
|
5
|
+
|
|
6
|
+
const policies = new Map()
|
|
7
|
+
const methods = ['get', 'post', 'put', 'patch', 'delete']
|
|
8
|
+
const originalMethods = new Map()
|
|
9
|
+
for (const method of methods) {
|
|
10
|
+
if (typeof router[method] !== 'function') continue
|
|
11
|
+
const original = router[method]
|
|
12
|
+
originalMethods.set(method, original)
|
|
13
|
+
router[method] = (route, ...handlers) => {
|
|
14
|
+
policies.set(`${method.toUpperCase()} ${route}`, 'admin')
|
|
15
|
+
return original.call(router, route, ...handlers)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
router.access = (level) => {
|
|
20
|
+
if (level !== 'readonly' && level !== 'readwrite') {
|
|
21
|
+
throw new Error(`Unsupported route access level: ${level}`)
|
|
22
|
+
}
|
|
23
|
+
const scopedRouter = {}
|
|
24
|
+
for (const [method, original] of originalMethods) {
|
|
25
|
+
scopedRouter[method] = (route, ...handlers) => {
|
|
26
|
+
policies.set(`${method.toUpperCase()} ${route}`, level)
|
|
27
|
+
return original.call(router, route, ...handlers)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return scopedRouter
|
|
31
|
+
}
|
|
32
|
+
router.routePolicies = policies
|
|
33
|
+
return router
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function wrapPluginRoutes(plugin) {
|
|
37
|
+
const registerWithRouter = plugin.registerWithRouter
|
|
38
|
+
plugin.registerWithRouter = (router) => registerWithRouter(withRoutePolicies(router))
|
|
39
|
+
return plugin
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { withRoutePolicies, wrapPluginRoutes }
|