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 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
- ## [Unreleased]
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, and publishes the selected active polar to Signal K (`polars.activePolar`) along with any performance factor (`polars.performanceFactor`).
11
- - **Performance Plugin (`signalk-polar-performance-plugin`):** Subscribes to the active polar and boat instrument data (TWS, TWA, STW/SOG), calculates real-time performance values using the [`polar-math`](https://github.com/Asw1n/polar-math) engine, and publishes the resulting performance metrics back to the Signal K delta stream.
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**. Without a polar resource provider installed and enabled, Polar Performance will wait for an active polar to be published and indicate this in its status.
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 (e.g. signalk-polar-management); this plugin reads them from the `polars.activePolar` and `polars.performanceFactor` Signal K paths. If no polar is active, the plugin runs but publishes nothing; any previously published values are nullified immediately.
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
- | Polar values and ratios based on them | `polarSpeed`, `polarSpeedRatio`, `targetSpeed`, `targetVelocityMadeGood`, `polarVelocityMadeGood`, `polarVelocityMadeGoodRatio`, `beatAngle`, `gybeAngle`, `beatAngleVelocityMadeGood`, `gybeAngleVelocityMadeGood`, `maxSpeed`, `maxSpeedAngle` | Set to `null` |
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
- Clearing the polar values in irons also prevents spikes in the 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 values return on the next wind update.
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 a separate 'polars' resource-provider plugin, such as [signalk-polar-management](https://github.com/Asw1n/signalk-polar-management). See that plugin's documentation for import formats, the ORC certificate search, and extrapolation behaviour of the underlying `polar-math` library.
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 `polars.activePolar` is published. Select an active polar in the polar management webapp. |
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` structure, polar storage, and import, 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.
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 a 'polars' resource-provider plugin (e.g. `signalk-polar-management`) to be installed and running for polar storage, import, and active polar selection.
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 a separate 'polars' resource-provider plugin
5
- (e.g. [signalk-polar-management](https://github.com/Asw1n/signalk-polar-management)). For the
6
- canonical `polarTable` format and its management API, see that plugin's documentation and the
7
- [polar-format](https://github.com/Asw1n/polar-format) / [polar-math](https://github.com/Asw1n/polar-math)
8
- packages it depends on.
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
- This plugin subscribes to two Signal K paths published by the 'polars' resource provider:
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
- - `polars.activePolar` — `{ href: '/resources/polars/<id>' }`, or absent if no polar is active.
17
- - `polars.performanceFactor` — a number applied as a multiplier to all polar speeds.
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
- On a change to `polars.activePolar`, the plugin resolves `<id>` via the in-process
20
- `app.resourcesApi.getResource('polars', id)` call and builds a `polar-math` `Polar` instance
21
- from the returned canonical document. If the resource provider or the active polar becomes
22
- unavailable, the last successfully loaded polar is kept until a valid update arrives.
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.1.0",
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
- "peerDependencies": {
33
- "signalk-polar-management": ">=1.0.0"
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 — 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
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 the
190
- // `polars.activePolar` / `polars.performanceFactor` SK paths, published
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 — sourced from SK paths published by a 'polars' resource provider
212
+ // Active polar — calculations and descriptive metadata via the polar
213
+ // management in-process service
215
214
  // ---------------------------------------------------------------------------
216
215
 
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
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
- 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')
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
- const id = hrefToId(href)
247
- if (!id) {
248
- app.setPluginError(`Cannot parse active polar href: ${href}`)
251
+ const handle = service.activePolar()
252
+ if (typeof handle.describe !== 'function') {
253
+ if (hadPolar) nullifyOutputs()
254
+ app.setPluginError('signalk-polar-management is too old — update it to a version whose polar handles provide describe()')
249
255
  return
250
256
  }
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()
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 (!polar) return
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 ? polar.speedAt({ tws: TWS, twa: TWA, performanceFactor }) : null
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 ? polar.targetsAt({ tws: TWS, performanceFactor }).value : null
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 targetAngle = isUpwind ? beatAngle : runAngle
401
- const targetVMG = isUpwind ? beatVMG : runVMG
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 = targetAngle === null ? 0 : Math.abs(Math.cos(targetAngle))
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), targetVMG)
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', targetVMG, 'm/s',
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 polar.entries
519
- .filter(entry => entry.tws > 0.001)
520
- .map(entry => parseFloat(entry.tws.toFixed(4)))
518
+ return polarDescription.twsAxis
519
+ .filter(tws => Number.isFinite(tws) && tws > 0.001)
520
+ .map(tws => parseFloat(tws.toFixed(4)))
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 } = polar.speedAt({ tws, twa, performanceFactor })
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 } = polar.targetsAt({ tws, performanceFactor })
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 (!polar) return res.status(404).json({ error: 'No active polar selected' })
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 (!polar) return res.status(404).json({ error: 'No active polar selected' })
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 = (polar && Number.isFinite(TWS) && Number.isFinite(TWA))
593
- ? polar.speedAt({ tws: TWS, twa: TWA, performanceFactor })
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 = (polar && Number.isFinite(TWS) && Number.isFinite(TWAsigned))
638
- ? polar.speedAt({ tws: TWS, twa: Math.abs(TWAsigned), performanceFactor }).state
637
+ const polarState = (polarHandle && Number.isFinite(TWS) && Number.isFinite(TWAsigned))
638
+ ? polarHandle.speedAt({ tws: TWS, twa: Math.abs(TWAsigned) }).state
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
- // (both sourced from the `polars.*` SK paths — not editable here).
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: 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
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
- polar = null
739
- activePolarId = null
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
- 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.")
749
- }
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
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 (activePolarHandler) { activePolarHandler.unsubscribe(); activePolarHandler = null }
836
- if (performanceFactorHandler) { performanceFactorHandler.unsubscribe(); performanceFactorHandler = null }
837
- polar = null
838
- activePolarId = null
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('No active polar — select one in the polar management webapp')
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)
@@ -25,6 +25,7 @@ function makeApp(dataDir) {
25
25
  },
26
26
  getDataDirPath: () => dataDir,
27
27
  handleMessage: () => {},
28
+ onPropertyValues: () => () => {},
28
29
  config: { port: 3000 },
29
30
  subscriptionmanager: {
30
31
  subscribe: (_sub, unsubscribes, _onErr, _onDelta) => {
@@ -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
- resourcesApi: {
73
- listResources: async () => ({}),
74
- getResource: async () => table
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.targetSpeed',
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.equal(status.outputs['performance.targetSpeed'], 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)
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
- assert.equal(h.bus.get('performance.targetSpeed'), null)
225
- assert.equal(h.bus.get('performance.targetVelocityMadeGood'), 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')
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
  })