signalk-polar-performance-plugin 2.1.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 +20 -1
- package/README.md +14 -11
- package/docs/developer-reference.md +32 -13
- package/package.json +6 -9
- package/plugin/index.js +105 -117
- package/public/app.js +11 -1
- package/test/lifecycle.test.js +1 -0
- package/test/outputs.test.js +126 -11
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,26 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Unreleased
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Keep `performance.targetSpeed` and `performance.targetVelocityMadeGood` published in irons using the selected beat or run target; polar values and ratios based on the boat's current angle are still cleared.
|
|
11
|
+
|
|
12
|
+
## [2.2.0] - 2026-11-02
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Polar calculations now come directly from signalk-polar-management's live calculation service: the plugin always follows the active polar, the sailplan selection and the performance factor chosen in the Polar Management webapp — including v2 polar sets, which could not be calculated before. The active polar's details shown in the webapp come from the same service. signalk-polar-management (with the calculation service, >=1.5.0) is now required for all polar outputs; the `polar-math` library is no longer bundled.
|
|
16
|
+
- The overview now shows the selected sailplan for v2 polar sets, and the active polar's details refresh automatically after selection changes or edits in the management webapp, without reloading the page.
|
|
17
|
+
- Smoothed-input outputs (damped TWA and boat speed) are now published even while no polar is active; polar-derived outputs are still cleared.
|
|
18
|
+
- The webapp now distinguishes between a missing calculation service (management plugin not installed or disabled) and no active polar being selected.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- The polar selected in Polar Management is picked up automatically after a Signal K server restart, without reselecting it ([#41](https://github.com/htool/signalk-polar-performance-plugin/issues/41)).
|
|
22
|
+
|
|
23
|
+
### Known limitations
|
|
24
|
+
- Sea state (wave) correction is not applied: the Signal K specification defines no paths for the required wave height, period and direction inputs. It can be added once such paths exist.
|
|
25
|
+
|
|
26
|
+
## [2.1.0] - 2026-10-27
|
|
8
27
|
|
|
9
28
|
### Changed
|
|
10
29
|
- Requires signalk-server >=2.35.0 for the upstream metadata-first subscription fix ([SignalK/signalk-server#3216](https://github.com/SignalK/signalk-server/pull/3216)).
|
package/README.md
CHANGED
|
@@ -7,8 +7,8 @@ Polar Performance calculates live sailing performance metrics — target boat sp
|
|
|
7
7
|
**Polar Performance works together with a polar provider plugin** (such as [signalk-polar-management](https://github.com/Asw1n/signalk-polar-management)).
|
|
8
8
|
|
|
9
9
|
Starting in version 2.0.0, polar file storage, file format conversion, ORC imports, and polar selection are decoupled from this compute plugin:
|
|
10
|
-
- **Polar Provider (`signalk-polar-management`):** Stores polar files, imports ORC/CSV/text polar files, manages polar metadata,
|
|
11
|
-
- **Performance Plugin (`signalk-polar-performance-plugin`):** Subscribes to
|
|
10
|
+
- **Polar Provider (`signalk-polar-management`):** Stores polar files, imports ORC/CSV/text polar files, manages polar metadata, publishes the selected active polar to Signal K (`polars.activePolar`) along with the shared performance factor (`polars.performanceFactor`), and provides an in-process polar calculation service to other plugins.
|
|
11
|
+
- **Performance Plugin (`signalk-polar-performance-plugin`):** Subscribes to boat instrument data (TWS, TWA, STW/SOG), calculates real-time performance values through the management plugin's live *active polar* calculation handle, and publishes the resulting performance metrics back to the Signal K delta stream. The handle automatically follows the active polar, the sailplan selection, and the performance factor chosen in the Polar Management webapp — including v2 polar sets.
|
|
12
12
|
|
|
13
13
|
When a polar lookup cannot be completed or required instrument data is missing/stale, output paths are cleanly set to `null` and the webapp status indicates the exact cause.
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ When a polar lookup cannot be completed or required instrument data is missing/s
|
|
|
16
16
|
|
|
17
17
|
## Installation
|
|
18
18
|
|
|
19
|
-
Requires signalk-server >=2.35.0 for reliable metadata-first input subscriptions. Idle/stale warnings do not trigger resubscriptions; existing subscriptions receive data when the source resumes.
|
|
19
|
+
Requires signalk-server >=2.35.0 for reliable metadata-first input subscriptions. Idle/stale warnings do not trigger resubscriptions; existing subscriptions receive data when the source resumes. Requires signalk-polar-management >=1.5.0, which provides the in-process polar calculation service.
|
|
20
20
|
|
|
21
21
|
Install both plugins from the Signal K App Store, or via npm:
|
|
22
22
|
|
|
@@ -25,7 +25,7 @@ cd ~/.signalk
|
|
|
25
25
|
npm install signalk-polar-performance-plugin signalk-polar-management
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
Then restart Signal K and enable both plugins in **Server → Plugin Config**.
|
|
28
|
+
Then restart Signal K and enable both plugins in **Server → Plugin Config**. This plugin requires signalk-polar-management for all polar calculations: without it (or with it disabled), Polar Performance runs but publishes nothing and reports the missing calculation service in its status.
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
@@ -68,7 +68,7 @@ Most configuration is done through the webapp.
|
|
|
68
68
|
|
|
69
69
|
### Active polar and performance factor
|
|
70
70
|
|
|
71
|
-
These are **not** configured here. Select the active polar and set the performance factor in the polar management webapp (
|
|
71
|
+
These are **not** configured here. Select the active polar (and, for v2 polar sets, a sailplan) and set the performance factor in the polar management webapp (signalk-polar-management); this plugin's calculation handle follows those selections automatically on every calculation. If no polar is active, the plugin runs but publishes nothing; any previously published values are nullified immediately.
|
|
72
72
|
|
|
73
73
|
### Smoother
|
|
74
74
|
|
|
@@ -175,9 +175,10 @@ The polar defines two zones above the beat angle:
|
|
|
175
175
|
|------|---------|----------|
|
|
176
176
|
| Measured | `velocityMadeGood`, `angleTrueWaterDamped`, `boatSpeedDamped` | Published |
|
|
177
177
|
| Steering advice | `targetAngle`, `optimumWindAngle`, `tackTrue` | Published, so you can see how far to bear away |
|
|
178
|
-
|
|
|
178
|
+
| Selected target performance | `targetSpeed`, `targetVelocityMadeGood` | Published from the selected beat or run target |
|
|
179
|
+
| Polar values and ratios based on them | `polarSpeed`, `polarSpeedRatio`, `polarVelocityMadeGood`, `polarVelocityMadeGoodRatio`, `beatAngle`, `gybeAngle`, `beatAngleVelocityMadeGood`, `gybeAngleVelocityMadeGood`, `maxSpeed`, `maxSpeedAngle` | Set to `null` |
|
|
179
180
|
|
|
180
|
-
|
|
181
|
+
The selected target speed and VMG remain available in irons because they describe the optimal beat or run target for the current TWS, rather than the boat's current angle. Clearing values based on the boat's current polar state prevents spikes in performance ratios while tacking. Through a tack the boat carries its speed head-to-wind while the polar speed falls towards zero, which would briefly drive the ratios to meaningless values. As soon as you bear away out of irons, all other polar values return on the next wind update.
|
|
181
182
|
|
|
182
183
|
A value is set to `null` once when it becomes unavailable and is not repeated until it returns.
|
|
183
184
|
|
|
@@ -185,7 +186,7 @@ A value is set to `null` once when it becomes unavailable and is not repeated un
|
|
|
185
186
|
|
|
186
187
|
## Managing polars
|
|
187
188
|
|
|
188
|
-
Polar storage, import (ORC, text formats), and active-polar/performance-factor selection are handled by
|
|
189
|
+
Polar storage, import (ORC, text formats), and active-polar/sailplan/performance-factor selection are handled by [signalk-polar-management](https://github.com/Asw1n/signalk-polar-management), which also performs all polar calculations for this plugin through its in-process calculation service. See that plugin's documentation for import formats, the ORC certificate search, and extrapolation behaviour of the underlying `polar-math` library.
|
|
189
190
|
|
|
190
191
|
---
|
|
191
192
|
|
|
@@ -246,7 +247,8 @@ The webapp shows warnings whenever something prevents accurate calculation:
|
|
|
246
247
|
| *True wind speed — no data* | `environment.wind.speedTrue` is not arriving from Signal K. |
|
|
247
248
|
| *True wind angle — no data* | `environment.wind.angleTrueWater` is not arriving. |
|
|
248
249
|
| *Boat speed — no data* | `navigation.speedThroughWater` (or SOG) is not arriving. |
|
|
249
|
-
| *No active polar* | No
|
|
250
|
+
| *No active polar* | No active polar is selected. Select one in the polar management webapp. |
|
|
251
|
+
| *Polar calculation service not available* | signalk-polar-management is not installed, not enabled, or too old to provide the in-process calculation service. |
|
|
250
252
|
| *Sailing in irons* | TWA is below the in-irons cutoff. Polar values and the ratios based on them are set to `null`; measured values and steering advice keep updating. See [Output handling when pinching and in irons](#output-handling-when-pinching-and-in-irons). |
|
|
251
253
|
| *Pinching* | TWA is between the in-irons cutoff and the beat angle. All outputs are published; values come from the extrapolated beat zone. |
|
|
252
254
|
| *Extrapolated beyond run angle* | TWA is deeper than the run angle. Values come from the cosine-VMG extrapolation model. |
|
|
@@ -270,11 +272,12 @@ If you want to consume this plugin's live performance queries, use the developer
|
|
|
270
272
|
|
|
271
273
|
- [Developer reference](docs/developer-reference.md) for the plugin REST API.
|
|
272
274
|
- [openApi.json](openApi.json) for the authoritative machine-readable contract.
|
|
273
|
-
- For the canonical `polarTable`
|
|
275
|
+
- For the canonical `polarTable` / `polarSet` structures, polar storage, import, and the in-process calculation service, see [signalk-polar-management](https://github.com/Asw1n/signalk-polar-management) and the [polar-format](https://github.com/Asw1n/polar-format) / [polar-math](https://github.com/Asw1n/polar-math) packages it depends on.
|
|
274
276
|
|
|
275
277
|
---
|
|
276
278
|
|
|
277
279
|
## Known limitations
|
|
278
280
|
|
|
279
281
|
- Heel angle is not taken into account in the polar lookup. Most ORC polars are upright polars.
|
|
280
|
-
- Requires
|
|
282
|
+
- Requires `signalk-polar-management` to be installed and running: it stores and imports polars, owns the active selection, and performs all polar calculations for this plugin.
|
|
283
|
+
- Sea state (wave) correction is not applied, even when the active v2 polar set carries a wave-correction table. The Signal K specification currently defines no paths for the required inputs (significant wave height, wave period, wave direction), so this plugin has nothing to feed the correction with. Support can be added once such paths exist in the specification.
|
|
@@ -1,25 +1,44 @@
|
|
|
1
1
|
# Polar Performance Developer Reference
|
|
2
2
|
|
|
3
3
|
This document covers the REST API this plugin still exposes. It no longer stores, imports,
|
|
4
|
-
or manages polar files — that is handled by
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
or manages polar files — that is handled by
|
|
5
|
+
[signalk-polar-management](https://github.com/Asw1n/signalk-polar-management), which also
|
|
6
|
+
provides the in-process polar calculation service this plugin uses. For the canonical
|
|
7
|
+
`polarTable` / `polarSet` formats and the management API, see that plugin's documentation
|
|
8
|
+
and the [polar-format](https://github.com/Asw1n/polar-format) package; the underlying
|
|
9
|
+
calculation engine, [polar-math](https://github.com/Asw1n/polar-math), is a dependency of
|
|
10
|
+
the management plugin, not of this one.
|
|
9
11
|
|
|
10
12
|
For installation, day-to-day use, and webapp workflow, start with [../README.md](../README.md).
|
|
11
13
|
|
|
12
14
|
## How the active polar is resolved
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
All polar calculations and all active-polar metadata go through the in-process
|
|
17
|
+
calculation service announced by signalk-polar-management via the `polarManagement`
|
|
18
|
+
PropertyValue. At startup this plugin subscribes with
|
|
19
|
+
`app.onPropertyValues('polarManagement', …)` and acquires an **active-polar handle**
|
|
20
|
+
(`service.activePolar()`). Every calculation call on that handle automatically follows
|
|
21
|
+
the shared active polar, the shared sailplan selection (for v2 polar sets), the shared
|
|
22
|
+
performance factor, and the shared wave-correction toggle — this plugin applies none of
|
|
23
|
+
them itself. When the management plugin is missing or stopped, calculations are
|
|
24
|
+
unavailable and the plugin reports this in its status; results then carry
|
|
25
|
+
`state.available: false` with a reason such as `no_active_polar`.
|
|
15
26
|
|
|
16
|
-
|
|
17
|
-
|
|
27
|
+
Descriptive metadata comes from the same handle: `handle.describe()` returns the active
|
|
28
|
+
document's name, boat details, selected sailplan, the applied performance factor, and the
|
|
29
|
+
selected table's TWS axis. The plugin refreshes this snapshot on `handle.onChange`
|
|
30
|
+
notifications and serves it through `/meta` and `/polar/axes/tws`. It does not subscribe
|
|
31
|
+
to the `polars.*` Signal K paths and performs no resource lookups; those paths remain
|
|
32
|
+
published by signalk-polar-management for other consumers.
|
|
18
33
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
### Sea state / wave correction
|
|
35
|
+
|
|
36
|
+
v2 polar sets can carry a wave-correction table, and active handles accept per-call
|
|
37
|
+
`seaState` input. This plugin does not supply `seaState`, because the Signal K
|
|
38
|
+
specification currently defines no paths for the required inputs (significant wave height,
|
|
39
|
+
wave period, wave direction). Wave correction therefore has no effect on this plugin's
|
|
40
|
+
outputs. If the specification gains such paths, sea state can be wired into the existing
|
|
41
|
+
handle calls without restructuring.
|
|
23
42
|
|
|
24
43
|
## REST API
|
|
25
44
|
|
|
@@ -35,7 +54,7 @@ fields are also available to read-only users.
|
|
|
35
54
|
|--------|------|-------------|--------|
|
|
36
55
|
| `GET` | `/live` | Current smoothed TWS, TWA, BSP, polar speed, and polar state. | Read-only |
|
|
37
56
|
| `GET` | `/status` | Full snapshot: raw inputs, smoothed inputs, and all enabled output values. | Read-only |
|
|
38
|
-
| `GET` | `/meta` | Display unit metadata for all fields, plus a read-only summary of the active polar (name, boatType, sailnumber, year, source) and performance factor. | Read-only |
|
|
57
|
+
| `GET` | `/meta` | Display unit metadata for all fields, plus a read-only summary of the active polar (name, boatType, sailnumber, year, source, notes, selected sailplan) and the applied performance factor. | Read-only |
|
|
39
58
|
| `GET` | `/settings` | Current plugin settings (smoother, output toggles, speed source — no polar selection). | Administrator |
|
|
40
59
|
| `PUT` | `/settings` | Update settings. Body: JSON object with changed keys only. | Administrator |
|
|
41
60
|
| `GET` | `/polar/axes/tws` | TWS axis of the currently active polar. 404 if none is active. | Read-only |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-polar-performance-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Calculates live sailing performance from the active polar selected in a polar management plugin (e.g. signalk-polar-management).",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -26,16 +26,10 @@
|
|
|
26
26
|
"prepublishOnly": "npm test"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"polar-math": "^1.1.1",
|
|
30
29
|
"signalkutilities": "^3.2.0"
|
|
31
30
|
},
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
},
|
|
35
|
-
"peerDependenciesMeta": {
|
|
36
|
-
"signalk-polar-management": {
|
|
37
|
-
"optional": true
|
|
38
|
-
}
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"polar-math": "^1.1.1"
|
|
39
33
|
},
|
|
40
34
|
"repository": {
|
|
41
35
|
"type": "git",
|
|
@@ -51,6 +45,9 @@
|
|
|
51
45
|
"signalk": {
|
|
52
46
|
"appIcon": "./icon.png",
|
|
53
47
|
"displayName": "Polar Performance",
|
|
48
|
+
"requires": [
|
|
49
|
+
"signalk-polar-management"
|
|
50
|
+
],
|
|
54
51
|
"screenshots": [
|
|
55
52
|
"./docs/plotter.png",
|
|
56
53
|
"./docs/overview.png",
|
package/plugin/index.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { Polar } = require('polar-math')
|
|
4
3
|
const {
|
|
5
|
-
MessageHandler,
|
|
6
4
|
createSmoothedPolar,
|
|
7
5
|
createSmoothedHandler,
|
|
8
6
|
SmoothedAngle,
|
|
@@ -41,15 +39,16 @@ module.exports = (app) => {
|
|
|
41
39
|
let hasPendingChanges = false
|
|
42
40
|
let isRunning = false
|
|
43
41
|
|
|
44
|
-
// Active polar —
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
let
|
|
51
|
-
let
|
|
52
|
-
let
|
|
42
|
+
// Active polar — provided entirely by signalk-polar-management's in-process
|
|
43
|
+
// service, discovered through the `polarManagement` PropertyValue. The active
|
|
44
|
+
// handle mirrors the shared active polar, sailplan, performance factor and
|
|
45
|
+
// wave-correction toggle on every calculation, so this plugin applies none of
|
|
46
|
+
// them itself. Descriptive metadata and the plotter's TWS axis come from the
|
|
47
|
+
// handle's describe() snapshot — no resource lookups or `polars.*` paths.
|
|
48
|
+
let polarHandle = null // live activePolar() calculation handle, or null
|
|
49
|
+
let polarDescription = null // latest handle.describe() result, or null
|
|
50
|
+
let unsubscribeDiscovery = null // PropertyValues discovery subscription
|
|
51
|
+
let unsubscribeChanges = null // polarHandle.onChange subscription
|
|
53
52
|
|
|
54
53
|
let windSmoother = null
|
|
55
54
|
let bspSmoother = null
|
|
@@ -186,9 +185,8 @@ module.exports = (app) => {
|
|
|
186
185
|
}
|
|
187
186
|
|
|
188
187
|
if (s.settingsVersion < 2) {
|
|
189
|
-
// v1 → v2: polar selection and performance adjustment moved to
|
|
190
|
-
//
|
|
191
|
-
// by a 'polars' resource-provider plugin (e.g. signalk-polar-management).
|
|
188
|
+
// v1 → v2: polar selection and performance adjustment moved to
|
|
189
|
+
// signalk-polar-management, which owns the shared selection and factor.
|
|
192
190
|
delete s.activePolar
|
|
193
191
|
delete s.perfAdjust
|
|
194
192
|
s.settingsVersion = 2
|
|
@@ -211,59 +209,59 @@ module.exports = (app) => {
|
|
|
211
209
|
}
|
|
212
210
|
|
|
213
211
|
// ---------------------------------------------------------------------------
|
|
214
|
-
// Active polar —
|
|
212
|
+
// Active polar — calculations and descriptive metadata via the polar
|
|
213
|
+
// management in-process service
|
|
215
214
|
// ---------------------------------------------------------------------------
|
|
216
215
|
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
216
|
+
// Refreshes the cached describe() snapshot that feeds /meta and the plotter's
|
|
217
|
+
// TWS axis, clears outputs when the polar becomes unavailable, and keeps the
|
|
218
|
+
// plugin status in sync with the current selection.
|
|
219
|
+
function refreshDescription() {
|
|
220
|
+
if (!polarHandle) return
|
|
221
|
+
const hadPolar = polarDescription?.available === true
|
|
222
|
+
polarDescription = polarHandle.describe()
|
|
223
|
+
if (polarDescription.available) {
|
|
224
|
+
app.setPluginStatus(`Polar '${polarDescription.name || polarDescription.id}' active`)
|
|
225
|
+
} else {
|
|
226
|
+
if (hadPolar) nullifyOutputs()
|
|
227
|
+
app.setPluginStatus(polarDescription.reason === 'no_active_polar'
|
|
228
|
+
? 'No active polar selected — select one in the polar management webapp'
|
|
229
|
+
: `Polar calculations unavailable (${String(polarDescription.reason).replace(/_/g, ' ')})`)
|
|
231
230
|
}
|
|
232
231
|
}
|
|
233
232
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
233
|
+
// Discovery of the polar calculation service announced by
|
|
234
|
+
// signalk-polar-management through the `polarManagement` PropertyValue.
|
|
235
|
+
// Only the latest record counts: an `available: false` record means the
|
|
236
|
+
// provider stopped and old handles are permanently unusable.
|
|
237
|
+
function handleServiceDiscovery(history) {
|
|
238
|
+
const record = history.findLast(entry => entry?.setter === 'signalk-polar-management')
|
|
239
|
+
const service = record?.value
|
|
240
|
+
unsubscribeChanges?.()
|
|
241
|
+
unsubscribeChanges = null
|
|
242
|
+
const hadPolar = polarDescription?.available === true
|
|
243
|
+
polarHandle = null
|
|
244
|
+
polarDescription = null
|
|
245
|
+
if (service?.apiVersion !== 1 || service.available !== true ||
|
|
246
|
+
typeof service.activePolar !== 'function' || typeof service.polar !== 'function') {
|
|
247
|
+
if (hadPolar) nullifyOutputs()
|
|
248
|
+
app.setPluginStatus('Polar calculation service not available — install or enable signalk-polar-management')
|
|
243
249
|
return
|
|
244
250
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
app.setPluginError(
|
|
251
|
+
const handle = service.activePolar()
|
|
252
|
+
if (typeof handle.describe !== 'function') {
|
|
253
|
+
if (hadPolar) nullifyOutputs()
|
|
254
|
+
app.setPluginError('signalk-polar-management is too old — update it to a version whose polar handles provide describe()')
|
|
249
255
|
return
|
|
250
256
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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()
|
|
257
|
+
polarHandle = handle
|
|
258
|
+
// Every change kind can affect the description (selection, edits, factor).
|
|
259
|
+
// Callbacks must stay short, so the snapshot refresh is deferred.
|
|
260
|
+
unsubscribeChanges = polarHandle.onChange((change) => {
|
|
261
|
+
app.debug('Polar configuration changed: %o', change)
|
|
262
|
+
setImmediate(() => { if (polarHandle === handle) refreshDescription() })
|
|
266
263
|
})
|
|
264
|
+
refreshDescription()
|
|
267
265
|
}
|
|
268
266
|
|
|
269
267
|
// ---------------------------------------------------------------------------
|
|
@@ -345,7 +343,7 @@ module.exports = (app) => {
|
|
|
345
343
|
|
|
346
344
|
function computeAndSend() {
|
|
347
345
|
if (hasPendingChanges) applyOptionChanges()
|
|
348
|
-
if (!
|
|
346
|
+
if (!polarHandle) return
|
|
349
347
|
|
|
350
348
|
const wind = windSmoother.polarValue
|
|
351
349
|
const TWS = wind.magnitude
|
|
@@ -384,11 +382,11 @@ module.exports = (app) => {
|
|
|
384
382
|
// - all other polar values and polar ratios are cleared in irons, where the polar says nothing
|
|
385
383
|
// about the boat's current state.
|
|
386
384
|
const isUpwind = windValid && TWA < Math.PI / 2
|
|
387
|
-
const speedResult = windValid ?
|
|
385
|
+
const speedResult = windValid ? polarHandle.speedAt({ tws: TWS, twa: TWA }) : null
|
|
388
386
|
const inIrons = speedResult?.state?.twa === 'in_irons'
|
|
389
387
|
const fromPolar = value => (inIrons ? null : finiteOrNull(value))
|
|
390
388
|
|
|
391
|
-
const targets = windValid ?
|
|
389
|
+
const targets = windValid ? polarHandle.targetsAt({ tws: TWS }).value : null
|
|
392
390
|
const adviceBeatAngle = finiteOrNull(targets?.beat?.twa)
|
|
393
391
|
const adviceRunAngle = finiteOrNull(targets?.run?.twa)
|
|
394
392
|
const adviceAngle = isUpwind ? adviceBeatAngle : adviceRunAngle
|
|
@@ -397,8 +395,8 @@ module.exports = (app) => {
|
|
|
397
395
|
const runAngle = fromPolar(adviceRunAngle)
|
|
398
396
|
const beatVMG = fromPolar(targets?.beat?.vmg)
|
|
399
397
|
const runVMG = fromPolar(targets?.run?.vmg)
|
|
400
|
-
const
|
|
401
|
-
const
|
|
398
|
+
const targetVMG = finiteOrNull(isUpwind ? targets?.beat?.vmg : targets?.run?.vmg)
|
|
399
|
+
const polarTargetVMG = fromPolar(targetVMG)
|
|
402
400
|
const maxSpeed = fromPolar(targets?.maxSpeed?.speed)
|
|
403
401
|
const maxSpeedAngle = maxSpeed === null ? null : finiteOrNull(targets?.maxSpeed?.twa)
|
|
404
402
|
|
|
@@ -406,13 +404,13 @@ module.exports = (app) => {
|
|
|
406
404
|
const speedAtTwa = speedResult?.value
|
|
407
405
|
const polarSpeed = Number.isFinite(speedAtTwa) && speedAtTwa > 0 ? speedAtTwa : null
|
|
408
406
|
|
|
409
|
-
const targetCos =
|
|
407
|
+
const targetCos = adviceAngle === null ? 0 : Math.abs(Math.cos(adviceAngle))
|
|
410
408
|
const targetSpeed = targetVMG !== null && targetVMG >= 0 && targetCos > 0.01
|
|
411
409
|
? targetVMG / targetCos
|
|
412
410
|
: null
|
|
413
411
|
|
|
414
412
|
const vmg = windValid && BSP !== null ? BSP * Math.cos(TWA) : null
|
|
415
|
-
const vmgRatio = ratio(vmg === null ? null : Math.abs(vmg),
|
|
413
|
+
const vmgRatio = ratio(vmg === null ? null : Math.abs(vmg), polarTargetVMG)
|
|
416
414
|
|
|
417
415
|
let optimumWindAngle = null
|
|
418
416
|
if (isUpwind && adviceBeatAngle !== null) optimumWindAngle = (TWA - adviceBeatAngle) * port
|
|
@@ -458,7 +456,7 @@ module.exports = (app) => {
|
|
|
458
456
|
|
|
459
457
|
output('VMG', 'performance.velocityMadeGood', vmg, 'm/s',
|
|
460
458
|
'Actual VMG based on current boat speed and TWA.')
|
|
461
|
-
output('VMG', 'performance.polarVelocityMadeGood',
|
|
459
|
+
output('VMG', 'performance.polarVelocityMadeGood', polarTargetVMG, 'm/s',
|
|
462
460
|
"Polar VMG for current TWS. The VMG predicted by the polar, not the vessel's actual VMG.")
|
|
463
461
|
output('VMG', 'performance.polarVelocityMadeGoodRatio', vmgRatio, 'ratio',
|
|
464
462
|
'Actual VMG divided by polar VMG.')
|
|
@@ -514,16 +512,18 @@ module.exports = (app) => {
|
|
|
514
512
|
return Number.isFinite(value) ? parseFloat(value.toFixed(digits)) : null
|
|
515
513
|
}
|
|
516
514
|
|
|
515
|
+
// TWS axis for the webapp plotter — from the handle's description, which
|
|
516
|
+
// already reflects the active selection (v1 table, or v2 best/sailplan).
|
|
517
517
|
function getPolarTwsValues() {
|
|
518
|
-
return
|
|
519
|
-
.filter(
|
|
520
|
-
.map(
|
|
518
|
+
return polarDescription.twsAxis
|
|
519
|
+
.filter(tws => Number.isFinite(tws) && tws > 0.001)
|
|
520
|
+
.map(tws => parseFloat(tws.toFixed(4)))
|
|
521
521
|
}
|
|
522
522
|
|
|
523
523
|
function buildCurveResult(tws, stepRad) {
|
|
524
524
|
const points = []
|
|
525
525
|
for (let twa = 0; twa <= Math.PI + 1e-9; twa += stepRad) {
|
|
526
|
-
const { value: tbs } =
|
|
526
|
+
const { value: tbs } = polarHandle.speedAt({ tws, twa })
|
|
527
527
|
if (Number.isFinite(tbs) && tbs > 0) {
|
|
528
528
|
points.push({
|
|
529
529
|
twa: toFixedNumber(twa, 5),
|
|
@@ -532,7 +532,7 @@ module.exports = (app) => {
|
|
|
532
532
|
}
|
|
533
533
|
}
|
|
534
534
|
|
|
535
|
-
const { value: targets } =
|
|
535
|
+
const { value: targets } = polarHandle.targetsAt({ tws })
|
|
536
536
|
return {
|
|
537
537
|
tws,
|
|
538
538
|
points,
|
|
@@ -557,12 +557,12 @@ module.exports = (app) => {
|
|
|
557
557
|
// ---- Active polar curve queries (read-only — for the webapp's polar canvas) --
|
|
558
558
|
|
|
559
559
|
router.access('readonly').get('/polar/axes/tws', (req, res) => {
|
|
560
|
-
if (!
|
|
560
|
+
if (!polarDescription?.available) return res.status(404).json({ error: 'No active polar selected' })
|
|
561
561
|
res.json(getPolarTwsValues())
|
|
562
562
|
})
|
|
563
563
|
|
|
564
564
|
router.access('readonly').get('/polar/queries/curve', (req, res) => {
|
|
565
|
-
if (!
|
|
565
|
+
if (!polarHandle || !polarDescription?.available) return res.status(404).json({ error: 'No active polar selected' })
|
|
566
566
|
try {
|
|
567
567
|
const tws = parseFloat(req.query.tws)
|
|
568
568
|
if (!Number.isFinite(tws) || tws < 0) {
|
|
@@ -589,8 +589,8 @@ module.exports = (app) => {
|
|
|
589
589
|
const TWA = Number.isFinite(TWAsigned) ? Math.abs(TWAsigned) : null
|
|
590
590
|
const BSP = bspSmoother ? bspSmoother.value : null
|
|
591
591
|
|
|
592
|
-
const polarResult = (
|
|
593
|
-
?
|
|
592
|
+
const polarResult = (polarHandle && Number.isFinite(TWS) && Number.isFinite(TWA))
|
|
593
|
+
? polarHandle.speedAt({ tws: TWS, twa: TWA })
|
|
594
594
|
: null
|
|
595
595
|
const polarSpeed = polarResult ? polarResult.value : null
|
|
596
596
|
|
|
@@ -634,8 +634,8 @@ module.exports = (app) => {
|
|
|
634
634
|
|
|
635
635
|
const bspPath = settings.useSOG ? 'navigation.speedOverGround' : 'navigation.speedThroughWater'
|
|
636
636
|
|
|
637
|
-
const polarState = (
|
|
638
|
-
?
|
|
637
|
+
const polarState = (polarHandle && Number.isFinite(TWS) && Number.isFinite(TWAsigned))
|
|
638
|
+
? polarHandle.speedAt({ tws: TWS, twa: Math.abs(TWAsigned) }).state
|
|
639
639
|
: null
|
|
640
640
|
|
|
641
641
|
// Build outputs object: only include paths that are enabled and were
|
|
@@ -678,8 +678,10 @@ module.exports = (app) => {
|
|
|
678
678
|
|
|
679
679
|
// Metadata describing the units and display preferences for each field
|
|
680
680
|
// returned by /live and the canonical curve query endpoints, plus a
|
|
681
|
-
// read-only summary of the active polar and performance factor
|
|
682
|
-
//
|
|
681
|
+
// read-only summary of the active polar and performance factor, taken
|
|
682
|
+
// from the management handle's describe() snapshot. The factor is
|
|
683
|
+
// informational: the handle applies it in every calculation, so this
|
|
684
|
+
// plugin never multiplies by it.
|
|
683
685
|
router.access('readonly').get('/meta', (req, res) => {
|
|
684
686
|
const speed = { formula: 'value * 1.943844', symbol: 'kn', displayFormat: '0.0' }
|
|
685
687
|
const angle = { formula: 'value * 57.29577951308231', symbol: '\u00b0', displayFormat: '0.0' }
|
|
@@ -694,16 +696,17 @@ module.exports = (app) => {
|
|
|
694
696
|
'curve.tbs': { units: 'm/s', displayUnits: speed },
|
|
695
697
|
'curve.vmg': { units: 'm/s', displayUnits: speed },
|
|
696
698
|
'curve.twa': { units: 'rad', displayUnits: angle },
|
|
697
|
-
activePolar:
|
|
698
|
-
id:
|
|
699
|
-
name:
|
|
700
|
-
boatType:
|
|
701
|
-
sailnumber:
|
|
702
|
-
year:
|
|
703
|
-
source:
|
|
704
|
-
notes:
|
|
699
|
+
activePolar: polarDescription?.available ? {
|
|
700
|
+
id: polarDescription.id,
|
|
701
|
+
name: polarDescription.name || polarDescription.id,
|
|
702
|
+
boatType: polarDescription.boatType,
|
|
703
|
+
sailnumber: polarDescription.sailnumber,
|
|
704
|
+
year: polarDescription.year,
|
|
705
|
+
source: polarDescription.source,
|
|
706
|
+
notes: polarDescription.notes,
|
|
707
|
+
sailplanId: polarDescription.sailplanId
|
|
705
708
|
} : null,
|
|
706
|
-
performanceFactor
|
|
709
|
+
performanceFactor: polarDescription?.available ? polarDescription.performanceFactor : null
|
|
707
710
|
})
|
|
708
711
|
})
|
|
709
712
|
|
|
@@ -735,35 +738,21 @@ module.exports = (app) => {
|
|
|
735
738
|
metaSentPaths = new Set() // reset so metadata is re-emitted after restart
|
|
736
739
|
lifecycleWarningMap = new Map()
|
|
737
740
|
lifecycleWarnings = []
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
activePolarDoc = null
|
|
741
|
-
performanceFactor = 1
|
|
741
|
+
polarHandle = null
|
|
742
|
+
polarDescription = null
|
|
742
743
|
|
|
743
744
|
settings = migrateSettings({ ...DEFAULT_SETTINGS, ...options, settingsVersion: options.settingsVersion ?? 0 })
|
|
744
745
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
746
|
+
// Discover the in-process polar calculation service. The callback fires
|
|
747
|
+
// immediately with the retained PropertyValues history, so startup order
|
|
748
|
+
// relative to signalk-polar-management does not matter. The handle also
|
|
749
|
+
// provides the active selection's metadata through describe(), so no
|
|
750
|
+
// `polars.*` subscriptions or resource lookups are needed.
|
|
751
|
+
if (typeof app.onPropertyValues === 'function') {
|
|
752
|
+
unsubscribeDiscovery = app.onPropertyValues('polarManagement', handleServiceDiscovery)
|
|
753
|
+
} else {
|
|
754
|
+
app.setPluginError('Signal K server does not support PropertyValues — polar calculations unavailable')
|
|
765
755
|
}
|
|
766
|
-
performanceFactorHandler.subscribe()
|
|
767
756
|
|
|
768
757
|
const SmootherClass = getSmootherClass(settings.smootherType)
|
|
769
758
|
const smootherOptions = getSmootherOptions(settings.smootherType, settings)
|
|
@@ -832,11 +821,10 @@ module.exports = (app) => {
|
|
|
832
821
|
if (windSmoother) { windSmoother.terminate(); windSmoother = null }
|
|
833
822
|
if (bspSmoother) { bspSmoother.terminate(); bspSmoother = null }
|
|
834
823
|
if (hdgSmoother) { hdgSmoother.terminate(); hdgSmoother = null }
|
|
835
|
-
if (
|
|
836
|
-
if (
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
activePolarDoc = null
|
|
824
|
+
if (unsubscribeDiscovery) { unsubscribeDiscovery(); unsubscribeDiscovery = null }
|
|
825
|
+
if (unsubscribeChanges) { unsubscribeChanges(); unsubscribeChanges = null }
|
|
826
|
+
polarHandle = null
|
|
827
|
+
polarDescription = null
|
|
840
828
|
lifecycleWarningMap = new Map()
|
|
841
829
|
lifecycleWarnings = []
|
|
842
830
|
app.debug('Plugin stopped')
|
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
package/test/outputs.test.js
CHANGED
|
@@ -52,10 +52,56 @@ const targets = reference.targetsAt({ tws: TWS }).value
|
|
|
52
52
|
const close = (actual, expected, msg) =>
|
|
53
53
|
assert.ok(Number.isFinite(actual) && Math.abs(actual - expected) < 1e-9, `${msg}: ${actual} != ${expected}`)
|
|
54
54
|
|
|
55
|
-
function harness(table = TABLE) {
|
|
55
|
+
function harness(table = TABLE, { withService = true, description = {} } = {}) {
|
|
56
56
|
const subscribers = new Map()
|
|
57
57
|
const bus = new Map() // latest value per path written by the plugin
|
|
58
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
|
+
|
|
59
105
|
const app = {
|
|
60
106
|
debug: () => {}, error: () => {},
|
|
61
107
|
setPluginStatus: () => {}, setPluginError: () => {},
|
|
@@ -69,9 +115,11 @@ function harness(table = TABLE) {
|
|
|
69
115
|
}
|
|
70
116
|
}
|
|
71
117
|
},
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
118
|
+
onPropertyValues: (name, callback) => {
|
|
119
|
+
if (name === 'polarManagement' && withService) {
|
|
120
|
+
callback([{ setter: 'signalk-polar-management', value: service }])
|
|
121
|
+
}
|
|
122
|
+
return () => {}
|
|
75
123
|
},
|
|
76
124
|
subscriptionmanager: {
|
|
77
125
|
subscribe: (msg, unsubscribes, _err, callback) => {
|
|
@@ -99,10 +147,9 @@ function harness(table = TABLE) {
|
|
|
99
147
|
}
|
|
100
148
|
}
|
|
101
149
|
return {
|
|
102
|
-
app, plugin, bus, writes, routes, deliver,
|
|
150
|
+
app, plugin, bus, writes, calls, routes, deliver, state, notifyChange,
|
|
103
151
|
async start(options = ALL_OUTPUTS) {
|
|
104
152
|
plugin.start(options)
|
|
105
|
-
deliver('polars.activePolar', { href: '/resources/polars/ref' })
|
|
106
153
|
await new Promise(resolve => setImmediate(resolve))
|
|
107
154
|
},
|
|
108
155
|
sail({ tws = TWS, twa, bsp, hdg }) {
|
|
@@ -160,13 +207,14 @@ describe('computeAndSend output validity', () => {
|
|
|
160
207
|
const ironsTwa = rad(20)
|
|
161
208
|
assert.equal(reference.speedAt({ tws: TWS, twa: ironsTwa }).state.twa, 'in_irons')
|
|
162
209
|
h.sail({ twa: ironsTwa, bsp, hdg: 1 })
|
|
163
|
-
for (const path of ['performance.polarSpeed', 'performance.polarSpeedRatio', 'performance.
|
|
164
|
-
'performance.targetVelocityMadeGood', 'performance.polarVelocityMadeGood',
|
|
210
|
+
for (const path of ['performance.polarSpeed', 'performance.polarSpeedRatio', 'performance.polarVelocityMadeGood',
|
|
165
211
|
'performance.polarVelocityMadeGoodRatio', 'performance.beatAngle', 'performance.gybeAngle',
|
|
166
212
|
'performance.beatAngleVelocityMadeGood', 'performance.gybeAngleVelocityMadeGood',
|
|
167
213
|
'performance.maxSpeed', 'performance.maxSpeedAngle']) {
|
|
168
214
|
assert.equal(h.bus.get(path), null, path)
|
|
169
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')
|
|
170
218
|
close(h.bus.get('performance.targetAngle'), targets.beat.twa, 'targetAngle in irons')
|
|
171
219
|
close(h.bus.get('performance.optimumWindAngle'), ironsTwa - targets.beat.twa, 'optimumWindAngle in irons')
|
|
172
220
|
close(h.bus.get('performance.tackTrue'), 1 + targets.beat.twa, 'tackTrue in irons')
|
|
@@ -178,7 +226,8 @@ describe('computeAndSend output validity', () => {
|
|
|
178
226
|
|
|
179
227
|
const status = h.status()
|
|
180
228
|
assert.equal(status.outputs['performance.polarSpeed'], null)
|
|
181
|
-
assert.
|
|
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)
|
|
182
231
|
|
|
183
232
|
// Pinching is not in irons, so nothing is cleared
|
|
184
233
|
const pinchTwa = rad(38)
|
|
@@ -221,8 +270,8 @@ describe('computeAndSend output validity', () => {
|
|
|
221
270
|
|
|
222
271
|
h.sail({ twa: rad(-20), bsp })
|
|
223
272
|
assert.equal(h.bus.get('performance.polarSpeed'), null)
|
|
224
|
-
|
|
225
|
-
|
|
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')
|
|
226
275
|
assert.equal(h.bus.get('performance.beatAngle'), null)
|
|
227
276
|
close(h.bus.get('performance.targetAngle'), -targets.beat.twa, 'targetAngle port irons')
|
|
228
277
|
close(h.bus.get('performance.optimumWindAngle'), -(rad(20) - targets.beat.twa), 'optimumWindAngle port irons')
|
|
@@ -301,4 +350,70 @@ describe('computeAndSend output validity', () => {
|
|
|
301
350
|
assert.equal(h.bus.get(path), null, path)
|
|
302
351
|
}
|
|
303
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
|
+
})
|
|
304
419
|
})
|