signalk-polar-performance-plugin 1.2.0 → 1.3.1
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 +17 -0
- package/README.md +2 -0
- package/openApi.json +912 -0
- package/package.json +3 -2
- package/plugin/index.js +96 -18
- package/public/app.js +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ 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]
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- `/live` and `/status` endpoints now return `null` for `tws`/`twa` (and downstream fields) when the wind smoother has no data or is stale, instead of `0`. The guard was checking for the presence of the smoother object rather than its `ready` state.
|
|
11
|
+
- `computeAndSend`: when the polar table lookup fails (boat outside polar range — in irons or above max TWS), the `performance.polarSpeed`, `performance.polarSpeedRatio`, and `performance.targetSpeed` SK paths are now written with `null` instead of `0`. Writing `0` was misleading because it is a valid-looking value rather than an explicit "no data" signal.
|
|
12
|
+
- Live input subscriptions are now re-established after prolonged silence for all subscribed inputs, not just true wind. Boat speed and optional true heading use the same recovery path, and the plugin now reports their lifecycle state through the webapp/status endpoints.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Idle input recovery is now always enabled; the temporary `detectStaleData` setting has been removed from the runtime settings UI.
|
|
16
|
+
|
|
17
|
+
## [1.2.1] - 2026-07-25
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- `openApi.json` was missing from the npm tarball (`files` field in `package.json` did not list it), causing the plugin to crash on install with `Cannot find module '../openApi.json'` (#20).
|
|
21
|
+
|
|
22
|
+
## [1.2.0] - 2026-07-25
|
|
23
|
+
|
|
7
24
|
## [1.1.0] - 2026-07-21
|
|
8
25
|
|
|
9
26
|
### Added
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Polar Performance reads your boat's true wind speed, true wind angle, and boat speed from Signal K, looks up the corresponding target values from your polar diagram, and publishes performance metrics — beat angle, run angle, VMG, polar speed ratio, and others — back to the Signal K bus in real time. An integrated webapp lets you inspect the live values, manage polars, and configure the plugin while it is running.
|
|
4
4
|
|
|
5
|
+
Current runtime behaviour is also more explicit: when a polar lookup cannot be completed or a required input has no usable value, the plugin writes `null` for the affected output paths and the `/live` and `/status` endpoints expose that state clearly. Idle input recovery is enabled for all live subscriptions, so temporary silence is handled without leaving the plugin in a stale state.
|
|
6
|
+
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
## Installation
|
package/openApi.json
ADDED
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
{
|
|
2
|
+
"openapi": "3.0.3",
|
|
3
|
+
"info": {
|
|
4
|
+
"title": "Polar Performance Plugin API",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "REST API for polar library management and polar performance queries.\nAll speed values are in m/s, all angles in radians."
|
|
7
|
+
},
|
|
8
|
+
"servers": [
|
|
9
|
+
{ "url": "/plugins/signalk-polar-performance-plugin" }
|
|
10
|
+
],
|
|
11
|
+
"tags": [
|
|
12
|
+
{
|
|
13
|
+
"name": "polar-manager",
|
|
14
|
+
"description": "Polar library management: list, replace, inspect, delete, and select canonical polars by id."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"name": "polar-query",
|
|
18
|
+
"description": "Polar performance queries: inspect axes and derive speed, targets, curves, and performance ratios for any stored polar."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "polar-import",
|
|
22
|
+
"description": "Optional polar ingestion extension: discover supported external sources and text formats, convert them to canonical polar resources, and store them in the library."
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"paths": {
|
|
26
|
+
|
|
27
|
+
"/polars": {
|
|
28
|
+
"get": {
|
|
29
|
+
"tags": ["polar-manager"],
|
|
30
|
+
"operationId": "listPolars",
|
|
31
|
+
"summary": "List all stored polars",
|
|
32
|
+
"description": "Returns a summary of every polar in the library.",
|
|
33
|
+
"responses": {
|
|
34
|
+
"200": {
|
|
35
|
+
"description": "Array of polar summaries",
|
|
36
|
+
"content": {
|
|
37
|
+
"application/json": {
|
|
38
|
+
"schema": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"items": { "$ref": "#/components/schemas/PolarSummary" }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"post": {
|
|
48
|
+
"tags": ["polar-manager"],
|
|
49
|
+
"operationId": "createPolar",
|
|
50
|
+
"summary": "Create a canonical polar with an auto-generated id",
|
|
51
|
+
"description": "Stores the supplied canonical PolarTable resource under an auto-generated id and returns that id.",
|
|
52
|
+
"requestBody": {
|
|
53
|
+
"required": true,
|
|
54
|
+
"content": {
|
|
55
|
+
"application/json": {
|
|
56
|
+
"schema": { "$ref": "#/components/schemas/PolarResourceBody" }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"responses": {
|
|
61
|
+
"201": {
|
|
62
|
+
"description": "Polar stored with an auto-generated id",
|
|
63
|
+
"content": {
|
|
64
|
+
"application/json": {
|
|
65
|
+
"schema": { "$ref": "#/components/schemas/IdResponse" }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"400": {
|
|
70
|
+
"description": "Invalid canonical polar resource",
|
|
71
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
"/imports/formats": {
|
|
78
|
+
"get": {
|
|
79
|
+
"tags": ["polar-import"],
|
|
80
|
+
"operationId": "listImportFormats",
|
|
81
|
+
"summary": "List supported text import formats",
|
|
82
|
+
"description": "Returns the format identifiers accepted by the text import endpoint. These formats are conversion inputs only; imported data is stored as canonical polar resources.",
|
|
83
|
+
"responses": {
|
|
84
|
+
"200": {
|
|
85
|
+
"description": "Supported text import formats",
|
|
86
|
+
"content": {
|
|
87
|
+
"application/json": {
|
|
88
|
+
"schema": {
|
|
89
|
+
"type": "array",
|
|
90
|
+
"items": { "$ref": "#/components/schemas/ImportFormatDescriptor" }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
"/imports/text/{format}": {
|
|
100
|
+
"post": {
|
|
101
|
+
"tags": ["polar-import"],
|
|
102
|
+
"operationId": "importPolarFromText",
|
|
103
|
+
"summary": "Import a polar from text content",
|
|
104
|
+
"description": "Parses the supplied text payload in the specified format, converts it to the canonical polar resource model, stores it in the local library, and returns the assigned id.",
|
|
105
|
+
"parameters": [
|
|
106
|
+
{
|
|
107
|
+
"name": "format",
|
|
108
|
+
"in": "path",
|
|
109
|
+
"required": true,
|
|
110
|
+
"description": "Text format identifier returned by GET /imports/formats.",
|
|
111
|
+
"schema": { "type": "string" }
|
|
112
|
+
}
|
|
113
|
+
],
|
|
114
|
+
"requestBody": {
|
|
115
|
+
"required": true,
|
|
116
|
+
"content": {
|
|
117
|
+
"application/json": {
|
|
118
|
+
"schema": { "$ref": "#/components/schemas/TextPolarImportRequest" }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
"responses": {
|
|
123
|
+
"201": {
|
|
124
|
+
"description": "Polar imported and stored",
|
|
125
|
+
"content": {
|
|
126
|
+
"application/json": {
|
|
127
|
+
"schema": { "$ref": "#/components/schemas/IdResponse" }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"400": {
|
|
132
|
+
"description": "Unsupported format or invalid text payload",
|
|
133
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
134
|
+
},
|
|
135
|
+
"409": {
|
|
136
|
+
"description": "Requested id already exists",
|
|
137
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
"/imports/sources": {
|
|
144
|
+
"get": {
|
|
145
|
+
"tags": ["polar-import"],
|
|
146
|
+
"operationId": "listImportSources",
|
|
147
|
+
"summary": "List supported external import sources",
|
|
148
|
+
"description": "Returns the external sources from which polars can be searched and imported. The current backend exposes the official ORC active certificate source as `orc`.",
|
|
149
|
+
"responses": {
|
|
150
|
+
"200": {
|
|
151
|
+
"description": "Supported external sources",
|
|
152
|
+
"content": {
|
|
153
|
+
"application/json": {
|
|
154
|
+
"schema": {
|
|
155
|
+
"type": "array",
|
|
156
|
+
"items": { "$ref": "#/components/schemas/ImportSourceDescriptor" }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
"/imports/sources/{source}/search": {
|
|
166
|
+
"get": {
|
|
167
|
+
"tags": ["polar-import"],
|
|
168
|
+
"operationId": "searchImportSource",
|
|
169
|
+
"summary": "Search an external polar source",
|
|
170
|
+
"description": "Queries the specified external source for polar candidates matching the search term. For the `orc` source this searches the official active certificate index by certificate RefNo, boat name, sail number, and class.",
|
|
171
|
+
"parameters": [
|
|
172
|
+
{
|
|
173
|
+
"name": "source",
|
|
174
|
+
"in": "path",
|
|
175
|
+
"required": true,
|
|
176
|
+
"description": "External source identifier returned by GET /imports/sources. The current backend exposes `orc` for the official ORC active certificate source.",
|
|
177
|
+
"schema": { "type": "string" }
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"name": "q",
|
|
181
|
+
"in": "query",
|
|
182
|
+
"required": false,
|
|
183
|
+
"description": "Free-text search string matched against source-specific polar metadata.",
|
|
184
|
+
"schema": { "type": "string" }
|
|
185
|
+
}
|
|
186
|
+
],
|
|
187
|
+
"responses": {
|
|
188
|
+
"200": {
|
|
189
|
+
"description": "Matching external polar candidates",
|
|
190
|
+
"content": {
|
|
191
|
+
"application/json": {
|
|
192
|
+
"schema": {
|
|
193
|
+
"type": "array",
|
|
194
|
+
"items": { "$ref": "#/components/schemas/ExternalPolarSummary" }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
"404": {
|
|
200
|
+
"description": "Import source not found",
|
|
201
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
202
|
+
},
|
|
203
|
+
"502": {
|
|
204
|
+
"description": "Failed to reach the external source",
|
|
205
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
"/imports/sources/{source}/items/{externalId}": {
|
|
212
|
+
"post": {
|
|
213
|
+
"tags": ["polar-import"],
|
|
214
|
+
"operationId": "importPolarFromSource",
|
|
215
|
+
"summary": "Import a polar from an external source",
|
|
216
|
+
"description": "Fetches the specified external polar, converts it to the canonical resource model, stores it in the local library, and returns the assigned id. For the `orc` source, `externalId` is the certificate RefNo from the official active certificate index.",
|
|
217
|
+
"parameters": [
|
|
218
|
+
{
|
|
219
|
+
"name": "source",
|
|
220
|
+
"in": "path",
|
|
221
|
+
"required": true,
|
|
222
|
+
"description": "External source identifier returned by GET /imports/sources. The current backend exposes `orc` for the official ORC active certificate source.",
|
|
223
|
+
"schema": { "type": "string" }
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"name": "externalId",
|
|
227
|
+
"in": "path",
|
|
228
|
+
"required": true,
|
|
229
|
+
"description": "External polar identifier returned by GET /imports/sources/{source}/search. For the `orc` source, this is the certificate RefNo.",
|
|
230
|
+
"schema": { "type": "string" }
|
|
231
|
+
}
|
|
232
|
+
],
|
|
233
|
+
"responses": {
|
|
234
|
+
"201": {
|
|
235
|
+
"description": "Polar imported and stored",
|
|
236
|
+
"content": {
|
|
237
|
+
"application/json": {
|
|
238
|
+
"schema": { "$ref": "#/components/schemas/IdResponse" }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
"404": {
|
|
243
|
+
"description": "Import source or external polar not found",
|
|
244
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
245
|
+
},
|
|
246
|
+
"409": {
|
|
247
|
+
"description": "Requested id already exists",
|
|
248
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
249
|
+
},
|
|
250
|
+
"502": {
|
|
251
|
+
"description": "Failed to reach the external source",
|
|
252
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
"/polars/active": {
|
|
259
|
+
"get": {
|
|
260
|
+
"tags": ["polar-manager"],
|
|
261
|
+
"operationId": "getActivePolar",
|
|
262
|
+
"summary": "Get the active polar id",
|
|
263
|
+
"description": "Returns the id of the currently active polar.",
|
|
264
|
+
"responses": {
|
|
265
|
+
"200": {
|
|
266
|
+
"description": "Active polar id",
|
|
267
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdResponse" } } }
|
|
268
|
+
},
|
|
269
|
+
"404": {
|
|
270
|
+
"description": "No polar is currently active",
|
|
271
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
"put": {
|
|
276
|
+
"tags": ["polar-manager"],
|
|
277
|
+
"operationId": "setActivePolar",
|
|
278
|
+
"summary": "Set the active polar",
|
|
279
|
+
"description": "Makes the identified polar the active polar for live computation.",
|
|
280
|
+
"requestBody": {
|
|
281
|
+
"required": true,
|
|
282
|
+
"content": {
|
|
283
|
+
"application/json": {
|
|
284
|
+
"schema": { "$ref": "#/components/schemas/IdResponse" }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
"responses": {
|
|
289
|
+
"200": {
|
|
290
|
+
"description": "Active polar updated",
|
|
291
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdResponse" } } }
|
|
292
|
+
},
|
|
293
|
+
"404": {
|
|
294
|
+
"description": "Polar not found",
|
|
295
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
"delete": {
|
|
300
|
+
"tags": ["polar-manager"],
|
|
301
|
+
"operationId": "clearActivePolar",
|
|
302
|
+
"summary": "Clear the active polar",
|
|
303
|
+
"description": "Clears the active polar so no stored polar is used for live computation.",
|
|
304
|
+
"responses": {
|
|
305
|
+
"200": {
|
|
306
|
+
"description": "Active polar cleared",
|
|
307
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdResponse" } } }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
"/polars/{id}": {
|
|
314
|
+
"parameters": [
|
|
315
|
+
{ "$ref": "#/components/parameters/polarId" }
|
|
316
|
+
],
|
|
317
|
+
"get": {
|
|
318
|
+
"tags": ["polar-manager"],
|
|
319
|
+
"operationId": "getPolar",
|
|
320
|
+
"summary": "Get a canonical polar resource",
|
|
321
|
+
"description": "Returns the full canonical PolarTable resource, including axes, values, units, symmetry assumptions, and derived metadata.",
|
|
322
|
+
"responses": {
|
|
323
|
+
"200": {
|
|
324
|
+
"description": "Canonical polar resource",
|
|
325
|
+
"content": {
|
|
326
|
+
"application/json": {
|
|
327
|
+
"schema": { "$ref": "#/components/schemas/PolarResource" }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
"404": {
|
|
332
|
+
"description": "Polar not found",
|
|
333
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
"put": {
|
|
338
|
+
"tags": ["polar-manager"],
|
|
339
|
+
"operationId": "replacePolar",
|
|
340
|
+
"summary": "Create or replace a canonical polar resource",
|
|
341
|
+
"description": "Stores the supplied canonical PolarTable resource under the given id. If the polar exists it is replaced; otherwise it is created.",
|
|
342
|
+
"requestBody": {
|
|
343
|
+
"required": true,
|
|
344
|
+
"content": {
|
|
345
|
+
"application/json": {
|
|
346
|
+
"schema": { "$ref": "#/components/schemas/PolarResourceBody" }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
"responses": {
|
|
351
|
+
"200": {
|
|
352
|
+
"description": "Polar stored",
|
|
353
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdResponse" } } }
|
|
354
|
+
},
|
|
355
|
+
"400": {
|
|
356
|
+
"description": "Invalid canonical resource body",
|
|
357
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
"delete": {
|
|
362
|
+
"tags": ["polar-manager"],
|
|
363
|
+
"operationId": "deletePolar",
|
|
364
|
+
"summary": "Delete a polar",
|
|
365
|
+
"description": "Permanently removes the identified polar from the library.",
|
|
366
|
+
"responses": {
|
|
367
|
+
"200": {
|
|
368
|
+
"description": "Polar deleted",
|
|
369
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdResponse" } } }
|
|
370
|
+
},
|
|
371
|
+
"404": {
|
|
372
|
+
"description": "Polar not found",
|
|
373
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
"/polars/{id}/meta": {
|
|
380
|
+
"get": {
|
|
381
|
+
"tags": ["polar-manager"],
|
|
382
|
+
"operationId": "getPolarMeta",
|
|
383
|
+
"summary": "Get polar metadata",
|
|
384
|
+
"description": "Returns the stored metadata for the identified polar: name, sailnumber, boatType, year, source, notes, and TWS range.",
|
|
385
|
+
"parameters": [
|
|
386
|
+
{ "$ref": "#/components/parameters/polarId" }
|
|
387
|
+
],
|
|
388
|
+
"responses": {
|
|
389
|
+
"200": {
|
|
390
|
+
"description": "Polar metadata",
|
|
391
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PolarMeta" } } }
|
|
392
|
+
},
|
|
393
|
+
"404": {
|
|
394
|
+
"description": "Polar not found",
|
|
395
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
"/polars/{id}/axes/tws": {
|
|
402
|
+
"get": {
|
|
403
|
+
"tags": ["polar-query"],
|
|
404
|
+
"operationId": "getPolarTws",
|
|
405
|
+
"summary": "TWS values available in a polar",
|
|
406
|
+
"description": "Returns the array of true wind speed values (m/s) for which the polar has tabulated data.",
|
|
407
|
+
"parameters": [
|
|
408
|
+
{ "$ref": "#/components/parameters/polarId" }
|
|
409
|
+
],
|
|
410
|
+
"responses": {
|
|
411
|
+
"200": {
|
|
412
|
+
"description": "TWS values in m/s",
|
|
413
|
+
"content": {
|
|
414
|
+
"application/json": {
|
|
415
|
+
"schema": { "type": "array", "items": { "type": "number" } }
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
"404": {
|
|
420
|
+
"description": "Polar not found",
|
|
421
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
"/polars/{id}/queries/curve": {
|
|
428
|
+
"get": {
|
|
429
|
+
"tags": ["polar-query"],
|
|
430
|
+
"operationId": "getPolarCurve",
|
|
431
|
+
"summary": "Interpolated polar curve at a given TWS",
|
|
432
|
+
"description": "Returns a sampled polar curve at the requested TWS, including beat and run target markers.",
|
|
433
|
+
"parameters": [
|
|
434
|
+
{ "$ref": "#/components/parameters/polarId" },
|
|
435
|
+
{
|
|
436
|
+
"name": "tws",
|
|
437
|
+
"in": "query",
|
|
438
|
+
"required": true,
|
|
439
|
+
"description": "True wind speed in m/s",
|
|
440
|
+
"schema": { "type": "number", "minimum": 0 }
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
"name": "step",
|
|
444
|
+
"in": "query",
|
|
445
|
+
"required": false,
|
|
446
|
+
"description": "Angular sampling step in radians (default ~0.035 rad = 2 deg)",
|
|
447
|
+
"schema": { "type": "number", "minimum": 0 }
|
|
448
|
+
}
|
|
449
|
+
],
|
|
450
|
+
"responses": {
|
|
451
|
+
"200": {
|
|
452
|
+
"description": "Polar curve data",
|
|
453
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PolarCurveResult" } } }
|
|
454
|
+
},
|
|
455
|
+
"400": {
|
|
456
|
+
"description": "Missing or invalid query parameters",
|
|
457
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
458
|
+
},
|
|
459
|
+
"404": {
|
|
460
|
+
"description": "Polar not found",
|
|
461
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
"/polars/{id}/queries/speed": {
|
|
468
|
+
"get": {
|
|
469
|
+
"tags": ["polar-query"],
|
|
470
|
+
"operationId": "getPolarSpeed",
|
|
471
|
+
"summary": "Interpolated boat speed at a TWS/TWA point",
|
|
472
|
+
"description": "Returns the polar boat speed at the given true wind speed and angle, plus interpolation quality indicators.",
|
|
473
|
+
"parameters": [
|
|
474
|
+
{ "$ref": "#/components/parameters/polarId" },
|
|
475
|
+
{
|
|
476
|
+
"name": "tws",
|
|
477
|
+
"in": "query",
|
|
478
|
+
"required": true,
|
|
479
|
+
"description": "True wind speed in m/s",
|
|
480
|
+
"schema": { "type": "number", "minimum": 0 }
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
"name": "twa",
|
|
484
|
+
"in": "query",
|
|
485
|
+
"required": true,
|
|
486
|
+
"description": "True wind angle in radians (positive, 0 to pi)",
|
|
487
|
+
"schema": { "type": "number", "minimum": 0 }
|
|
488
|
+
}
|
|
489
|
+
],
|
|
490
|
+
"responses": {
|
|
491
|
+
"200": {
|
|
492
|
+
"description": "Interpolated speed result",
|
|
493
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PolarSpeedResult" } } }
|
|
494
|
+
},
|
|
495
|
+
"400": {
|
|
496
|
+
"description": "Missing or invalid query parameters",
|
|
497
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
498
|
+
},
|
|
499
|
+
"404": {
|
|
500
|
+
"description": "Polar not found",
|
|
501
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
"/polars/{id}/queries/targets": {
|
|
508
|
+
"get": {
|
|
509
|
+
"tags": ["polar-query"],
|
|
510
|
+
"operationId": "getPolarTargets",
|
|
511
|
+
"summary": "Optimal beat and run targets at a given TWS",
|
|
512
|
+
"description": "Returns the optimal beat and run angles, boat speeds, and VMG values at the given true wind speed.",
|
|
513
|
+
"parameters": [
|
|
514
|
+
{ "$ref": "#/components/parameters/polarId" },
|
|
515
|
+
{
|
|
516
|
+
"name": "tws",
|
|
517
|
+
"in": "query",
|
|
518
|
+
"required": true,
|
|
519
|
+
"description": "True wind speed in m/s",
|
|
520
|
+
"schema": { "type": "number", "minimum": 0 }
|
|
521
|
+
}
|
|
522
|
+
],
|
|
523
|
+
"responses": {
|
|
524
|
+
"200": {
|
|
525
|
+
"description": "Beat and run targets",
|
|
526
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PolarTargetsResult" } } }
|
|
527
|
+
},
|
|
528
|
+
"400": {
|
|
529
|
+
"description": "Missing or invalid query parameters",
|
|
530
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
531
|
+
},
|
|
532
|
+
"404": {
|
|
533
|
+
"description": "Polar not found",
|
|
534
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
"/polars/{id}/queries/performance": {
|
|
541
|
+
"get": {
|
|
542
|
+
"tags": ["polar-query"],
|
|
543
|
+
"operationId": "getPolarPerformance",
|
|
544
|
+
"summary": "Performance ratio against the polar",
|
|
545
|
+
"description": "Given actual TWS, TWA, and BSP, returns the polar target speed and performance ratio, plus VMG performance. VMG is upwind when TWA < 60 deg (1.047 rad), downwind when TWA > 120 deg (2.094 rad), and null for reaching in between.",
|
|
546
|
+
"parameters": [
|
|
547
|
+
{ "$ref": "#/components/parameters/polarId" },
|
|
548
|
+
{
|
|
549
|
+
"name": "tws",
|
|
550
|
+
"in": "query",
|
|
551
|
+
"required": true,
|
|
552
|
+
"description": "True wind speed in m/s",
|
|
553
|
+
"schema": { "type": "number", "minimum": 0 }
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
"name": "twa",
|
|
557
|
+
"in": "query",
|
|
558
|
+
"required": true,
|
|
559
|
+
"description": "True wind angle in radians (positive, 0 to pi)",
|
|
560
|
+
"schema": { "type": "number", "minimum": 0 }
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
"name": "bsp",
|
|
564
|
+
"in": "query",
|
|
565
|
+
"required": true,
|
|
566
|
+
"description": "Actual boat speed in m/s",
|
|
567
|
+
"schema": { "type": "number", "minimum": 0 }
|
|
568
|
+
}
|
|
569
|
+
],
|
|
570
|
+
"responses": {
|
|
571
|
+
"200": {
|
|
572
|
+
"description": "Performance result",
|
|
573
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PolarPerformanceResult" } } }
|
|
574
|
+
},
|
|
575
|
+
"400": {
|
|
576
|
+
"description": "Missing or invalid query parameters",
|
|
577
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
578
|
+
},
|
|
579
|
+
"404": {
|
|
580
|
+
"description": "Polar not found",
|
|
581
|
+
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
"components": {
|
|
590
|
+
"parameters": {
|
|
591
|
+
"polarId": {
|
|
592
|
+
"name": "id",
|
|
593
|
+
"in": "path",
|
|
594
|
+
"required": true,
|
|
595
|
+
"description": "Polar identifier",
|
|
596
|
+
"schema": { "type": "string" }
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
"schemas": {
|
|
600
|
+
|
|
601
|
+
"Error": {
|
|
602
|
+
"type": "object",
|
|
603
|
+
"required": ["error"],
|
|
604
|
+
"properties": {
|
|
605
|
+
"error": { "type": "string" }
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
"IdResponse": {
|
|
610
|
+
"type": "object",
|
|
611
|
+
"required": ["id"],
|
|
612
|
+
"properties": {
|
|
613
|
+
"id": { "type": "string" }
|
|
614
|
+
}
|
|
615
|
+
},
|
|
616
|
+
|
|
617
|
+
"ImportFormatDescriptor": {
|
|
618
|
+
"type": "object",
|
|
619
|
+
"required": ["id", "name"],
|
|
620
|
+
"properties": {
|
|
621
|
+
"id": { "type": "string", "description": "Stable text format identifier used in POST /imports/text/{format}." },
|
|
622
|
+
"name": { "type": "string", "description": "Human-readable format name." },
|
|
623
|
+
"description": { "type": "string", "description": "Short description of the source text format." }
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
"ImportSourceDescriptor": {
|
|
628
|
+
"type": "object",
|
|
629
|
+
"required": ["id", "name"],
|
|
630
|
+
"properties": {
|
|
631
|
+
"id": { "type": "string", "description": "Stable source identifier used in /imports/sources/{source}/... endpoints." },
|
|
632
|
+
"name": { "type": "string", "description": "Human-readable source name." },
|
|
633
|
+
"description": { "type": "string", "description": "Short description of the external source." },
|
|
634
|
+
"url": { "type": "string", "description": "Optional source home page or API URL." },
|
|
635
|
+
"available": { "type": "boolean", "description": "Whether the external source is currently reachable for live imports." },
|
|
636
|
+
"availabilityMessage": { "type": "string", "description": "Human-readable reason when the source is currently unavailable." }
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
|
|
640
|
+
"ExternalPolarSummary": {
|
|
641
|
+
"type": "object",
|
|
642
|
+
"required": ["externalId"],
|
|
643
|
+
"properties": {
|
|
644
|
+
"externalId": { "type": "string", "description": "Source-specific polar identifier used when importing a result. For the `orc` source, this is the certificate RefNo." },
|
|
645
|
+
"name": { "type": "string", "description": "Human-readable boat or polar name." },
|
|
646
|
+
"sailnumber": { "type": "string" },
|
|
647
|
+
"boatType": { "type": "string" },
|
|
648
|
+
"year": { "type": "integer", "description": "Certificate or measurement year if known." },
|
|
649
|
+
"source": { "type": "string", "description": "Source identifier that produced this result." },
|
|
650
|
+
"countryId": { "type": "string", "description": "Source-specific country identifier if available." },
|
|
651
|
+
"certificateName": { "type": "string", "description": "Certificate variant label if available." },
|
|
652
|
+
"familyName": { "type": "string", "description": "Source-specific certificate family label if available." }
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
|
|
656
|
+
"TextPolarImportRequest": {
|
|
657
|
+
"type": "object",
|
|
658
|
+
"required": ["content"],
|
|
659
|
+
"properties": {
|
|
660
|
+
"content": { "type": "string", "description": "Raw source text to parse and import." },
|
|
661
|
+
"name": { "type": "string", "description": "Optional human-readable label override." },
|
|
662
|
+
"sailnumber": { "type": "string" },
|
|
663
|
+
"boatType": { "type": "string" },
|
|
664
|
+
"year": { "type": "integer", "description": "Certificate or measurement year override." },
|
|
665
|
+
"source": { "type": "string", "description": "Source label override stored in the canonical metadata." },
|
|
666
|
+
"notes": { "type": "string", "description": "Optional notes stored in the canonical metadata." }
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
|
|
670
|
+
"PolarSummary": {
|
|
671
|
+
"type": "object",
|
|
672
|
+
"required": ["id"],
|
|
673
|
+
"properties": {
|
|
674
|
+
"id": { "type": "string", "description": "Opaque stable identifier for this polar" },
|
|
675
|
+
"name": { "type": "string", "description": "Human-readable label" },
|
|
676
|
+
"sailnumber": { "type": "string" },
|
|
677
|
+
"boatType": { "type": "string" },
|
|
678
|
+
"year": { "type": "integer", "description": "Certificate or measurement year" },
|
|
679
|
+
"source": { "type": "string", "description": "Data origin, e.g. orc, irc, measured, custom" }
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
|
|
683
|
+
"PolarMeta": {
|
|
684
|
+
"allOf": [
|
|
685
|
+
{ "$ref": "#/components/schemas/PolarSummary" },
|
|
686
|
+
{
|
|
687
|
+
"type": "object",
|
|
688
|
+
"properties": {
|
|
689
|
+
"notes": { "type": "string" },
|
|
690
|
+
"twsMin": { "type": "number", "description": "Lowest available TWS in the table (m/s)" },
|
|
691
|
+
"twsMax": { "type": "number", "description": "Highest available TWS in the table (m/s)" }
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
]
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
"PolarUnits": {
|
|
698
|
+
"type": "object",
|
|
699
|
+
"required": ["tws", "twa", "boatSpeed"],
|
|
700
|
+
"properties": {
|
|
701
|
+
"tws": { "type": "string", "enum": ["m/s"] },
|
|
702
|
+
"twa": { "type": "string", "enum": ["rad"] },
|
|
703
|
+
"boatSpeed": { "type": "string", "enum": ["m/s"] }
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
|
|
707
|
+
"PolarSymmetry": {
|
|
708
|
+
"type": "object",
|
|
709
|
+
"required": ["portStarboardSymmetric"],
|
|
710
|
+
"properties": {
|
|
711
|
+
"portStarboardSymmetric": {
|
|
712
|
+
"type": "boolean",
|
|
713
|
+
"description": "Whether the polar assumes the same performance on port and starboard tack."
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
|
|
718
|
+
"PolarAxes": {
|
|
719
|
+
"type": "object",
|
|
720
|
+
"required": ["tws", "twa"],
|
|
721
|
+
"properties": {
|
|
722
|
+
"tws": {
|
|
723
|
+
"type": "array",
|
|
724
|
+
"description": "Sorted true wind speed axis in m/s.",
|
|
725
|
+
"items": { "type": "number" }
|
|
726
|
+
},
|
|
727
|
+
"twa": {
|
|
728
|
+
"type": "array",
|
|
729
|
+
"description": "Sorted true wind angle axis in radians, expressed over 0 to pi.",
|
|
730
|
+
"items": { "type": "number" }
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
|
|
735
|
+
"PolarValues": {
|
|
736
|
+
"type": "object",
|
|
737
|
+
"required": ["boatSpeedMatrix"],
|
|
738
|
+
"properties": {
|
|
739
|
+
"boatSpeedMatrix": {
|
|
740
|
+
"type": "array",
|
|
741
|
+
"description": "Boat speed matrix in m/s indexed by TWS row then TWA column.",
|
|
742
|
+
"items": {
|
|
743
|
+
"type": "array",
|
|
744
|
+
"items": { "type": "number" }
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
|
|
750
|
+
"PolarDerivedRow": {
|
|
751
|
+
"type": "object",
|
|
752
|
+
"required": ["tws"],
|
|
753
|
+
"properties": {
|
|
754
|
+
"tws": { "type": "number", "description": "True wind speed in m/s for the derived row." },
|
|
755
|
+
"beat": { "nullable": true, "$ref": "#/components/schemas/PolarTargetPoint" },
|
|
756
|
+
"run": { "nullable": true, "$ref": "#/components/schemas/PolarTargetPoint" },
|
|
757
|
+
"maxSpeed": { "type": "number", "description": "Maximum boat speed in m/s at this TWS." },
|
|
758
|
+
"maxSpeedAngle": { "type": "number", "description": "True wind angle in radians at which maxSpeed occurs." }
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
|
|
762
|
+
"PolarDerived": {
|
|
763
|
+
"type": "object",
|
|
764
|
+
"properties": {
|
|
765
|
+
"rows": {
|
|
766
|
+
"type": "array",
|
|
767
|
+
"items": { "$ref": "#/components/schemas/PolarDerivedRow" }
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
},
|
|
771
|
+
|
|
772
|
+
"PolarResource": {
|
|
773
|
+
"allOf": [
|
|
774
|
+
{ "$ref": "#/components/schemas/PolarMeta" },
|
|
775
|
+
{
|
|
776
|
+
"type": "object",
|
|
777
|
+
"required": ["id", "kind", "schemaVersion", "units", "symmetry", "axes", "values"],
|
|
778
|
+
"properties": {
|
|
779
|
+
"kind": {
|
|
780
|
+
"type": "string",
|
|
781
|
+
"enum": ["polarTable"],
|
|
782
|
+
"description": "Canonical resource type identifier."
|
|
783
|
+
},
|
|
784
|
+
"schemaVersion": {
|
|
785
|
+
"type": "string",
|
|
786
|
+
"description": "Version of the canonical PolarTable resource schema."
|
|
787
|
+
},
|
|
788
|
+
"units": { "$ref": "#/components/schemas/PolarUnits" },
|
|
789
|
+
"symmetry": { "$ref": "#/components/schemas/PolarSymmetry" },
|
|
790
|
+
"axes": { "$ref": "#/components/schemas/PolarAxes" },
|
|
791
|
+
"values": { "$ref": "#/components/schemas/PolarValues" },
|
|
792
|
+
"derived": { "$ref": "#/components/schemas/PolarDerived" }
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
]
|
|
796
|
+
},
|
|
797
|
+
|
|
798
|
+
"PolarResourceBody": {
|
|
799
|
+
"type": "object",
|
|
800
|
+
"required": ["kind", "schemaVersion", "units", "symmetry", "axes", "values"],
|
|
801
|
+
"properties": {
|
|
802
|
+
"name": { "type": "string", "description": "Human-readable label" },
|
|
803
|
+
"sailnumber": { "type": "string" },
|
|
804
|
+
"boatType": { "type": "string" },
|
|
805
|
+
"year": { "type": "integer", "description": "Certificate or measurement year" },
|
|
806
|
+
"source": { "type": "string", "description": "Data origin, e.g. orc, irc, measured, custom" },
|
|
807
|
+
"notes": { "type": "string" },
|
|
808
|
+
"kind": {
|
|
809
|
+
"type": "string",
|
|
810
|
+
"enum": ["polarTable"],
|
|
811
|
+
"description": "Canonical resource type identifier."
|
|
812
|
+
},
|
|
813
|
+
"schemaVersion": {
|
|
814
|
+
"type": "string",
|
|
815
|
+
"description": "Version of the canonical PolarTable resource schema."
|
|
816
|
+
},
|
|
817
|
+
"units": { "$ref": "#/components/schemas/PolarUnits" },
|
|
818
|
+
"symmetry": { "$ref": "#/components/schemas/PolarSymmetry" },
|
|
819
|
+
"axes": { "$ref": "#/components/schemas/PolarAxes" },
|
|
820
|
+
"values": { "$ref": "#/components/schemas/PolarValues" },
|
|
821
|
+
"derived": { "$ref": "#/components/schemas/PolarDerived" }
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
|
|
825
|
+
"PolarTargetPoint": {
|
|
826
|
+
"type": "object",
|
|
827
|
+
"description": "Optimal sailing target (beat or run).",
|
|
828
|
+
"properties": {
|
|
829
|
+
"twa": { "type": "number", "description": "Optimal true wind angle (rad)" },
|
|
830
|
+
"tbs": { "type": "number", "description": "Boat speed at optimal angle (m/s)" },
|
|
831
|
+
"vmg": { "type": "number", "description": "Velocity made good at optimal angle (m/s)" }
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
|
|
835
|
+
"PolarCurveResult": {
|
|
836
|
+
"type": "object",
|
|
837
|
+
"properties": {
|
|
838
|
+
"tws": { "type": "number", "description": "Requested TWS (m/s)" },
|
|
839
|
+
"points": {
|
|
840
|
+
"type": "array",
|
|
841
|
+
"description": "Sampled points from 0 to pi",
|
|
842
|
+
"items": {
|
|
843
|
+
"type": "object",
|
|
844
|
+
"properties": {
|
|
845
|
+
"twa": { "type": "number", "description": "True wind angle (rad)" },
|
|
846
|
+
"tbs": { "type": "number", "description": "Boat speed (m/s)" }
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
"beat": { "nullable": true, "$ref": "#/components/schemas/PolarTargetPoint" },
|
|
851
|
+
"run": { "nullable": true, "$ref": "#/components/schemas/PolarTargetPoint" }
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
|
|
855
|
+
"PolarSpeedResult": {
|
|
856
|
+
"type": "object",
|
|
857
|
+
"properties": {
|
|
858
|
+
"tws": { "type": "number", "description": "True wind speed (m/s)" },
|
|
859
|
+
"twa": { "type": "number", "description": "True wind angle (rad)" },
|
|
860
|
+
"tbs": { "type": "number", "nullable": true, "description": "Interpolated boat speed (m/s), null if outside polar range" },
|
|
861
|
+
"state": {
|
|
862
|
+
"type": "object",
|
|
863
|
+
"description": "Interpolation quality indicators.",
|
|
864
|
+
"properties": {
|
|
865
|
+
"tws": { "type": "string", "enum": ["below_range", "in_range", "above_range"] },
|
|
866
|
+
"twa": { "type": "string", "enum": ["in_irons", "pinching", "in_range", "extrapolated", "above_range"] }
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
|
|
872
|
+
"PolarTargetsResult": {
|
|
873
|
+
"type": "object",
|
|
874
|
+
"properties": {
|
|
875
|
+
"tws": { "type": "number", "description": "True wind speed (m/s)" },
|
|
876
|
+
"beat": { "nullable": true, "$ref": "#/components/schemas/PolarTargetPoint" },
|
|
877
|
+
"run": { "nullable": true, "$ref": "#/components/schemas/PolarTargetPoint" }
|
|
878
|
+
}
|
|
879
|
+
},
|
|
880
|
+
|
|
881
|
+
"PerformanceMetric": {
|
|
882
|
+
"type": "object",
|
|
883
|
+
"properties": {
|
|
884
|
+
"polar": { "type": "number", "description": "Polar reference value (m/s)" },
|
|
885
|
+
"actual": { "type": "number", "description": "Actual measured value (m/s)" },
|
|
886
|
+
"ratio": { "type": "number", "description": "actual / polar" }
|
|
887
|
+
}
|
|
888
|
+
},
|
|
889
|
+
|
|
890
|
+
"PolarPerformanceResult": {
|
|
891
|
+
"type": "object",
|
|
892
|
+
"properties": {
|
|
893
|
+
"tws": { "type": "number", "description": "True wind speed (m/s)" },
|
|
894
|
+
"twa": { "type": "number", "description": "True wind angle (rad)" },
|
|
895
|
+
"bsp": { "type": "number", "description": "Actual boat speed (m/s)" },
|
|
896
|
+
"direction": {
|
|
897
|
+
"type": "string",
|
|
898
|
+
"enum": ["upwind", "reaching", "downwind"],
|
|
899
|
+
"description": "Point of sail. upwind: TWA < 60 deg (1.047 rad); downwind: TWA > 120 deg (2.094 rad); reaching: between."
|
|
900
|
+
},
|
|
901
|
+
"speed": { "$ref": "#/components/schemas/PerformanceMetric" },
|
|
902
|
+
"vmg": {
|
|
903
|
+
"nullable": true,
|
|
904
|
+
"$ref": "#/components/schemas/PerformanceMetric",
|
|
905
|
+
"description": "null when direction is reaching"
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-polar-performance-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Calculates live sailing performance from polar data, with built-in polar management and import tools.",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"icon.png",
|
|
11
|
+
"openApi.json",
|
|
11
12
|
"plugin/",
|
|
12
13
|
"public/",
|
|
13
14
|
"test/",
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
"prepublishOnly": "npm test"
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
|
-
"signalkutilities": "^3.0
|
|
29
|
+
"signalkutilities": "^3.1.0"
|
|
29
30
|
},
|
|
30
31
|
"repository": {
|
|
31
32
|
"type": "git",
|
package/plugin/index.js
CHANGED
|
@@ -17,6 +17,8 @@ const {
|
|
|
17
17
|
|
|
18
18
|
const CURRENT_SETTINGS_VERSION = 1
|
|
19
19
|
|
|
20
|
+
const STALE_RESUBSCRIBE_PERIOD = 60000 // ms — idle period before live input subscriptions are re-established
|
|
21
|
+
|
|
20
22
|
const DEFAULT_SETTINGS = {
|
|
21
23
|
settingsVersion: CURRENT_SETTINGS_VERSION,
|
|
22
24
|
activePolar: '',
|
|
@@ -52,6 +54,8 @@ module.exports = (app) => {
|
|
|
52
54
|
let bspSmoother = null
|
|
53
55
|
let hdgSmoother = null
|
|
54
56
|
let metaSentPaths = new Set() // tracks paths that have had metadata emitted
|
|
57
|
+
let lifecycleWarningMap = new Map()
|
|
58
|
+
let lifecycleWarnings = []
|
|
55
59
|
|
|
56
60
|
// Last-computed output values, updated by computeAndSend on every cycle.
|
|
57
61
|
// Keys match the settings keys; values are SI numbers or null.
|
|
@@ -75,6 +79,52 @@ module.exports = (app) => {
|
|
|
75
79
|
// Helpers
|
|
76
80
|
// ---------------------------------------------------------------------------
|
|
77
81
|
|
|
82
|
+
function _setLifecycleWarning(id, status, path) {
|
|
83
|
+
const safePath = path || 'unknown path'
|
|
84
|
+
const message = status === 'idle'
|
|
85
|
+
? `Input ${id} is idle on ${safePath}; resubscribing`
|
|
86
|
+
: `Input ${id} is stale on ${safePath}`
|
|
87
|
+
lifecycleWarningMap.set(id, {
|
|
88
|
+
id,
|
|
89
|
+
status,
|
|
90
|
+
path: safePath,
|
|
91
|
+
message,
|
|
92
|
+
updatedAt: Date.now()
|
|
93
|
+
})
|
|
94
|
+
lifecycleWarnings = Array.from(lifecycleWarningMap.values())
|
|
95
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _clearLifecycleWarning(id) {
|
|
99
|
+
if (!lifecycleWarningMap.has(id)) return
|
|
100
|
+
lifecycleWarningMap.delete(id)
|
|
101
|
+
lifecycleWarnings = Array.from(lifecycleWarningMap.values())
|
|
102
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function _wireHandlerWatchdog({ id, getPath, unsubscribe, subscribe }) {
|
|
106
|
+
return {
|
|
107
|
+
idlePeriod: STALE_RESUBSCRIBE_PERIOD,
|
|
108
|
+
onDelta: () => {
|
|
109
|
+
_clearLifecycleWarning(id)
|
|
110
|
+
},
|
|
111
|
+
onStale: () => {
|
|
112
|
+
if (!isRunning) return
|
|
113
|
+
const path = getPath()
|
|
114
|
+
app.debug(`[${plugin.id}] stale input ${id} on ${path}`)
|
|
115
|
+
_setLifecycleWarning(id, 'stale', path)
|
|
116
|
+
},
|
|
117
|
+
onIdle: () => {
|
|
118
|
+
if (!isRunning) return
|
|
119
|
+
const path = getPath()
|
|
120
|
+
app.debug(`[${plugin.id}] idle input ${id} on ${path}; resubscribing`)
|
|
121
|
+
_setLifecycleWarning(id, 'idle', path)
|
|
122
|
+
unsubscribe()
|
|
123
|
+
subscribe()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
78
128
|
function getSmootherClass(type) {
|
|
79
129
|
switch (type) {
|
|
80
130
|
case 'None': return BaseSmoother
|
|
@@ -184,11 +234,13 @@ module.exports = (app) => {
|
|
|
184
234
|
if (hdgSmoother) { hdgSmoother.setSmootherClass(SC); hdgSmoother.setSmootherOptions(so) }
|
|
185
235
|
}
|
|
186
236
|
|
|
187
|
-
// Speed source change — re-point the BSP handler
|
|
237
|
+
// Speed source change — re-point the BSP handler with an explicit unsubscribe/subscribe cycle.
|
|
188
238
|
if (keys.includes('useSOG') && bspSmoother) {
|
|
239
|
+
bspSmoother.unsubscribe()
|
|
189
240
|
bspSmoother.handler.path = settings.useSOG
|
|
190
241
|
? 'navigation.speedOverGround'
|
|
191
242
|
: 'navigation.speedThroughWater'
|
|
243
|
+
bspSmoother.subscribe()
|
|
192
244
|
}
|
|
193
245
|
|
|
194
246
|
// Tack heading toggle
|
|
@@ -199,7 +251,12 @@ module.exports = (app) => {
|
|
|
199
251
|
hdgSmoother = new SmoothedAngle(app, plugin.id, 'hdg', 'navigation.headingTrue', {
|
|
200
252
|
angleRange: '0to2pi',
|
|
201
253
|
SmootherClass: SC,
|
|
202
|
-
smootherOptions: so
|
|
254
|
+
smootherOptions: so,
|
|
255
|
+
..._wireHandlerWatchdog({
|
|
256
|
+
get path() { return hdgSmoother?.handler?.path ?? 'navigation.headingTrue' },
|
|
257
|
+
unsubscribe: () => hdgSmoother?.unsubscribe(),
|
|
258
|
+
subscribe: () => hdgSmoother?.subscribe(false, true),
|
|
259
|
+
})
|
|
203
260
|
})
|
|
204
261
|
} else if (!settings.tackTrue && hdgSmoother) {
|
|
205
262
|
hdgSmoother.terminate()
|
|
@@ -374,14 +431,11 @@ module.exports = (app) => {
|
|
|
374
431
|
}
|
|
375
432
|
}
|
|
376
433
|
} else {
|
|
377
|
-
//
|
|
434
|
+
// Clear these paths so no stale non-zero value remains on the SK bus
|
|
378
435
|
if (settings.polarSpeed) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
'Actual boat speed divided by polar speed.')
|
|
383
|
-
add('performance.targetSpeed', 0, 'm/s',
|
|
384
|
-
'Boat speed needed to achieve target VMG at the optimal angle.')
|
|
436
|
+
values.push({ path: 'performance.polarSpeed', value: null })
|
|
437
|
+
values.push({ path: 'performance.polarSpeedRatio', value: null })
|
|
438
|
+
values.push({ path: 'performance.targetSpeed', value: null })
|
|
385
439
|
}
|
|
386
440
|
}
|
|
387
441
|
|
|
@@ -850,7 +904,7 @@ module.exports = (app) => {
|
|
|
850
904
|
// Returns null for any field not yet available (plugin not running,
|
|
851
905
|
// no BSP source, polar not loaded, or boat in irons).
|
|
852
906
|
router.get('/live', (req, res) => {
|
|
853
|
-
const wind = windSmoother ? windSmoother.polarValue : null
|
|
907
|
+
const wind = windSmoother?.ready ? windSmoother.polarValue : null
|
|
854
908
|
const TWS = wind ? wind.magnitude : null
|
|
855
909
|
const TWAsigned = wind ? wind.angle : null
|
|
856
910
|
const TWA = Number.isFinite(TWAsigned) ? Math.abs(TWAsigned) : null
|
|
@@ -896,7 +950,7 @@ module.exports = (app) => {
|
|
|
896
950
|
const rawBsp = si(bspSmoother?.handler?.value ?? null)
|
|
897
951
|
const rawHdg = si(hdgSmoother?.handler?.value ?? null)
|
|
898
952
|
|
|
899
|
-
const wind = windSmoother ? windSmoother.polarValue : null
|
|
953
|
+
const wind = windSmoother?.ready ? windSmoother.polarValue : null
|
|
900
954
|
const TWS = wind ? wind.magnitude : null
|
|
901
955
|
const TWAsigned = wind ? wind.angle : null
|
|
902
956
|
const BSP = bspSmoother ? bspSmoother.value : null
|
|
@@ -941,7 +995,8 @@ module.exports = (app) => {
|
|
|
941
995
|
}
|
|
942
996
|
},
|
|
943
997
|
outputs,
|
|
944
|
-
polarState
|
|
998
|
+
polarState,
|
|
999
|
+
lifecycleWarnings
|
|
945
1000
|
})
|
|
946
1001
|
})
|
|
947
1002
|
|
|
@@ -1062,6 +1117,8 @@ module.exports = (app) => {
|
|
|
1062
1117
|
|
|
1063
1118
|
start(options) {
|
|
1064
1119
|
metaSentPaths = new Set() // reset so metadata is re-emitted after restart
|
|
1120
|
+
lifecycleWarningMap = new Map()
|
|
1121
|
+
lifecycleWarnings = []
|
|
1065
1122
|
|
|
1066
1123
|
store = new PolarFileStore(app.getDataDirPath())
|
|
1067
1124
|
importService = new ImportService(store)
|
|
@@ -1100,7 +1157,17 @@ module.exports = (app) => {
|
|
|
1100
1157
|
app,
|
|
1101
1158
|
pluginId: plugin.id,
|
|
1102
1159
|
SmootherClass,
|
|
1103
|
-
smootherOptions
|
|
1160
|
+
smootherOptions,
|
|
1161
|
+
..._wireHandlerWatchdog({
|
|
1162
|
+
id: 'wind.smoothed',
|
|
1163
|
+
getPath: () => `${windSmoother?.polar?.pathMagnitude ?? 'environment.wind.speedTrue'}, ${windSmoother?.polar?.pathAngle ?? 'environment.wind.angleTrueWater'}`,
|
|
1164
|
+
unsubscribe: () => windSmoother?.unsubscribe(),
|
|
1165
|
+
subscribe: () => windSmoother?.subscribe(true, true),
|
|
1166
|
+
}),
|
|
1167
|
+
onDelta: () => {
|
|
1168
|
+
_clearLifecycleWarning('wind.smoothed')
|
|
1169
|
+
computeAndSend()
|
|
1170
|
+
}
|
|
1104
1171
|
})
|
|
1105
1172
|
|
|
1106
1173
|
// Boat speed (STW or SOG depending on settings)
|
|
@@ -1113,7 +1180,13 @@ module.exports = (app) => {
|
|
|
1113
1180
|
app,
|
|
1114
1181
|
pluginId: plugin.id,
|
|
1115
1182
|
SmootherClass,
|
|
1116
|
-
smootherOptions
|
|
1183
|
+
smootherOptions,
|
|
1184
|
+
..._wireHandlerWatchdog({
|
|
1185
|
+
id: 'bsp.smoothed',
|
|
1186
|
+
getPath: () => bspSmoother?.handler?.path ?? (settings.useSOG ? 'navigation.speedOverGround' : 'navigation.speedThroughWater'),
|
|
1187
|
+
unsubscribe: () => bspSmoother?.unsubscribe(),
|
|
1188
|
+
subscribe: () => bspSmoother?.subscribe(),
|
|
1189
|
+
})
|
|
1117
1190
|
})
|
|
1118
1191
|
|
|
1119
1192
|
// Optional heading handler for opposite-tack computation.
|
|
@@ -1123,13 +1196,16 @@ module.exports = (app) => {
|
|
|
1123
1196
|
hdgSmoother = new SmoothedAngle(app, plugin.id, 'hdg', 'navigation.headingTrue', {
|
|
1124
1197
|
angleRange: '0to2pi',
|
|
1125
1198
|
SmootherClass,
|
|
1126
|
-
smootherOptions
|
|
1199
|
+
smootherOptions,
|
|
1200
|
+
..._wireHandlerWatchdog({
|
|
1201
|
+
id: 'hdg.smoothed',
|
|
1202
|
+
getPath: () => hdgSmoother?.handler?.path ?? 'navigation.headingTrue',
|
|
1203
|
+
unsubscribe: () => hdgSmoother?.unsubscribe(),
|
|
1204
|
+
subscribe: () => hdgSmoother?.subscribe(false, true),
|
|
1205
|
+
})
|
|
1127
1206
|
})
|
|
1128
1207
|
}
|
|
1129
1208
|
|
|
1130
|
-
// Trigger performance computation whenever a new smoothed wind value is ready
|
|
1131
|
-
windSmoother.onChange = computeAndSend
|
|
1132
|
-
|
|
1133
1209
|
isRunning = true
|
|
1134
1210
|
app.debug('Plugin started')
|
|
1135
1211
|
},
|
|
@@ -1141,6 +1217,8 @@ module.exports = (app) => {
|
|
|
1141
1217
|
if (windSmoother) { windSmoother.terminate(); windSmoother = null }
|
|
1142
1218
|
if (bspSmoother) { bspSmoother.terminate(); bspSmoother = null }
|
|
1143
1219
|
if (hdgSmoother) { hdgSmoother.terminate(); hdgSmoother = null }
|
|
1220
|
+
lifecycleWarningMap = new Map()
|
|
1221
|
+
lifecycleWarnings = []
|
|
1144
1222
|
app.debug('Plugin stopped')
|
|
1145
1223
|
}
|
|
1146
1224
|
}
|
package/public/app.js
CHANGED
|
@@ -309,6 +309,7 @@ let settings = null
|
|
|
309
309
|
let polarsList = []
|
|
310
310
|
let importFormats = []
|
|
311
311
|
let internetOnline = false
|
|
312
|
+
let lifecycleWarnings = []
|
|
312
313
|
|
|
313
314
|
// Canvas state
|
|
314
315
|
let polar = null
|
|
@@ -443,6 +444,7 @@ async function refreshLive() {
|
|
|
443
444
|
const st = await apiGet('/status')
|
|
444
445
|
if (st) {
|
|
445
446
|
statusData = st
|
|
447
|
+
lifecycleWarnings = Array.isArray(st.lifecycleWarnings) ? st.lifecycleWarnings : []
|
|
446
448
|
// Populate rawValues and outputValues from /status for the Inputs/Outputs pages
|
|
447
449
|
if (st.inputs) {
|
|
448
450
|
rawValues.tws = st.inputs.raw.tws
|
|
@@ -690,9 +692,9 @@ function _tickInputs() {
|
|
|
690
692
|
setStale('in-bsp-smo', d?.bsp == null)
|
|
691
693
|
|
|
692
694
|
const warns = []
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
695
|
+
lifecycleWarnings.forEach(w => {
|
|
696
|
+
if (w && typeof w.message === 'string') warns.push(w.message)
|
|
697
|
+
})
|
|
696
698
|
updateWarnings(document.getElementById('in-warnings'), warns)
|
|
697
699
|
}
|
|
698
700
|
|