signalk-performance-processor 0.0.1 → 0.0.3
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 +15 -0
- package/README.md +64 -10
- package/index.js +70 -48
- package/lib/defaults.js +4 -2
- package/lib/processor.js +51 -7
- package/package.json +2 -2
- package/public/app.js +19 -7
- package/public/index.html +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.3 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
Still a proof of concept: run it in Shadow mode alongside your instruments.
|
|
6
|
+
|
|
7
|
+
- Fix: the Calibration and Settings pages were empty on a real server. The plugin's `/config` endpoint was shadowed by the server's own route of that name; it is now `/calibration`, and the webapp says so if the calibration cannot be loaded.
|
|
8
|
+
- README and App Store description explain the problem this solves, who it is for, and the results on the boat it was built for.
|
|
9
|
+
|
|
10
|
+
## 0.0.2 — 2026-10-29
|
|
11
|
+
|
|
12
|
+
Still a proof of concept: run it in Shadow mode alongside your instruments.
|
|
13
|
+
|
|
14
|
+
- Heading for TWD and current comes from `navigation.headingTrue` if present, otherwise magnetic heading plus `navigation.magneticVariation` (normally from the GPS). The variation in Settings is now only a fallback, and the Live page says which is in use.
|
|
15
|
+
- Configuration is edited only in the webapp and saved to `config.json` in the plugin's data directory. The admin Plugin Config form no longer shows the tables, so saving it cannot overwrite them. A configuration saved by 0.0.1 is picked up until the webapp first saves.
|
|
16
|
+
- The configuration file records its format version (`"schema": 1`). Future releases upgrade older files as they load; a file from a newer release is refused and never overwritten.
|
|
17
|
+
|
|
3
18
|
## 0.0.1 — 2026-10-29
|
|
4
19
|
|
|
5
20
|
First release, a proof of concept. Run it in Shadow mode alongside your instruments before relying on it.
|
package/README.md
CHANGED
|
@@ -2,13 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
# signalk-performance-processor
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**True wind direction that holds steady when you tack.**
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
If your TWD swings 5–10° every time you tack or gybe, the wind hasn't shifted: your true wind calibration is off. It makes real shifts hard to call, puts laylines in the wrong place, and feeds bad numbers into your polars and targets.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
This plugin does the calibration job of a B&G H5000-class instrument processor inside Signal K. It corrects boat speed and leeway, then applies H5000-style TWA and TWS correction tables, and publishes corrected true wind and current. You edit everything in a webapp and can watch each correction work live. Run it in **Shadow mode** first: it publishes alongside your existing instruments without changing anything they do.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
> **Proof of concept (0.0.3).** Compare it against your instruments in Shadow mode before relying on it. See the [changelog](CHANGELOG.md).
|
|
12
|
+
|
|
13
|
+
## Why it exists
|
|
14
|
+
|
|
15
|
+
True wind is worked out from apparent wind, boat speed and heading, so it inherits every error in them. The masthead sensor reads a bent airflow upwind, the paddlewheel misreads when heeled, and the boat slides sideways. None of these is the same on every point of sail, and some flip from one tack to the other. That is why an uncalibrated TWD jumps through every tack.
|
|
16
|
+
|
|
17
|
+
Instrument processors fix this with calibration tables, but:
|
|
18
|
+
|
|
19
|
+
- **The tables are locked in the box.** You edit them through display menus, cannot see what each one is doing at any moment, and keep no history of what you changed or why.
|
|
20
|
+
- **Older and simpler systems have less to work with.** An H2000, for example, has a TWS correction that only applies downwind, and many systems have no tables at all.
|
|
21
|
+
- **Formula-based corrections don't fit every boat.** A single upwash formula cannot follow a boat whose correction is twice as big in light air as in a breeze at the same apparent wind angle. That was the case on the boat this was built for; a table can follow it.
|
|
22
|
+
|
|
23
|
+
This plugin moves the calibration into Signal K, where you can see it, change it, log it and fit it from your own race data:
|
|
24
|
+
|
|
25
|
+
- **The same shape as an H5000:** boat speed linearity and heel correction, leeway, and TWA/TWS tables by wind speed with upwind, reach and downwind rows. An existing calibration carries across, and the H5000 tuning rule ("TWD lifts on every tack: take half the error off TWA") works unchanged.
|
|
26
|
+
- **Every correction has a switch and a live readout,** so you can see what each one does to the numbers right now.
|
|
27
|
+
- **The calibration is a versioned JSON file,** easy to back up, compare and fit from logged races offline.
|
|
28
|
+
- **It is safe to try.** Shadow mode publishes `…Corrected` paths beside your instruments. Replace mode takes over the standard paths only when you choose it.
|
|
29
|
+
|
|
30
|
+
### On the boat it was built for
|
|
31
|
+
|
|
32
|
+
From the offline model the defaults were fitted with: trained on 2025 race logs, tested on Jan–Mar 2026 races it had not seen, and measured by how far true wind direction jumps through each manoeuvre (median). The shipped defaults differ slightly (the owner's leeway K, and the TWA table resampled to H5000 breakpoints).
|
|
33
|
+
|
|
34
|
+
| | Uncorrected | Instrument (H2000) | This plugin |
|
|
35
|
+
|---|---|---|---|
|
|
36
|
+
| Tacks (41) | 6.2° | 2.5° | 2.2° |
|
|
37
|
+
| Gybes (5) | 2.4° | 4.7° | 1.1° |
|
|
38
|
+
| Roundings (38) | 9.3° | 9.7° | 7.3° |
|
|
39
|
+
| Current step through manoeuvres | 0.65 kn | 0.65 kn | 0.37 kn |
|
|
40
|
+
|
|
41
|
+
These figures are one boat's, and the defaults are that boat's. On a different boat, start from the defaults in Shadow mode and refit. After the boat's own rig and log changes in late 2026, the defaults needed refitting too.
|
|
42
|
+
|
|
43
|
+
## Who it's for
|
|
44
|
+
|
|
45
|
+
- **Racing sailors running Signal K** who want TWD they can call shifts on, and polars built on corrected wind.
|
|
46
|
+
- **Boats with an older or limited instrument processor,** or none, that still have apparent wind, a paddlewheel, heading and heel on the network.
|
|
47
|
+
- **Anyone who wants to see and log** what their wind calibration is doing, rather than trust a box.
|
|
48
|
+
|
|
49
|
+
You need apparent wind, boat speed through the water and heading in Signal K. Heel (`navigation.attitude`) enables the heel and leeway corrections; SOG and COG enable current.
|
|
12
50
|
|
|
13
51
|
## What it does
|
|
14
52
|
|
|
@@ -29,7 +67,7 @@ The corrections run in this order, each on the output of the one before:
|
|
|
29
67
|
|---|---|
|
|
30
68
|
| Apparent wind | `environment.wind.angleApparent`, `environment.wind.speedApparent` |
|
|
31
69
|
| Speed through water | `navigation.speedThroughWater` |
|
|
32
|
-
| Heading | `navigation.headingTrue
|
|
70
|
+
| Heading | `navigation.headingTrue`; otherwise `navigation.headingMagnetic` + `navigation.magneticVariation` (normally from the GPS); otherwise magnetic heading + the fallback variation in Settings |
|
|
33
71
|
| Heel | `navigation.attitude` (roll) |
|
|
34
72
|
| SOG and COG | `navigation.speedOverGround`, `navigation.courseOverGroundTrue` |
|
|
35
73
|
|
|
@@ -63,9 +101,9 @@ Open **Webapps → Performance Processor**.
|
|
|
63
101
|
|
|
64
102
|
- **Live**: the outputs next to their uncorrected values, what each step is doing right now, and which source and how fresh each input is.
|
|
65
103
|
- **Calibration**: every correction in pipeline order with an on/off switch. Select one to edit its values, then **Save**. **Reset to defaults** puts back this boat's fitted values for that correction.
|
|
66
|
-
- **Settings**: Shadow or Replace, magnetic variation, and which source to read for each input.
|
|
104
|
+
- **Settings**: Shadow or Replace, the fallback magnetic variation, and which source to read for each input.
|
|
67
105
|
|
|
68
|
-
|
|
106
|
+
All configuration lives in the webapp. It is saved to `config.json` in the plugin's data directory (under `~/.signalk/plugin-config-data/`), not in the admin **Plugin Config** form, which only points here; saving that form cannot overwrite the tables. Saving needs write access when Signal K security is on.
|
|
69
107
|
|
|
70
108
|
## Defaults for this boat
|
|
71
109
|
|
|
@@ -83,7 +121,23 @@ Fitted to the 2025 race logs and checked on Jan–Mar 2026 races not used in fit
|
|
|
83
121
|
|
|
84
122
|
The reach row is provisional: it rests on roundings only.
|
|
85
123
|
- **TWS table (%):** off. Downwind −11.1% at 15 kn and −14.4% from 20 kn; light air and upwind left at 0.
|
|
86
|
-
- **
|
|
124
|
+
- **Fallback variation:** 12.8° E, used only when nothing on the boat sends true heading or magnetic variation. The Live page shows which applies.
|
|
125
|
+
|
|
126
|
+
## Configuration file
|
|
127
|
+
|
|
128
|
+
`config.json` in the plugin's data directory, written by the webapp:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"schema": 1,
|
|
133
|
+
"mode": "shadow",
|
|
134
|
+
"variation": 12.8,
|
|
135
|
+
"sources": { "apparentWind": "", "stw": "", "heading": "", "attitude": "", "gps": "", "variation": "" },
|
|
136
|
+
"corrections": { "stw": { … }, "leeway": { … }, "twa": { … }, "tws": { … } }
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`schema` is the version of the file's shape. A release that changes the shape bumps `CONFIG_SCHEMA` in `lib/processor.js` and adds a migration from the previous version, so older files are upgraded as they load and nothing is reset. A file without `schema` (saved by 0.0.1) is read as schema 1. A file from a newer release is refused rather than guessed at, and is never overwritten by an older plugin.
|
|
87
141
|
|
|
88
142
|
## Adding a correction
|
|
89
143
|
|
|
@@ -93,8 +147,8 @@ Corrections live in `lib/processor.js` as entries in `CORRECTIONS`, each with a
|
|
|
93
147
|
|
|
94
148
|
Under `/plugins/signalk-performance-processor`:
|
|
95
149
|
|
|
96
|
-
- `GET /
|
|
97
|
-
- `PUT /
|
|
150
|
+
- `GET /calibration` returns the configuration in use and the defaults.
|
|
151
|
+
- `PUT /calibration` validates, saves and restarts with a new configuration. It returns 400 with the reasons if the configuration is invalid.
|
|
98
152
|
- `GET /live` returns the latest result, each step, input ages and the sources seen.
|
|
99
153
|
|
|
100
154
|
## Development
|
package/index.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
3
5
|
const { DEFAULTS } = require('./lib/defaults')
|
|
4
|
-
const { process, correctInputs, normalizeConfig, CORRECTIONS } = require('./lib/processor')
|
|
6
|
+
const { process, correctInputs, normalizeConfig, CORRECTIONS, CONFIG_SCHEMA } = require('./lib/processor')
|
|
5
7
|
|
|
6
8
|
const MS_TO_KN = 1.94384
|
|
7
9
|
const RAD_TO_DEG = 180 / Math.PI
|
|
8
10
|
const STALE_MS = 3000 // an input older than this is not used
|
|
11
|
+
// Variation changes over days, not seconds; a GPS that drops it briefly should not
|
|
12
|
+
// push heading onto the fallback value.
|
|
13
|
+
const VARIATION_MAX_AGE_MS = 10 * 60 * 1000
|
|
14
|
+
const CONFIG_FILE = 'config.json'
|
|
9
15
|
const STATUS_INTERVAL_MS = 1000
|
|
10
16
|
// Bounds for sanity-checking sensor input. Anything outside these is a fault, not a reading.
|
|
11
17
|
const MAX_PLAUSIBLE_STW_KN = 60
|
|
@@ -21,7 +27,8 @@ const INPUTS = {
|
|
|
21
27
|
stw: ['navigation.speedThroughWater'],
|
|
22
28
|
heading: ['navigation.headingMagnetic', 'navigation.headingTrue'],
|
|
23
29
|
attitude: ['navigation.attitude'],
|
|
24
|
-
gps: ['navigation.speedOverGround', 'navigation.courseOverGroundTrue']
|
|
30
|
+
gps: ['navigation.speedOverGround', 'navigation.courseOverGroundTrue'],
|
|
31
|
+
variation: ['navigation.magneticVariation']
|
|
25
32
|
}
|
|
26
33
|
const INPUT_OF = Object.fromEntries(Object.entries(INPUTS).flatMap(([k, ps]) => ps.map(p => [p, k])))
|
|
27
34
|
|
|
@@ -41,7 +48,7 @@ module.exports = function (app) {
|
|
|
41
48
|
const plugin = {
|
|
42
49
|
id: 'signalk-performance-processor',
|
|
43
50
|
name: 'gh - Performance Processor',
|
|
44
|
-
description: '
|
|
51
|
+
description: 'Steady true wind through tacks and gybes: H5000-style calibration of boat speed, leeway and TWA/TWS, configured in the Performance Processor webapp'
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
let config = null
|
|
@@ -56,7 +63,14 @@ module.exports = function (app) {
|
|
|
56
63
|
let lastResultAt = 0
|
|
57
64
|
let lastStatus = ''
|
|
58
65
|
|
|
59
|
-
plugin
|
|
66
|
+
// Everything is configured in the webapp and kept in this plugin's data directory,
|
|
67
|
+
// not in the plugin options: the admin form would otherwise show the tables, and
|
|
68
|
+
// saving it could overwrite the webapp's values.
|
|
69
|
+
plugin.schema = {
|
|
70
|
+
type: 'object',
|
|
71
|
+
description: 'Calibration tables and settings are edited in Webapps → Performance Processor.',
|
|
72
|
+
properties: {}
|
|
73
|
+
}
|
|
60
74
|
|
|
61
75
|
function outPath (key) {
|
|
62
76
|
return config.mode === 'shadow' ? OUTPUTS[key].path + SHADOW_SUFFIX : OUTPUTS[key].path
|
|
@@ -80,9 +94,9 @@ module.exports = function (app) {
|
|
|
80
94
|
}
|
|
81
95
|
const isStwEcho = ms => recentStw.some(o => Math.abs(o - ms) < 1e-12)
|
|
82
96
|
|
|
83
|
-
function fresh (
|
|
84
|
-
const l = latest[
|
|
85
|
-
return l && now - l.at <
|
|
97
|
+
function fresh (p, now, maxAge = STALE_MS) {
|
|
98
|
+
const l = latest[p]
|
|
99
|
+
return l && now - l.at < maxAge ? l.value : undefined
|
|
86
100
|
}
|
|
87
101
|
|
|
88
102
|
// Refresh on a change of state, and at most once a second within a state so the
|
|
@@ -148,6 +162,7 @@ module.exports = function (app) {
|
|
|
148
162
|
heel: heelDeg(now),
|
|
149
163
|
headingMagnetic: deg(fresh('navigation.headingMagnetic', now)),
|
|
150
164
|
headingTrue: deg(fresh('navigation.headingTrue', now)),
|
|
165
|
+
variation: deg(fresh('navigation.magneticVariation', now, VARIATION_MAX_AGE_MS)),
|
|
151
166
|
sog: (v => v == null ? undefined : v * MS_TO_KN)(fresh('navigation.speedOverGround', now)),
|
|
152
167
|
cog: deg(fresh('navigation.courseOverGroundTrue', now))
|
|
153
168
|
}
|
|
@@ -265,10 +280,43 @@ module.exports = function (app) {
|
|
|
265
280
|
unsubscribes = []
|
|
266
281
|
}
|
|
267
282
|
|
|
283
|
+
// ---- stored configuration ---------------------------------------------------
|
|
284
|
+
|
|
285
|
+
function configFile () {
|
|
286
|
+
return path.join(app.getDataDirPath(), CONFIG_FILE)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// The webapp's saved configuration. Before the file exists, take a configuration left
|
|
290
|
+
// in the plugin options by 0.0.1, which stored it there; otherwise start from defaults.
|
|
291
|
+
function loadStored (options) {
|
|
292
|
+
try {
|
|
293
|
+
return JSON.parse(fs.readFileSync(configFile(), 'utf8'))
|
|
294
|
+
} catch (e) {
|
|
295
|
+
if (e.code !== 'ENOENT') app.error(`could not read ${configFile()}: ${e.message}`)
|
|
296
|
+
}
|
|
297
|
+
return options && options.corrections ? options : {}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Write to a temporary file and rename, so a crash mid-write never leaves half a file.
|
|
301
|
+
// Never overwrite a file from a newer release: this version would drop what it
|
|
302
|
+
// does not understand.
|
|
303
|
+
function saveStored (cfg) {
|
|
304
|
+
const file = configFile()
|
|
305
|
+
let onDisk = null
|
|
306
|
+
try { onDisk = JSON.parse(fs.readFileSync(file, 'utf8')).schema } catch (e) {}
|
|
307
|
+
if (Number.isInteger(onDisk) && onDisk > CONFIG_SCHEMA) {
|
|
308
|
+
throw new Error(`the saved configuration is schema ${onDisk}, from a newer version of this plugin; update the plugin rather than overwrite it`)
|
|
309
|
+
}
|
|
310
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
311
|
+
fs.writeFileSync(file + '.tmp', JSON.stringify(cfg, null, 2))
|
|
312
|
+
fs.renameSync(file + '.tmp', file)
|
|
313
|
+
}
|
|
314
|
+
|
|
268
315
|
plugin.start = function (options) {
|
|
269
|
-
const { config: cfg, errors } = normalizeConfig(options
|
|
316
|
+
const { config: cfg, errors } = normalizeConfig(loadStored(options))
|
|
270
317
|
if (errors.length) {
|
|
271
|
-
app.setPluginError(`Configuration: ${errors.join('; ')}`)
|
|
318
|
+
app.setPluginError(`Configuration: ${errors.join('; ')} — fix it in the webapp`)
|
|
319
|
+
config = normalizeConfig({}).config // so the webapp can still load and repair it
|
|
272
320
|
return
|
|
273
321
|
}
|
|
274
322
|
startWith(cfg)
|
|
@@ -308,23 +356,25 @@ module.exports = function (app) {
|
|
|
308
356
|
}
|
|
309
357
|
|
|
310
358
|
plugin.registerWithRouter = function (router) {
|
|
311
|
-
|
|
359
|
+
// Not /config: the server registers GET and POST /config on every plugin's router
|
|
360
|
+
// for its own options, ahead of the plugin's routes, so ours would never be reached.
|
|
361
|
+
router.get('/calibration', (req, res) => {
|
|
312
362
|
res.set('Cache-Control', 'no-store')
|
|
313
|
-
|
|
314
|
-
try { stored = app.readPluginOptions().configuration || {} } catch (e) {}
|
|
315
|
-
res.json({ config: config || normalizeConfig(stored).config, defaults: DEFAULTS })
|
|
363
|
+
res.json({ config: config || normalizeConfig(loadStored()).config, defaults: DEFAULTS })
|
|
316
364
|
})
|
|
317
365
|
|
|
318
|
-
router.put('/
|
|
366
|
+
router.put('/calibration', async (req, res) => {
|
|
319
367
|
let body
|
|
320
368
|
try { body = await readBody(req) } catch (e) { return res.status(400).json({ errors: ['body is not valid JSON'] }) }
|
|
321
369
|
const { config: cfg, errors } = normalizeConfig(body)
|
|
322
370
|
if (errors.length) return res.status(400).json({ errors })
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
res.json({
|
|
327
|
-
}
|
|
371
|
+
try {
|
|
372
|
+
saveStored(cfg)
|
|
373
|
+
} catch (e) {
|
|
374
|
+
return res.status(500).json({ errors: [`could not save: ${e.message}`] })
|
|
375
|
+
}
|
|
376
|
+
startWith(cfg)
|
|
377
|
+
res.json({ config: cfg })
|
|
328
378
|
})
|
|
329
379
|
|
|
330
380
|
router.get('/live', (req, res) => {
|
|
@@ -336,33 +386,5 @@ module.exports = function (app) {
|
|
|
336
386
|
return plugin
|
|
337
387
|
}
|
|
338
388
|
|
|
339
|
-
// JSON schema for the admin "Plugin Config" form, built from the defaults so the form
|
|
340
|
-
// is a working fallback editor and a fresh install starts from this boat's values.
|
|
341
|
-
function schemaFor (defaults) {
|
|
342
|
-
const titles = {
|
|
343
|
-
mode: 'Output: shadow publishes …Corrected paths alongside the instruments; replace publishes the standard paths (rank this plugin first in Source Priorities)',
|
|
344
|
-
variation: 'Magnetic variation (degrees, east positive)',
|
|
345
|
-
sources: 'Input sources ($source to read; blank = any source except this plugin)',
|
|
346
|
-
corrections: 'Corrections (easier to edit in the Performance Processor webapp)'
|
|
347
|
-
}
|
|
348
|
-
function node (value, title) {
|
|
349
|
-
if (Array.isArray(value)) return { type: 'array', title, items: { type: 'number' }, default: value }
|
|
350
|
-
if (value && typeof value === 'object') {
|
|
351
|
-
return {
|
|
352
|
-
type: 'object',
|
|
353
|
-
title,
|
|
354
|
-
properties: Object.fromEntries(Object.entries(value).map(([k, v]) => [k, node(v, k)]))
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
if (typeof value === 'boolean') return { type: 'boolean', title, default: value }
|
|
358
|
-
if (typeof value === 'number') return { type: 'number', title, default: value }
|
|
359
|
-
return { type: 'string', title, default: value }
|
|
360
|
-
}
|
|
361
|
-
const schema = node(defaults)
|
|
362
|
-
for (const [k, t] of Object.entries(titles)) schema.properties[k].title = t
|
|
363
|
-
schema.properties.mode.enum = ['shadow', 'replace']
|
|
364
|
-
return schema
|
|
365
|
-
}
|
|
366
|
-
|
|
367
389
|
// Exposed for the test suite only; not part of the Signal K plugin API.
|
|
368
|
-
module.exports._internals = {
|
|
390
|
+
module.exports._internals = { OUTPUTS, INPUTS, SHADOW_SUFFIX, CONFIG_FILE }
|
package/lib/defaults.js
CHANGED
|
@@ -8,8 +8,10 @@ const TWS_BINS = [5, 10, 15, 20, 25, 30]
|
|
|
8
8
|
|
|
9
9
|
const DEFAULTS = {
|
|
10
10
|
mode: 'shadow', // 'shadow' publishes …Corrected paths; 'replace' publishes the standard paths
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Degrees, east positive. Used only when nothing on the boat publishes
|
|
12
|
+
// navigation.headingTrue or navigation.magneticVariation. Sydney.
|
|
13
|
+
variation: 12.8,
|
|
14
|
+
sources: { apparentWind: '', stw: '', heading: '', attitude: '', gps: '', variation: '' }, // '' = any but this plugin
|
|
13
15
|
corrections: {
|
|
14
16
|
// Knots added to STW: linearity by speed, plus heel correction by |heel|.
|
|
15
17
|
// The 185-cell heel x STW table reduced to two rows (every cell within 0.03 kn).
|
package/lib/processor.js
CHANGED
|
@@ -145,7 +145,7 @@ function correctInputs (input, config) {
|
|
|
145
145
|
return { c, steps }
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
// input: { awa, aws, stw, heel?, headingMagnetic?, headingTrue?, sog?, cog? }
|
|
148
|
+
// input: { awa, aws, stw, heel?, headingMagnetic?, headingTrue?, variation?, sog?, cog? }
|
|
149
149
|
// returns { out: { stw, leeway, twa, tws, heading?, twd?, current? }, steps }
|
|
150
150
|
function process (input, config) {
|
|
151
151
|
const { c, steps } = correctInputs(input, config)
|
|
@@ -171,9 +171,19 @@ function process (input, config) {
|
|
|
171
171
|
|
|
172
172
|
const out = { stw: c.stw, leeway: c.leeway, twa: c.twa, tws: c.tws }
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
// True heading: from a true-heading source if there is one, otherwise magnetic
|
|
175
|
+
// heading plus variation, taken from Signal K (the GPS) when available and from the
|
|
176
|
+
// configured fallback when not.
|
|
177
|
+
let heading = null
|
|
178
|
+
if (input.headingTrue != null) {
|
|
179
|
+
heading = input.headingTrue
|
|
180
|
+
out.headingFrom = 'true heading'
|
|
181
|
+
} else if (input.headingMagnetic != null) {
|
|
182
|
+
const fromSignalK = input.variation != null
|
|
183
|
+
out.variation = fromSignalK ? input.variation : config.variation
|
|
184
|
+
out.headingFrom = fromSignalK ? 'magnetic + variation from Signal K' : 'magnetic + fallback variation'
|
|
185
|
+
heading = input.headingMagnetic + out.variation
|
|
186
|
+
}
|
|
177
187
|
if (heading != null) {
|
|
178
188
|
out.heading = norm360(heading)
|
|
179
189
|
out.twd = norm360(heading + c.twa)
|
|
@@ -233,6 +243,37 @@ function checkNumber (errors, label, v) {
|
|
|
233
243
|
if (typeof v !== 'number' || !Number.isFinite(v)) errors.push(`${label}: must be a number`)
|
|
234
244
|
}
|
|
235
245
|
|
|
246
|
+
// ---- file format versions ----------------------------------------------------
|
|
247
|
+
//
|
|
248
|
+
// Every saved config carries `schema`, the version of its shape. When a release changes
|
|
249
|
+
// that shape, bump CONFIG_SCHEMA and add MIGRATIONS[old], a function that turns a
|
|
250
|
+
// config of schema `old` into schema `old + 1`. Older files are then upgraded step by
|
|
251
|
+
// step as they load and saved back in the current shape; nothing is lost or reset.
|
|
252
|
+
//
|
|
253
|
+
// Schema 1 (0.0.1): mode, variation, sources, corrections { stw, leeway, twa, tws }.
|
|
254
|
+
|
|
255
|
+
const CONFIG_SCHEMA = 1
|
|
256
|
+
const MIGRATIONS = {
|
|
257
|
+
// 1: cfg => ({ ...cfg, schema: 2, ... }),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Bring a raw config up to the current schema. A config without `schema` predates the
|
|
261
|
+
// field and has the schema 1 shape. One from a newer release is refused rather than
|
|
262
|
+
// guessed at, since this version cannot know what its fields mean.
|
|
263
|
+
function migrate (raw, migrations = MIGRATIONS, current = CONFIG_SCHEMA) {
|
|
264
|
+
let cfg = clone(raw)
|
|
265
|
+
let v = cfg.schema == null ? 1 : cfg.schema
|
|
266
|
+
if (!Number.isInteger(v) || v < 1) return { error: `schema: "${cfg.schema}" is not a schema version` }
|
|
267
|
+
if (v > current) return { error: `schema ${v} was saved by a newer version of this plugin (this one reads up to ${current}); update the plugin` }
|
|
268
|
+
while (v < current) {
|
|
269
|
+
if (!migrations[v]) return { error: `schema: no upgrade from ${v} to ${v + 1}` }
|
|
270
|
+
cfg = migrations[v](cfg)
|
|
271
|
+
v++
|
|
272
|
+
}
|
|
273
|
+
cfg.schema = current
|
|
274
|
+
return { config: cfg }
|
|
275
|
+
}
|
|
276
|
+
|
|
236
277
|
// Merge a stored or submitted config over the defaults and check it. Numbers typed
|
|
237
278
|
// into a form arrive as strings, so coerce numeric-looking strings first.
|
|
238
279
|
function normalizeConfig (input) {
|
|
@@ -242,10 +283,12 @@ function normalizeConfig (input) {
|
|
|
242
283
|
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
|
|
243
284
|
return v
|
|
244
285
|
}
|
|
245
|
-
const
|
|
286
|
+
const migrated = migrate(isPlainObject(input) ? input : {})
|
|
287
|
+
if (migrated.error) return { config: { ...clone(DEFAULTS), schema: CONFIG_SCHEMA }, errors: [migrated.error] }
|
|
288
|
+
const raw = migrated.config
|
|
246
289
|
// Source names are text even when they look numeric.
|
|
247
290
|
const keepText = { sources: raw.sources, mode: raw.mode }
|
|
248
|
-
const config = merge(DEFAULTS, { ...coerce(raw), ...Object.fromEntries(Object.entries(keepText).filter(([, v]) => v !== undefined)) })
|
|
291
|
+
const config = merge({ schema: CONFIG_SCHEMA, ...DEFAULTS }, { ...coerce(raw), ...Object.fromEntries(Object.entries(keepText).filter(([, v]) => v !== undefined)) })
|
|
249
292
|
const errors = []
|
|
250
293
|
const k = config.corrections
|
|
251
294
|
|
|
@@ -283,5 +326,6 @@ module.exports = {
|
|
|
283
326
|
correctInputs,
|
|
284
327
|
normalizeConfig,
|
|
285
328
|
CORRECTIONS,
|
|
286
|
-
|
|
329
|
+
CONFIG_SCHEMA,
|
|
330
|
+
_internals: { interp, blendRows, tableLookup, merge, norm180, migrate }
|
|
287
331
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-performance-processor",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "H5000-style calibration
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "True wind direction that holds steady through tacks and gybes. H5000-style calibration in Signal K: boat speed, leeway, and TWA/TWS tables by wind speed, edited and watched live in a webapp. Try it in shadow mode beside your existing instruments.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "node --test",
|
package/public/app.js
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
stw: 'Speed through water',
|
|
20
20
|
heading: 'Heading',
|
|
21
21
|
attitude: 'Heel (attitude)',
|
|
22
|
-
gps: 'SOG and COG'
|
|
22
|
+
gps: 'SOG and COG',
|
|
23
|
+
variation: 'Magnetic variation'
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const state = {
|
|
@@ -49,14 +50,16 @@
|
|
|
49
50
|
|
|
50
51
|
async function loadConfig () {
|
|
51
52
|
try {
|
|
52
|
-
const body = await getJson('/
|
|
53
|
+
const body = await getJson('/calibration')
|
|
54
|
+
if (!body || !body.config || !body.config.corrections) throw new Error('the plugin returned no calibration')
|
|
53
55
|
state.saved = body.config
|
|
54
56
|
state.draft = clone(body.config)
|
|
55
57
|
state.defaults = body.defaults
|
|
56
|
-
|
|
58
|
+
state.configError = null
|
|
57
59
|
} catch (e) {
|
|
58
|
-
|
|
59
|
-
$
|
|
60
|
+
// Kept until a load succeeds: the live poll must not clear it.
|
|
61
|
+
state.configError = `Could not load the calibration (${e.message}). Is Performance Processor enabled and up to date?`
|
|
62
|
+
setTimeout(loadConfig, 5000)
|
|
60
63
|
}
|
|
61
64
|
renderAll()
|
|
62
65
|
}
|
|
@@ -79,7 +82,7 @@
|
|
|
79
82
|
state.saving = true
|
|
80
83
|
renderSaveBar()
|
|
81
84
|
try {
|
|
82
|
-
const res = await fetch(API + '/
|
|
85
|
+
const res = await fetch(API + '/calibration', {
|
|
83
86
|
method: 'PUT',
|
|
84
87
|
credentials: 'include',
|
|
85
88
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -137,6 +140,8 @@
|
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
function renderHeader () {
|
|
143
|
+
$('cfgError').textContent = state.configError || ''
|
|
144
|
+
$('cfgError').hidden = !state.configError
|
|
140
145
|
const mode = state.saved ? state.saved.mode : null
|
|
141
146
|
const badge = $('mode')
|
|
142
147
|
badge.textContent = mode === 'replace' ? 'Replace' : mode === 'shadow' ? 'Shadow' : '--'
|
|
@@ -160,6 +165,13 @@
|
|
|
160
165
|
return { twa: Math.atan2(y, x) * 180 / Math.PI, tws: Math.hypot(x, y) }
|
|
161
166
|
}
|
|
162
167
|
|
|
168
|
+
// Where true heading came from, so a fallback variation is visible at a glance.
|
|
169
|
+
function headingNote (o) {
|
|
170
|
+
if (o.headingFrom === 'true heading') return 'from true heading'
|
|
171
|
+
const v = o.variation == null ? '' : ` ${Math.abs(o.variation).toFixed(1)}° ${o.variation < 0 ? 'W' : 'E'}`
|
|
172
|
+
return o.headingFrom === 'magnetic + fallback variation' ? `fallback variation${v}` : `variation${v} from GPS`
|
|
173
|
+
}
|
|
174
|
+
|
|
163
175
|
function tile (label, value, unit, raw) {
|
|
164
176
|
return `<div class="tile"><div class="label">${esc(label)}</div>` +
|
|
165
177
|
`<div class="value">${esc(value)}<span class="unit">${esc(unit)}</span></div>` +
|
|
@@ -182,7 +194,7 @@
|
|
|
182
194
|
$('tiles').innerHTML = [
|
|
183
195
|
tile('TWA', fmt(Math.abs(o.twa)), `° ${side(o.twa)}`, `uncorrected ${fmt(Math.abs(u.twa))}°`),
|
|
184
196
|
tile('TWS', fmt(o.tws), 'kn', `uncorrected ${fmt(u.tws)} kn`),
|
|
185
|
-
tile('TWD', o.twd == null ? '--' : fmt(o.twd, 0), '° T', o.twd == null ? 'no heading' :
|
|
197
|
+
tile('TWD', o.twd == null ? '--' : fmt(o.twd, 0), '° T', o.twd == null ? 'no heading' : headingNote(o)),
|
|
186
198
|
tile('STW', fmt(o.stw, 2), 'kn', `paddlewheel ${fmt(r.input.stw, 2)} kn`),
|
|
187
199
|
tile('Leeway', fmt(Math.abs(o.leeway)), `° ${side(o.leeway)}`, r.input.heel == null ? 'no heel' : `heel ${fmt(r.input.heel, 0)}°`),
|
|
188
200
|
tile('Current', o.current ? fmt(o.current.drift, 2) : '--', o.current ? `kn · ${fmt(o.current.set, 0)}°` : 'kn', o.current ? '' : 'no SOG/COG or heading')
|
package/public/index.html
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
</nav>
|
|
21
21
|
<div id="status" class="muted status"></div>
|
|
22
22
|
<div id="conn" class="warn" hidden></div>
|
|
23
|
+
<div id="cfgError" class="warn" hidden></div>
|
|
23
24
|
</header>
|
|
24
25
|
|
|
25
26
|
<div id="saveBar" class="save-bar" hidden>
|
|
@@ -68,8 +69,9 @@
|
|
|
68
69
|
</fieldset>
|
|
69
70
|
</div>
|
|
70
71
|
<div class="panel">
|
|
71
|
-
<h2>
|
|
72
|
-
<
|
|
72
|
+
<h2>Fallback magnetic variation</h2>
|
|
73
|
+
<p class="muted sub">Used only when nothing on the boat sends true heading or magnetic variation. Normally your GPS supplies variation and this is never used; the Live page shows which applies.</p>
|
|
74
|
+
<label class="field">Degrees east (west negative)
|
|
73
75
|
<input id="variation" type="number" step="0.1" data-path="variation">
|
|
74
76
|
</label>
|
|
75
77
|
</div>
|