signalk-performance-processor 0.0.2 → 0.0.4
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 +19 -0
- package/README.md +64 -21
- package/index.js +5 -3
- package/lib/defaults.js +33 -15
- package/lib/processor.js +127 -31
- package/package.json +2 -2
- package/public/app.js +99 -22
- package/public/index.html +13 -2
- package/public/style.css +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.4 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
Still a proof of concept: run it in Shadow mode alongside your instruments. Saved configurations are upgraded to schema 2 on first load.
|
|
6
|
+
|
|
7
|
+
- **STW correction is a heel × speed table** (signed heel rows, STW columns), like the H5000's boat speed / heel table, with bilinear interpolation and blank cells filled from the nearest value. The default is the owner's full measured table. A table in the `signalk-stw-heel-correction` CSV format can be pasted into the webapp.
|
|
8
|
+
- **TWS table is in knots** (H5000 units). Default downwind −0.5 … −3 kn (−10% of each column); upwind and reach blank, meaning no correction.
|
|
9
|
+
- **New TWA table defaults** from the owner's calibration fit. Blank TWA cells mean too little data and use the nearest measured cells in the row.
|
|
10
|
+
- Config file schema 2. Schema 1 files are upgraded on load: the two STW rows become their grid and TWS percentages become knots.
|
|
11
|
+
- Live pipeline: each step's result is tagged **Published** or **Working**, worked out from what later enabled steps replace, so intermediate values are not mistaken for outputs.
|
|
12
|
+
- Calibration tables: the breakpoints (TWS columns, STW speeds, heel angles) are fixed labels, not editable fields; only the correction values are edited.
|
|
13
|
+
- Live page: a "Published to Signal K" table lists exactly what the plugin sends and on which path. The pipeline's true wind step is labelled "before tables", so its working TWA is not mistaken for a second output.
|
|
14
|
+
|
|
15
|
+
## 0.0.3 — 2026-10-29
|
|
16
|
+
|
|
17
|
+
Still a proof of concept: run it in Shadow mode alongside your instruments.
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
- README and App Store description explain the problem this solves, who it is for, and the results on the boat it was built for.
|
|
21
|
+
|
|
3
22
|
## 0.0.2 — 2026-10-29
|
|
4
23
|
|
|
5
24
|
Still a proof of concept: run it in Shadow mode alongside your instruments.
|
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.4).** 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:** a boat speed table by heel and speed, leeway (K × heel ÷ boat speed²), 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 (the owner's leeway K, full STW table and TWA table from a later calibration fit), so treat these as an indication, not a guarantee.
|
|
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
|
|
|
@@ -16,11 +54,11 @@ The corrections run in this order, each on the output of the one before:
|
|
|
16
54
|
|
|
17
55
|
| Step | What | Default |
|
|
18
56
|
|---|---|---|
|
|
19
|
-
| Boat speed (STW) | Knots added to the paddlewheel
|
|
57
|
+
| Boat speed (STW) | Knots added to the paddlewheel from a **heel × speed table** (signed heel rows, STW columns), like the H5000's boat speed / heel table | On |
|
|
20
58
|
| Leeway | Leeway = K × heel ÷ STW², turning the boat's velocity to leeward | On, K = 7.9446 |
|
|
21
59
|
| True wind | Apparent wind minus the boat's velocity through the water | Always |
|
|
22
60
|
| TWA table | Degrees added to \|TWA\| by TWS, with upwind, reach and downwind rows blended by TWA (70° / 100° / 130°) | On |
|
|
23
|
-
| TWS table |
|
|
61
|
+
| TWS table | Knots added to TWS by TWS, same rows and blending (H5000 units) | Off |
|
|
24
62
|
| TWD, current | TWD from heading and corrected TWA; current from SOG/COG minus water velocity | Always, when heading and GPS are available |
|
|
25
63
|
|
|
26
64
|
### Inputs
|
|
@@ -61,28 +99,28 @@ Everything is published under this plugin's own `$source`. Corrected STW goes ou
|
|
|
61
99
|
|
|
62
100
|
Open **Webapps → Performance Processor**.
|
|
63
101
|
|
|
64
|
-
- **Live**: the outputs next to their uncorrected values,
|
|
65
|
-
- **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
|
|
102
|
+
- **Live**: the outputs next to their uncorrected values; the pipeline, with each step's result tagged **Published** (sent to Signal K) or **Working** (only feeds the next step); a **Published to Signal K** table listing every value sent and its path; and which source and how fresh each input is.
|
|
103
|
+
- **Calibration**: every correction in pipeline order with an on/off switch. Select one to edit its values, then **Save**. Table headings (the breakpoints) are fixed; only the values are edited, and blank cells are allowed where a table says so. The boat speed table also has **Paste a table**, which reads the `signalk-stw-heel-correction` CSV format. **Reset to defaults** puts back this boat's values for that correction.
|
|
66
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
|
|
|
72
|
-
|
|
110
|
+
What a fresh install starts with. Every value can be changed in the webapp.
|
|
73
111
|
|
|
74
|
-
- **STW:**
|
|
112
|
+
- **STW:** the owner's measured heel × STW table, 15 heel rows (−35° to +35°) by 17 speed columns (0.5 to 8.5 kn), in knots added. Blank cells (corners the boat never sails in) are filled from the nearest value in their row; below 0.5 kn the correction tapers to zero. A table in the same CSV format as `signalk-stw-heel-correction` can be pasted straight into the webapp.
|
|
75
113
|
- **Leeway:** K = 7.9446, one value for every sail.
|
|
76
|
-
- **TWA table (°)
|
|
114
|
+
- **TWA table (°)**, from the owner's calibration fit (blank = too little data):
|
|
77
115
|
|
|
78
116
|
| TWS (kn) | 5 | 10 | 15 | 20 | 25 | 30 |
|
|
79
117
|
|---|---|---|---|---|---|---|
|
|
80
|
-
| Upwind | −
|
|
81
|
-
| Reach | −
|
|
82
|
-
| Downwind |
|
|
118
|
+
| Upwind | −7.6 | −12.6 | −5.8 | −5.7 | +0.2 | |
|
|
119
|
+
| Reach | −2.4 | −4.4 | −5.4 | +1.4 | +3.9 | +1.0 |
|
|
120
|
+
| Downwind | +1.2 | −3.5 | +1.0 | | | |
|
|
83
121
|
|
|
84
|
-
|
|
85
|
-
- **TWS table (
|
|
122
|
+
A blank TWA cell uses the measured cells either side, holding the nearest one at the ends: upwind at 30 kn uses +0.2, downwind from 20 kn uses +1.0.
|
|
123
|
+
- **TWS table (kn):** off. Downwind −0.5, −1, −1.5, −2, −2.5, −3 (−10% of each column); upwind and reach blank, which in this table means no correction.
|
|
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.
|
|
87
125
|
|
|
88
126
|
## Configuration file
|
|
@@ -91,15 +129,20 @@ Fitted to the 2025 race logs and checked on Jan–Mar 2026 races not used in fit
|
|
|
91
129
|
|
|
92
130
|
```json
|
|
93
131
|
{
|
|
94
|
-
"schema":
|
|
132
|
+
"schema": 2,
|
|
95
133
|
"mode": "shadow",
|
|
96
134
|
"variation": 12.8,
|
|
97
135
|
"sources": { "apparentWind": "", "stw": "", "heading": "", "attitude": "", "gps": "", "variation": "" },
|
|
98
|
-
"corrections": {
|
|
136
|
+
"corrections": {
|
|
137
|
+
"stw": { "enabled": true, "heel": [-35, …, 35], "stw": [0.5, …, 8.5], "add": [[…], …] },
|
|
138
|
+
"leeway": { "enabled": true, "k": 7.9446 },
|
|
139
|
+
"twa": { "enabled": true, "tws": [5, …, 30], "upwind": […], "reach": […], "downwind": […], "angles": { … } },
|
|
140
|
+
"tws": { "enabled": false, "tws": [5, …, 30], "upwind": […], "reach": […], "downwind": […] }
|
|
141
|
+
}
|
|
99
142
|
}
|
|
100
143
|
```
|
|
101
144
|
|
|
102
|
-
`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.
|
|
145
|
+
`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. Schema 1 files (0.0.1–0.0.3) are upgraded to 2: their two STW rows become the grid they describe, and their TWS percentages become knots. Blank table cells are stored as `null`. A file from a newer release is refused rather than guessed at, and is never overwritten by an older plugin.
|
|
103
146
|
|
|
104
147
|
## Adding a correction
|
|
105
148
|
|
|
@@ -109,8 +152,8 @@ Corrections live in `lib/processor.js` as entries in `CORRECTIONS`, each with a
|
|
|
109
152
|
|
|
110
153
|
Under `/plugins/signalk-performance-processor`:
|
|
111
154
|
|
|
112
|
-
- `GET /
|
|
113
|
-
- `PUT /
|
|
155
|
+
- `GET /calibration` returns the configuration in use and the defaults.
|
|
156
|
+
- `PUT /calibration` validates, saves and restarts with a new configuration. It returns 400 with the reasons if the configuration is invalid.
|
|
114
157
|
- `GET /live` returns the latest result, each step, input ages and the sources seen.
|
|
115
158
|
|
|
116
159
|
## Development
|
package/index.js
CHANGED
|
@@ -48,7 +48,7 @@ module.exports = function (app) {
|
|
|
48
48
|
const plugin = {
|
|
49
49
|
id: 'signalk-performance-processor',
|
|
50
50
|
name: 'gh - Performance Processor',
|
|
51
|
-
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'
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
let config = null
|
|
@@ -356,12 +356,14 @@ module.exports = function (app) {
|
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
plugin.registerWithRouter = function (router) {
|
|
359
|
-
|
|
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) => {
|
|
360
362
|
res.set('Cache-Control', 'no-store')
|
|
361
363
|
res.json({ config: config || normalizeConfig(loadStored()).config, defaults: DEFAULTS })
|
|
362
364
|
})
|
|
363
365
|
|
|
364
|
-
router.put('/
|
|
366
|
+
router.put('/calibration', async (req, res) => {
|
|
365
367
|
let body
|
|
366
368
|
try { body = await readBody(req) } catch (e) { return res.status(400).json({ errors: ['body is not valid JSON'] }) }
|
|
367
369
|
const { config: cfg, errors } = normalizeConfig(body)
|
package/lib/defaults.js
CHANGED
|
@@ -13,35 +13,53 @@ const DEFAULTS = {
|
|
|
13
13
|
variation: 12.8,
|
|
14
14
|
sources: { apparentWind: '', stw: '', heading: '', attitude: '', gps: '', variation: '' }, // '' = any but this plugin
|
|
15
15
|
corrections: {
|
|
16
|
-
// Knots added to STW: linearity by speed, plus heel correction by |heel|.
|
|
17
|
-
// The 185-cell heel x STW table reduced to two rows (every cell within 0.03 kn).
|
|
18
16
|
stw: {
|
|
19
17
|
enabled: true,
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
// Knots added to STW, by signed heel (rows, + = starboard side down) and STW (columns).
|
|
19
|
+
// The owner's measured heel x STW table; blank cells are filled from the nearest value.
|
|
20
|
+
heel: [-35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35],
|
|
21
|
+
stw: [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5],
|
|
22
|
+
add: [
|
|
23
|
+
[null, null, null, null, null, null, null, null, null, null, null, -0.276, -0.283, -0.287, -0.291, -0.293, -0.294],
|
|
24
|
+
[null, null, null, null, null, null, null, null, null, null, -0.266, -0.276, -0.283, -0.287, -0.291, -0.293, -0.294],
|
|
25
|
+
[null, null, null, null, null, null, null, null, -0.139, -0.16, -0.175, -0.185, -0.192, -0.197, -0.2, -0.202, -0.203],
|
|
26
|
+
[null, null, null, null, null, null, 0.009, -0.035, -0.065, -0.086, -0.101, -0.111, -0.118, -0.122, -0.126, -0.128, -0.129],
|
|
27
|
+
[0.536, 0.555, 0.442, 0.315, 0.208, 0.125, 0.065, 0.022, -0.008, -0.029, -0.043, -0.053, -0.06, -0.065, -0.068, -0.07, -0.071],
|
|
28
|
+
[0.552, 0.582, 0.474, 0.351, 0.246, 0.165, 0.105, 0.063, 0.033, 0.012, -0.002, -0.012, -0.019, -0.023, -0.026, -0.029, -0.03],
|
|
29
|
+
[0.562, 0.597, 0.493, 0.372, 0.268, 0.188, 0.129, 0.087, 0.057, 0.037, 0.022, 0.013, 0.006, 0.001, -0.002, -0.004, -0.005],
|
|
30
|
+
[0.565, 0.602, 0.5, 0.379, 0.276, 0.196, 0.137, 0.095, 0.065, 0.045, 0.031, 0.021, 0.014, 0.01, 0.007, 0.004, 0.003],
|
|
31
|
+
[0.562, 0.597, 0.494, 0.372, 0.269, 0.188, 0.129, 0.087, 0.057, 0.037, 0.023, 0.013, 0.006, 0.002, -0.001, -0.004, -0.005],
|
|
32
|
+
[0.552, 0.582, 0.475, 0.352, 0.246, 0.165, 0.106, 0.063, 0.034, 0.013, -0.001, -0.011, -0.018, -0.023, -0.026, -0.028, -0.029],
|
|
33
|
+
[0.536, 0.557, 0.444, 0.317, 0.21, 0.127, 0.067, 0.024, -0.006, -0.027, -0.042, -0.051, -0.058, -0.063, -0.066, -0.068, -0.069],
|
|
34
|
+
[null, null, null, null, null, null, 0.012, -0.031, -0.062, -0.083, -0.098, -0.108, -0.114, -0.119, -0.122, -0.124, -0.126],
|
|
35
|
+
[null, null, null, null, null, null, null, null, -0.133, -0.155, -0.17, -0.18, -0.187, -0.191, -0.194, -0.197, -0.198],
|
|
36
|
+
[null, null, null, null, null, null, null, null, null, null, -0.258, -0.268, -0.275, -0.28, -0.283, -0.285, -0.286],
|
|
37
|
+
[null, null, null, null, null, null, null, null, null, null, null, -0.268, -0.275, -0.28, -0.283, -0.285, -0.286]
|
|
38
|
+
]
|
|
22
39
|
},
|
|
23
40
|
// Leeway (degrees) = K x heel / STW^2, one K for every sail: downwind heel is
|
|
24
41
|
// small, so leeway there is near zero whatever K is.
|
|
25
42
|
leeway: { enabled: true, k: 7.9446 },
|
|
26
|
-
// Degrees added to |TWA| by TWS; negative = TWA was reading too wide.
|
|
27
|
-
//
|
|
28
|
-
//
|
|
43
|
+
// Degrees added to |TWA| by TWS; negative = TWA was reading too wide. From the
|
|
44
|
+
// owner's boatcal fit; blank = too little data, filled from the nearest measured
|
|
45
|
+
// cells in the row. Rows are blended by TWA: upwind in full to angles.upwind, reach
|
|
46
|
+
// at angles.reach, downwind from angles.downwind. The TWS table shares these angles.
|
|
29
47
|
twa: {
|
|
30
48
|
enabled: true,
|
|
31
49
|
tws: TWS_BINS.slice(),
|
|
32
|
-
upwind: [-
|
|
33
|
-
reach: [-
|
|
34
|
-
downwind: [
|
|
50
|
+
upwind: [-7.6, -12.6, -5.8, -5.7, 0.2, null],
|
|
51
|
+
reach: [-2.4, -4.4, -5.4, 1.4, 3.9, 1.0],
|
|
52
|
+
downwind: [1.2, -3.5, 1.0, null, null, null],
|
|
35
53
|
angles: { upwind: 70, reach: 100, downwind: 130 }
|
|
36
54
|
},
|
|
37
|
-
//
|
|
38
|
-
//
|
|
55
|
+
// Knots added to TWS by TWS, same rows and blending as the TWA table (H5000 units).
|
|
56
|
+
// Downwind: -10% of each column. Blank = no correction. Off until switched on.
|
|
39
57
|
tws: {
|
|
40
58
|
enabled: false,
|
|
41
59
|
tws: TWS_BINS.slice(),
|
|
42
|
-
upwind: [
|
|
43
|
-
reach: [
|
|
44
|
-
downwind: [0,
|
|
60
|
+
upwind: [null, null, null, null, null, null],
|
|
61
|
+
reach: [null, null, null, null, null, null],
|
|
62
|
+
downwind: [-0.5, -1, -1.5, -2, -2.5, -3]
|
|
45
63
|
}
|
|
46
64
|
}
|
|
47
65
|
}
|
package/lib/processor.js
CHANGED
|
@@ -43,11 +43,74 @@ function blendRows (absTwa, angles, up, reach, down) {
|
|
|
43
43
|
return down
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Interpolate along a row whose blank cells (null) mean "not measured": use only the
|
|
47
|
+
// measured cells, holding the nearest one beyond them. An all-blank row is zero.
|
|
48
|
+
function interpRow (xs, ys, x) {
|
|
49
|
+
const kx = []; const ky = []
|
|
50
|
+
ys.forEach((y, i) => { if (y != null) { kx.push(xs[i]); ky.push(y) } })
|
|
51
|
+
return kx.length ? interp(kx, ky, x) : 0
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
function tableLookup (table, angles, absTwa, tws) {
|
|
47
55
|
return blendRows(absTwa, angles,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
interpRow(table.tws, table.upwind, tws),
|
|
57
|
+
interpRow(table.tws, table.reach, tws),
|
|
58
|
+
interpRow(table.tws, table.downwind, tws))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- heel x STW table ----------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
// A blank cell means "not measured", typically a corner of the grid the boat never
|
|
64
|
+
// sails in. Extend the nearest value in its row into it rather than reading it as zero,
|
|
65
|
+
// then fill an all-blank row from the nearest row with data. As signalk-stw-heel-correction.
|
|
66
|
+
function fillGaps (grid) {
|
|
67
|
+
const table = grid.map(r => r.slice())
|
|
68
|
+
const cols = table[0].length
|
|
69
|
+
for (const row of table) {
|
|
70
|
+
let last = null
|
|
71
|
+
for (let j = 0; j < cols; j++) { if (row[j] != null) last = row[j]; else if (last != null) row[j] = last }
|
|
72
|
+
last = null
|
|
73
|
+
for (let j = cols - 1; j >= 0; j--) { if (row[j] != null) last = row[j]; else if (last != null) row[j] = last }
|
|
74
|
+
}
|
|
75
|
+
for (let i = 0; i < table.length; i++) {
|
|
76
|
+
if (table[i][0] != null) continue
|
|
77
|
+
for (let d = 1; d < table.length; d++) {
|
|
78
|
+
const src = (i - d >= 0 && table[i - d][0] != null) ? table[i - d] : (i + d < table.length && table[i + d][0] != null) ? table[i + d] : null
|
|
79
|
+
if (src) { table[i] = src.slice(); break }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return table
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function bracket (bins, v) {
|
|
86
|
+
if (v <= bins[0]) return [0, 0, 0]
|
|
87
|
+
const n = bins.length - 1
|
|
88
|
+
if (v >= bins[n]) return [n, n, 0]
|
|
89
|
+
let i = 0
|
|
90
|
+
while (bins[i + 1] < v) i++
|
|
91
|
+
return [i, i + 1, (v - bins[i]) / (bins[i + 1] - bins[i])]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The filled grid is worked out once per config object, not on every update.
|
|
95
|
+
const filledCache = new WeakMap()
|
|
96
|
+
function filledGrid (cfg) {
|
|
97
|
+
let f = filledCache.get(cfg)
|
|
98
|
+
if (!f) { f = fillGaps(cfg.add); filledCache.set(cfg, f) }
|
|
99
|
+
return f
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Bilinear in signed heel and STW, holding the edge values beyond the grid. Below the
|
|
103
|
+
// first STW column the correction tapers to zero at 0 kn, so a stopped boat is not
|
|
104
|
+
// given speed.
|
|
105
|
+
function stwGridLookup (cfg, heel, stw) {
|
|
106
|
+
const t = filledGrid(cfg)
|
|
107
|
+
const s0 = cfg.stw[0]
|
|
108
|
+
const at = s => {
|
|
109
|
+
const [h0, h1, ht] = bracket(cfg.heel, heel)
|
|
110
|
+
const [j0, j1, st] = bracket(cfg.stw, s)
|
|
111
|
+
return t[h0][j0] * (1 - ht) * (1 - st) + t[h0][j1] * (1 - ht) * st + t[h1][j0] * ht * (1 - st) + t[h1][j1] * ht * st
|
|
112
|
+
}
|
|
113
|
+
return stw < s0 ? at(s0) * Math.max(0, stw) / s0 : at(stw)
|
|
51
114
|
}
|
|
52
115
|
|
|
53
116
|
const fmt = (v, d = 1) => (v > 0 ? '+' : '') + v.toFixed(d)
|
|
@@ -55,27 +118,23 @@ const fmt = (v, d = 1) => (v > 0 ? '+' : '') + v.toFixed(d)
|
|
|
55
118
|
const CORRECTIONS = [
|
|
56
119
|
{
|
|
57
120
|
id: 'stw',
|
|
121
|
+
produces: ['stw'], // quantities this step's result replaces
|
|
58
122
|
stage: 'input',
|
|
59
123
|
name: 'Boat speed (STW)',
|
|
60
124
|
apply (c, cfg) {
|
|
61
|
-
|
|
62
|
-
// stopped boat is not given speed.
|
|
63
|
-
const lin = cfg.linearity
|
|
64
|
-
const add1 = c.stw < lin.stw[0]
|
|
65
|
-
? lin.add[0] * Math.max(0, c.stw) / lin.stw[0]
|
|
66
|
-
: interp(lin.stw, lin.add, c.stw)
|
|
67
|
-
const add2 = c.heel == null ? 0 : interp(cfg.heel.heel, cfg.heel.add, Math.abs(c.heel))
|
|
125
|
+
const add = stwGridLookup(cfg, c.heel == null ? 0 : c.heel, c.stw)
|
|
68
126
|
const before = c.stw
|
|
69
|
-
c.stw = Math.max(0, c.stw +
|
|
127
|
+
c.stw = Math.max(0, c.stw + add)
|
|
70
128
|
return {
|
|
71
|
-
input: `${before.toFixed(2)} kn` + (c.heel == null ? '' : ` · heel ${
|
|
72
|
-
detail: `${fmt(
|
|
129
|
+
input: `${before.toFixed(2)} kn` + (c.heel == null ? ' · no heel data, upright row used' : ` · heel ${c.heel.toFixed(0)}°`),
|
|
130
|
+
detail: `${fmt(add, 2)} kn from the heel × STW table`,
|
|
73
131
|
output: `${c.stw.toFixed(2)} kn`
|
|
74
132
|
}
|
|
75
133
|
}
|
|
76
134
|
},
|
|
77
135
|
{
|
|
78
136
|
id: 'leeway',
|
|
137
|
+
produces: ['leeway'], // quantities this step's result replaces
|
|
79
138
|
stage: 'input',
|
|
80
139
|
name: 'Leeway',
|
|
81
140
|
apply (c, cfg) {
|
|
@@ -93,6 +152,7 @@ const CORRECTIONS = [
|
|
|
93
152
|
},
|
|
94
153
|
{
|
|
95
154
|
id: 'twa',
|
|
155
|
+
produces: ['twa'], // quantities this step's result replaces
|
|
96
156
|
stage: 'trueWind',
|
|
97
157
|
name: 'TWA table',
|
|
98
158
|
apply (c, cfg) {
|
|
@@ -109,15 +169,19 @@ const CORRECTIONS = [
|
|
|
109
169
|
},
|
|
110
170
|
{
|
|
111
171
|
id: 'tws',
|
|
172
|
+
produces: ['tws'], // quantities this step's result replaces
|
|
112
173
|
stage: 'trueWind',
|
|
113
174
|
name: 'TWS table',
|
|
114
175
|
apply (c, cfg, all) {
|
|
115
|
-
|
|
176
|
+
// Blank cells mean no correction there.
|
|
177
|
+
const z = row => row.map(v => v == null ? 0 : v)
|
|
178
|
+
const kn = tableLookup({ tws: cfg.tws, upwind: z(cfg.upwind), reach: z(cfg.reach), downwind: z(cfg.downwind) },
|
|
179
|
+
all.corrections.twa.angles, Math.abs(c.twa), c.tws)
|
|
116
180
|
const before = c.tws
|
|
117
|
-
c.tws = c.tws
|
|
181
|
+
c.tws = Math.max(0, c.tws + kn)
|
|
118
182
|
return {
|
|
119
183
|
input: `TWS ${before.toFixed(1)} kn`,
|
|
120
|
-
detail: `${fmt(
|
|
184
|
+
detail: `${fmt(kn, 2)} kn`,
|
|
121
185
|
output: `TWS ${c.tws.toFixed(1)} kn`
|
|
122
186
|
}
|
|
123
187
|
}
|
|
@@ -128,11 +192,11 @@ function runStage (stage, c, config, steps) {
|
|
|
128
192
|
for (const corr of CORRECTIONS.filter(x => x.stage === stage)) {
|
|
129
193
|
const cfg = config.corrections[corr.id]
|
|
130
194
|
if (!cfg || !cfg.enabled) {
|
|
131
|
-
steps.push({ id: corr.id, name: corr.name, enabled: false })
|
|
195
|
+
steps.push({ id: corr.id, name: corr.name, produces: corr.produces, enabled: false })
|
|
132
196
|
continue
|
|
133
197
|
}
|
|
134
198
|
const r = corr.apply(c, cfg, config)
|
|
135
|
-
steps.push({ id: corr.id, name: corr.name, enabled: true, ...r })
|
|
199
|
+
steps.push({ id: corr.id, name: corr.name, produces: corr.produces, enabled: true, ...r })
|
|
136
200
|
}
|
|
137
201
|
}
|
|
138
202
|
|
|
@@ -160,7 +224,8 @@ function process (input, config) {
|
|
|
160
224
|
c.tws = Math.hypot(x, y)
|
|
161
225
|
steps.push({
|
|
162
226
|
id: 'truewind',
|
|
163
|
-
|
|
227
|
+
produces: ['twa', 'tws'],
|
|
228
|
+
name: 'True wind, before tables',
|
|
164
229
|
enabled: true,
|
|
165
230
|
input: `AWA ${Math.abs(input.awa).toFixed(1)}° · ${input.aws.toFixed(1)} kn`,
|
|
166
231
|
detail: 'apparent wind − boat velocity',
|
|
@@ -196,6 +261,7 @@ function process (input, config) {
|
|
|
196
261
|
out.current = { drift: Math.hypot(e, n), set: norm360(deg(Math.atan2(e, n))) }
|
|
197
262
|
steps.push({
|
|
198
263
|
id: 'current',
|
|
264
|
+
produces: ['current'],
|
|
199
265
|
name: 'Current',
|
|
200
266
|
enabled: true,
|
|
201
267
|
input: `SOG ${input.sog.toFixed(1)} · COG ${input.cog.toFixed(0)}°`,
|
|
@@ -234,9 +300,10 @@ function checkAxis (errors, label, xs) {
|
|
|
234
300
|
}
|
|
235
301
|
}
|
|
236
302
|
|
|
237
|
-
function checkRow (errors, label, ys, n) {
|
|
303
|
+
function checkRow (errors, label, ys, n, blanksAllowed) {
|
|
238
304
|
if (!Array.isArray(ys) || ys.length !== n) { errors.push(`${label}: needs ${n} values, one per column`); return }
|
|
239
|
-
|
|
305
|
+
const ok = v => (blanksAllowed && v === null) || (typeof v === 'number' && Number.isFinite(v))
|
|
306
|
+
if (!ys.every(ok)) errors.push(`${label}: every value must be a number${blanksAllowed ? ' or blank' : ''}`)
|
|
240
307
|
}
|
|
241
308
|
|
|
242
309
|
function checkNumber (errors, label, v) {
|
|
@@ -250,11 +317,32 @@ function checkNumber (errors, label, v) {
|
|
|
250
317
|
// config of schema `old` into schema `old + 1`. Older files are then upgraded step by
|
|
251
318
|
// step as they load and saved back in the current shape; nothing is lost or reset.
|
|
252
319
|
//
|
|
253
|
-
// Schema 1 (0.0.1): mode, variation, sources, corrections { stw, leeway, twa, tws }
|
|
320
|
+
// Schema 1 (0.0.1–0.0.3): mode, variation, sources, corrections { stw, leeway, twa, tws },
|
|
321
|
+
// with stw as two rows: linearity { stw, add } and heel { heel, add } by |heel|.
|
|
322
|
+
// Schema 2 (0.0.4): stw is one grid { heel (signed), stw, add[heel][stw] }, blanks allowed;
|
|
323
|
+
// the TWS table is in knots (was percent); both wind tables allow blanks.
|
|
254
324
|
|
|
255
|
-
const CONFIG_SCHEMA =
|
|
325
|
+
const CONFIG_SCHEMA = 2
|
|
256
326
|
const MIGRATIONS = {
|
|
257
|
-
//
|
|
327
|
+
// Two rows become the grid they describe: signed heel rows mirrored from the |heel|
|
|
328
|
+
// row, the linearity speeds as columns, each cell linearity + heel.
|
|
329
|
+
1: cfg => {
|
|
330
|
+
cfg = clone(cfg)
|
|
331
|
+
const t = cfg.corrections && cfg.corrections.tws
|
|
332
|
+
if (t && Array.isArray(t.tws)) {
|
|
333
|
+
// percent of TWS -> knots at each column
|
|
334
|
+
for (const r of ['upwind', 'reach', 'downwind']) {
|
|
335
|
+
if (Array.isArray(t[r])) t[r] = t[r].map((v, j) => typeof v === 'number' ? +(v / 100 * t.tws[j]).toFixed(3) : v)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const old = cfg.corrections && cfg.corrections.stw
|
|
339
|
+
if (!old || !old.linearity || !old.heel) return cfg
|
|
340
|
+
const ah = old.heel.heel
|
|
341
|
+
const heel = Array.from(new Set(ah.map(h => -h).concat(ah))).sort((x, y) => x - y)
|
|
342
|
+
const stwBins = old.linearity.stw
|
|
343
|
+
const add = heel.map(h => stwBins.map((_, j) => +(old.linearity.add[j] + interp(ah, old.heel.add, Math.abs(h))).toFixed(4)))
|
|
344
|
+
return { ...cfg, corrections: { ...cfg.corrections, stw: { enabled: old.enabled, heel, stw: stwBins, add } } }
|
|
345
|
+
}
|
|
258
346
|
}
|
|
259
347
|
|
|
260
348
|
// Bring a raw config up to the current schema. A config without `schema` predates the
|
|
@@ -295,11 +383,19 @@ function normalizeConfig (input) {
|
|
|
295
383
|
if (!['shadow', 'replace'].includes(config.mode)) errors.push('mode: must be shadow or replace')
|
|
296
384
|
checkNumber(errors, 'variation', config.variation)
|
|
297
385
|
|
|
298
|
-
checkAxis(errors, 'STW
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (Array.isArray(
|
|
386
|
+
checkAxis(errors, 'STW table heel angles', k.stw.heel)
|
|
387
|
+
checkAxis(errors, 'STW table speeds', k.stw.stw)
|
|
388
|
+
if (Array.isArray(k.stw.stw) && !(k.stw.stw[0] > 0)) errors.push('STW table speeds: must start above 0 kn')
|
|
389
|
+
const g = k.stw.add
|
|
390
|
+
if (!Array.isArray(g) || !Array.isArray(k.stw.heel) || g.length !== k.stw.heel.length) {
|
|
391
|
+
errors.push('STW table: needs one row per heel angle')
|
|
392
|
+
} else if (g.some(r => !Array.isArray(r) || r.length !== (k.stw.stw || []).length)) {
|
|
393
|
+
errors.push('STW table: every row needs one value per speed')
|
|
394
|
+
} else if (g.some(r => r.some(v => v !== null && (typeof v !== 'number' || !Number.isFinite(v))))) {
|
|
395
|
+
errors.push('STW table: every value must be a number or blank')
|
|
396
|
+
} else if (!g.some(r => r.some(v => v !== null))) {
|
|
397
|
+
errors.push('STW table: has no values at all')
|
|
398
|
+
}
|
|
303
399
|
|
|
304
400
|
checkNumber(errors, 'Leeway K', k.leeway.k)
|
|
305
401
|
|
|
@@ -307,7 +403,7 @@ function normalizeConfig (input) {
|
|
|
307
403
|
const t = k[id]
|
|
308
404
|
const name = id.toUpperCase() + ' table'
|
|
309
405
|
checkAxis(errors, `${name} TWS columns`, t.tws)
|
|
310
|
-
for (const row of ['upwind', 'reach', 'downwind']) checkRow(errors, `${name} ${row} row`, t[row], (t.tws || []).length)
|
|
406
|
+
for (const row of ['upwind', 'reach', 'downwind']) checkRow(errors, `${name} ${row} row`, t[row], (t.tws || []).length, true)
|
|
311
407
|
}
|
|
312
408
|
const ang = k.twa.angles
|
|
313
409
|
;['upwind', 'reach', 'downwind'].forEach(r => checkNumber(errors, `TWA table ${r} angle`, ang[r]))
|
|
@@ -327,5 +423,5 @@ module.exports = {
|
|
|
327
423
|
normalizeConfig,
|
|
328
424
|
CORRECTIONS,
|
|
329
425
|
CONFIG_SCHEMA,
|
|
330
|
-
_internals: { interp, blendRows, tableLookup, merge, norm180, migrate }
|
|
426
|
+
_internals: { interp, interpRow, blendRows, tableLookup, merge, norm180, migrate, fillGaps, stwGridLookup }
|
|
331
427
|
}
|
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.4",
|
|
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
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// Display names and help for each correction, in pipeline order. A correction the
|
|
9
9
|
// plugin knows but this list does not still shows, under its id.
|
|
10
10
|
const CORRECTIONS = {
|
|
11
|
-
stw: { name: 'Boat speed (STW)', what: '
|
|
11
|
+
stw: { name: 'Boat speed (STW)', what: 'Knots added to the paddlewheel by heel (rows, + = starboard side down) and speed (columns), like the H5000 boat speed / heel table. Blank cells are filled from the nearest value in their row. Applied before anything else uses STW.' },
|
|
12
12
|
leeway: { name: 'Leeway', what: 'Sideways slip: leeway = K × heel ÷ STW². Turns the boat’s velocity to leeward for true wind and current.' },
|
|
13
13
|
twa: { name: 'TWA table', what: 'Degrees added to |TWA| by TWS, upwind, reaching and downwind, H5000 style. Negative = TWA was reading too wide. Rows blend by TWA at the angles below.' },
|
|
14
|
-
tws: { name: 'TWS table', what: '
|
|
14
|
+
tws: { name: 'TWS table', what: 'Knots added to TWS by TWS, upwind, reaching and downwind, H5000 style. Blended at the TWA table’s angles. Blank = no correction. The default downwind row is −10% of each column.' }
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const SOURCE_LABELS = {
|
|
@@ -50,14 +50,16 @@
|
|
|
50
50
|
|
|
51
51
|
async function loadConfig () {
|
|
52
52
|
try {
|
|
53
|
-
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')
|
|
54
55
|
state.saved = body.config
|
|
55
56
|
state.draft = clone(body.config)
|
|
56
57
|
state.defaults = body.defaults
|
|
57
|
-
|
|
58
|
+
state.configError = null
|
|
58
59
|
} catch (e) {
|
|
59
|
-
|
|
60
|
-
$
|
|
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)
|
|
61
63
|
}
|
|
62
64
|
renderAll()
|
|
63
65
|
}
|
|
@@ -80,7 +82,7 @@
|
|
|
80
82
|
state.saving = true
|
|
81
83
|
renderSaveBar()
|
|
82
84
|
try {
|
|
83
|
-
const res = await fetch(API + '/
|
|
85
|
+
const res = await fetch(API + '/calibration', {
|
|
84
86
|
method: 'PUT',
|
|
85
87
|
credentials: 'include',
|
|
86
88
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -117,8 +119,9 @@
|
|
|
117
119
|
let value
|
|
118
120
|
if (el.type === 'checkbox') value = el.checked
|
|
119
121
|
else if (el.type === 'number') {
|
|
120
|
-
|
|
121
|
-
value
|
|
122
|
+
// A blank cell in a table that allows it means "not measured"; elsewhere keep the
|
|
123
|
+
// old value until something is typed.
|
|
124
|
+
if (el.value === '') { if (!el.dataset.nullable) return; value = null } else value = Number(el.value)
|
|
122
125
|
} else value = el.value
|
|
123
126
|
setPath(el.dataset.path, value)
|
|
124
127
|
renderSaveBar()
|
|
@@ -138,6 +141,8 @@
|
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
function renderHeader () {
|
|
144
|
+
$('cfgError').textContent = state.configError || ''
|
|
145
|
+
$('cfgError').hidden = !state.configError
|
|
141
146
|
const mode = state.saved ? state.saved.mode : null
|
|
142
147
|
const badge = $('mode')
|
|
143
148
|
badge.textContent = mode === 'replace' ? 'Replace' : mode === 'shadow' ? 'Shadow' : '--'
|
|
@@ -182,7 +187,7 @@
|
|
|
182
187
|
|
|
183
188
|
if (!r) {
|
|
184
189
|
$('tiles').innerHTML = ''
|
|
185
|
-
$('steps').innerHTML = `<tr><td colspan="
|
|
190
|
+
$('steps').innerHTML = `<tr><td colspan="5" class="muted center">${esc(live ? live.status || 'Waiting for data…' : 'Waiting for data…')}</td></tr>`
|
|
186
191
|
} else {
|
|
187
192
|
const o = r.out
|
|
188
193
|
const u = uncorrected(r.input)
|
|
@@ -195,10 +200,23 @@
|
|
|
195
200
|
tile('Leeway', fmt(Math.abs(o.leeway)), `° ${side(o.leeway)}`, r.input.heel == null ? 'no heel' : `heel ${fmt(r.input.heel, 0)}°`),
|
|
196
201
|
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')
|
|
197
202
|
].join('')
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
).
|
|
203
|
+
// A step's result is published only if no later enabled step replaces it; otherwise
|
|
204
|
+
// it is a working value that only feeds the steps after it.
|
|
205
|
+
const last = {}
|
|
206
|
+
r.steps.forEach((s, i) => { if (s.enabled && !s.skipped) (s.produces || []).forEach(q => { last[q] = i }) })
|
|
207
|
+
const NAMES = { stw: 'STW', leeway: 'Leeway', twa: 'TWA', tws: 'TWS', current: 'Current' }
|
|
208
|
+
$('steps').innerHTML = r.steps.map((s, i) => {
|
|
209
|
+
if (!s.enabled) return `<tr class="off"><th scope="row">${esc(s.name)}</th><td></td><td>off</td><td></td><td></td></tr>`
|
|
210
|
+
const sent = (s.produces || []).filter(q => last[q] === i)
|
|
211
|
+
const working = (s.produces || []).filter(q => last[q] !== i)
|
|
212
|
+
const tag = s.skipped ? '' : [
|
|
213
|
+
sent.length ? `<span class="chip pub">Published: ${sent.map(q => NAMES[q]).join(', ')}</span>` : '',
|
|
214
|
+
working.length ? `<span class="chip work">Working: ${working.map(q => NAMES[q]).join(', ')}</span>` : ''
|
|
215
|
+
].join(' ')
|
|
216
|
+
return `<tr class="${sent.length ? '' : 'working'}"><th scope="row">${esc(s.name)}</th><td class="mono">${esc(s.input || '')}</td>` +
|
|
217
|
+
`<td>${esc(s.skipped ? 'skipped — ' + s.skipped : s.detail || '')}</td><td class="mono result">${esc(s.output || '')}</td><td>${tag}</td></tr>`
|
|
218
|
+
}).join('')
|
|
219
|
+
renderPublished(o, live.outputPaths || {})
|
|
202
220
|
}
|
|
203
221
|
|
|
204
222
|
const inputs = live ? Object.entries(live.inputs) : []
|
|
@@ -207,6 +225,22 @@
|
|
|
207
225
|
: '<tr><td colspan="3" class="muted center">Nothing received yet</td></tr>'
|
|
208
226
|
}
|
|
209
227
|
|
|
228
|
+
// The values the plugin actually sends, one per path, matching what index.js publishes.
|
|
229
|
+
function renderPublished (o, paths) {
|
|
230
|
+
const on = id => !!(state.saved && state.saved.corrections[id] && state.saved.corrections[id].enabled)
|
|
231
|
+
const side = v => v < 0 ? 'port' : 'stbd'
|
|
232
|
+
const rows = [
|
|
233
|
+
['TWA', `${fmt(Math.abs(o.twa))}° ${side(o.twa)}`, paths.twa],
|
|
234
|
+
['TWS', `${fmt(o.tws)} kn`, paths.tws]
|
|
235
|
+
]
|
|
236
|
+
if (o.twd != null) rows.push(['TWD', `${fmt(o.twd, 0)}° T`, paths.twd])
|
|
237
|
+
if (on('stw')) rows.push(['STW', `${fmt(o.stw, 2)} kn`, paths.stw])
|
|
238
|
+
if (on('leeway')) rows.push(['Leeway', `${fmt(Math.abs(o.leeway))}° ${side(o.leeway)}`, paths.leeway])
|
|
239
|
+
if (o.current) rows.push(['Current', `${fmt(o.current.drift, 2)} kn → ${fmt(o.current.set, 0)}°`, paths.current])
|
|
240
|
+
$('published').innerHTML = rows.map(([label, value, p]) =>
|
|
241
|
+
`<tr><th scope="row">${esc(label)}</th><td class="num">${esc(value)}</td><td class="mono">${esc(p || '')}</td></tr>`).join('')
|
|
242
|
+
}
|
|
243
|
+
|
|
210
244
|
// -- Calibration
|
|
211
245
|
|
|
212
246
|
function correctionIds () {
|
|
@@ -232,13 +266,16 @@
|
|
|
232
266
|
const meta = CORRECTIONS[id] || { name: id, what: '' }
|
|
233
267
|
let body = ''
|
|
234
268
|
if (id === 'stw') {
|
|
235
|
-
body =
|
|
236
|
-
|
|
269
|
+
body = grid2d('Knots added to STW', 'Heel ° ╲ STW kn', 'corrections.stw', c) +
|
|
270
|
+
`<details class="paste"><summary>Paste a table</summary>` +
|
|
271
|
+
`<p class="muted sub">Same format as signalk-stw-heel-correction: first row <code>heel\\stw</code> then the speeds; each further row a heel angle then its corrections. Commas, tabs or semicolons; blank cells allowed.</p>` +
|
|
272
|
+
`<textarea id="pasteStw" rows="8" spellcheck="false" aria-label="Heel by STW table to paste"></textarea>` +
|
|
273
|
+
`<div><button type="button" class="ghost" id="pasteStwGo">Replace the table</button> <span id="pasteStwMsg" class="warn"></span></div></details>`
|
|
237
274
|
} else if (id === 'leeway') {
|
|
238
275
|
body = `<div class="fields">${field('K', 'corrections.leeway.k', c.k, 0.01)}</div>`
|
|
239
276
|
} else if (id === 'twa' || id === 'tws') {
|
|
240
277
|
const rows = [['Upwind', 'upwind'], ['Reach', 'reach'], ['Downwind', 'downwind']]
|
|
241
|
-
body = grid(id === 'twa' ? 'Degrees added to |TWA|' : '
|
|
278
|
+
body = grid(id === 'twa' ? 'Degrees added to |TWA| (blank = not enough data: nearest measured value used)' : 'Knots added to TWS (blank = no correction)', 'TWS kn', `corrections.${id}`, 'tws', rows, c, true)
|
|
242
279
|
if (id === 'twa') {
|
|
243
280
|
const a = c.angles
|
|
244
281
|
body += `<div class="fields">${field('Upwind row to (° TWA)', 'corrections.twa.angles.upwind', a.upwind, 1)}` +
|
|
@@ -252,16 +289,47 @@
|
|
|
252
289
|
`<div><button type="button" class="ghost" data-defaults="${esc(id)}">Reset to defaults</button></div>`
|
|
253
290
|
}
|
|
254
291
|
|
|
255
|
-
// A table whose
|
|
256
|
-
|
|
292
|
+
// A table whose header row is its fixed axis (the breakpoints, shown as labels) and
|
|
293
|
+
// whose other rows are the editable correction values.
|
|
294
|
+
function grid (title, axisLabel, base, axisKey, rows, obj, nullable) {
|
|
257
295
|
const axis = obj[axisKey]
|
|
258
|
-
const cell = (path, v,
|
|
296
|
+
const cell = (path, v, name) => `<td><input type="number" step="any"${nullable ? ' data-nullable="1"' : ''} data-path="${esc(path)}" value="${v == null ? '' : esc(v)}" aria-label="${esc(name)}"></td>`
|
|
259
297
|
return `<div><h2>${esc(title)}</h2><div class="table-wrap"><table class="grid">` +
|
|
260
|
-
`<tr><th scope="row" class="muted">${esc(axisLabel)}</th>${axis.map(
|
|
261
|
-
rows.map(([label, key]) => `<tr><th scope="row">${esc(label)}</th>${obj[key].map((v, i) => cell(`${base}.${key}.${i}`, v)).join('')}</tr>`).join('') +
|
|
298
|
+
`<tr><th scope="row" class="muted">${esc(axisLabel)}</th>${axis.map(v => `<th scope="col" class="axis">${esc(v)}</th>`).join('')}</tr>` +
|
|
299
|
+
rows.map(([label, key]) => `<tr><th scope="row">${esc(label)}</th>${obj[key].map((v, i) => cell(`${base}.${key}.${i}`, v, `${label} at ${axis[i]}`)).join('')}</tr>`).join('') +
|
|
262
300
|
'</table></div></div>'
|
|
263
301
|
}
|
|
264
302
|
|
|
303
|
+
// A two-dimensional table: fixed row and column headings, editable cells, and blank
|
|
304
|
+
// cells allowed (null = not measured).
|
|
305
|
+
function grid2d (title, corner, base, t) {
|
|
306
|
+
const head = `<tr><th scope="col" class="muted">${esc(corner)}</th>${t.stw.map(v => `<th scope="col" class="axis">${esc(v)}</th>`).join('')}</tr>`
|
|
307
|
+
const rows = t.heel.map((h, i) => `<tr><th scope="row" class="axis">${esc(h)}</th>` +
|
|
308
|
+
t.add[i].map((v, j) => `<td><input type="number" step="any" data-nullable="1" data-path="${base}.add.${i}.${j}" value="${v == null ? '' : esc(v)}" aria-label="heel ${esc(h)} at ${esc(t.stw[j])} kn"></td>`).join('') + '</tr>')
|
|
309
|
+
return `<div><h2>${esc(title)}</h2><div class="table-wrap"><table class="grid">${head}${rows.join('')}</table></div></div>`
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Read a pasted heel x STW table: header row of speeds, then a heel angle and its
|
|
313
|
+
// corrections per row. Tolerates the usual spreadsheet damage.
|
|
314
|
+
function parseStwTable (text) {
|
|
315
|
+
const clean = c => c.trim().replace(/^"(.*)"$/, '$1').replace(/\u2212/g, '-').replace(/\u00b0/g, '').trim()
|
|
316
|
+
const lines = text.replace(/^\ufeff/, '').trim().split(/\r?\n/).filter(l => l.trim())
|
|
317
|
+
if (lines.length < 2) throw new Error('needs a header row of speeds and at least one heel row')
|
|
318
|
+
const sep = lines[0].includes(',') ? ',' : lines[0].includes('\t') ? '\t' : ';'
|
|
319
|
+
const rows = lines.map(l => l.split(sep).map(clean))
|
|
320
|
+
const num = (c, what) => { const n = Number(c); if (c === '' || !Number.isFinite(n)) throw new Error(`${what} is not a number: "${c}"`); return n }
|
|
321
|
+
let stw = rows[0].slice(1).map((c, j) => num(c, `speed ${j + 1} in the header`))
|
|
322
|
+
let heel = []; let add = []
|
|
323
|
+
rows.slice(1).forEach((r, i) => {
|
|
324
|
+
if (r.length - 1 !== stw.length) throw new Error(`row ${i + 2} has ${r.length - 1} values, the header has ${stw.length} speeds`)
|
|
325
|
+
heel.push(num(r[0], `heel in row ${i + 2}`))
|
|
326
|
+
add.push(r.slice(1).map((c, j) => c === '' ? null : num(c, `value at heel ${r[0]}, ${stw[j]} kn`)))
|
|
327
|
+
})
|
|
328
|
+
if (stw.length > 1 && stw[0] > stw[stw.length - 1]) { stw = stw.reverse(); add = add.map(r => r.reverse()) }
|
|
329
|
+
if (heel.length > 1 && heel[0] > heel[heel.length - 1]) { heel = heel.reverse(); add = add.reverse() }
|
|
330
|
+
return { heel, stw, add }
|
|
331
|
+
}
|
|
332
|
+
|
|
265
333
|
function field (label, path, value, step) {
|
|
266
334
|
return `<label class="field">${esc(label)}<input type="number" step="${step}" data-path="${esc(path)}" value="${esc(value)}"></label>`
|
|
267
335
|
}
|
|
@@ -313,6 +381,15 @@
|
|
|
313
381
|
document.addEventListener('click', e => {
|
|
314
382
|
const sel = e.target.closest('[data-select]')
|
|
315
383
|
if (sel) { state.selected = sel.dataset.select; renderCalibration() }
|
|
384
|
+
if (e.target.id === 'pasteStwGo') {
|
|
385
|
+
try {
|
|
386
|
+
const t = parseStwTable($('pasteStw').value)
|
|
387
|
+
Object.assign(state.draft.corrections.stw, t)
|
|
388
|
+
renderAll()
|
|
389
|
+
} catch (err) {
|
|
390
|
+
$('pasteStwMsg').textContent = err.message
|
|
391
|
+
}
|
|
392
|
+
}
|
|
316
393
|
const def = e.target.closest('[data-defaults]')
|
|
317
394
|
if (def && state.defaults) {
|
|
318
395
|
const id = def.dataset.defaults
|
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>
|
|
@@ -34,10 +35,20 @@
|
|
|
34
35
|
<div id="tiles" class="tiles"></div>
|
|
35
36
|
<div class="panel">
|
|
36
37
|
<div class="panel-head"><h2>Pipeline</h2><span id="liveAge" class="muted sub"></span></div>
|
|
38
|
+
<p class="muted sub intro">Each step works on the result of the one before. Only results marked <b>Published</b> are sent to Signal K; <b>Working</b> results feed the next step and go nowhere else.</p>
|
|
37
39
|
<div class="table-wrap">
|
|
38
40
|
<table>
|
|
39
|
-
<thead><tr><th>Step</th><th>In</th><th>Correction</th><th>
|
|
40
|
-
<tbody id="steps"><tr><td colspan="
|
|
41
|
+
<thead><tr><th>Step</th><th>In</th><th>Correction</th><th>Result</th><th>Sent to Signal K</th></tr></thead>
|
|
42
|
+
<tbody id="steps"><tr><td colspan="5" class="muted center">Waiting for data…</td></tr></tbody>
|
|
43
|
+
</table>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="panel">
|
|
47
|
+
<div class="panel-head"><h2>Published to Signal K</h2><span class="muted sub">only these values are sent; the pipeline above shows the working</span></div>
|
|
48
|
+
<div class="table-wrap">
|
|
49
|
+
<table>
|
|
50
|
+
<thead><tr><th>Value</th><th class="num">Now</th><th>Path</th></tr></thead>
|
|
51
|
+
<tbody id="published"><tr><td colspan="3" class="muted center">Nothing published yet</td></tr></tbody>
|
|
41
52
|
</table>
|
|
42
53
|
</div>
|
|
43
54
|
</div>
|
package/public/style.css
CHANGED
|
@@ -104,6 +104,11 @@ table { width: 100%; border-collapse: collapse; }
|
|
|
104
104
|
th, td { padding: 8px; border-bottom: 1px solid var(--line); white-space: nowrap; text-align: left; }
|
|
105
105
|
th { font-size: 0.75em; color: var(--muted); text-transform: uppercase; }
|
|
106
106
|
tr.off td { color: var(--muted); }
|
|
107
|
+
/* Pipeline: published results stand out, working values recede */
|
|
108
|
+
.chip { display: inline-block; font-size: 0.7em; font-weight: 700; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
|
|
109
|
+
.chip.pub { background: var(--active); color: var(--accent); border: 1px solid var(--accent); }
|
|
110
|
+
.chip.work { color: var(--muted); border: 1px dashed var(--line); }
|
|
111
|
+
tr.working .result { color: var(--muted); font-style: italic; }
|
|
107
112
|
td.mono { font-variant-numeric: tabular-nums; }
|
|
108
113
|
|
|
109
114
|
/* Calibration */
|
|
@@ -153,12 +158,16 @@ td.mono { font-variant-numeric: tabular-nums; }
|
|
|
153
158
|
.grid th, .grid td { border: 0; padding: 0; }
|
|
154
159
|
.grid th { padding: 0 6px; }
|
|
155
160
|
.grid input { width: 4.6em; text-align: right; font-variant-numeric: tabular-nums; background: var(--cell); }
|
|
156
|
-
.grid
|
|
161
|
+
.grid th.axis { text-align: right; padding: 0 10px 2px; font-size: 0.8em; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
157
162
|
.grid input:focus, .field input:focus { outline: 2px solid var(--accent); outline-offset: 0; }
|
|
158
163
|
.fields { display: flex; flex-wrap: wrap; gap: 16px; }
|
|
159
164
|
.field { display: flex; flex-direction: column; gap: 4px; font-size: 0.8em; font-weight: 600; }
|
|
160
165
|
.field input { width: 7em; }
|
|
161
166
|
|
|
167
|
+
.paste summary { cursor: pointer; font-size: 0.85em; color: var(--accent); min-height: 32px; }
|
|
168
|
+
.paste textarea { width: 100%; font: 0.8em ui-monospace, monospace; background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
|
|
169
|
+
.paste code { font-size: 0.95em; }
|
|
170
|
+
|
|
162
171
|
/* Settings */
|
|
163
172
|
.modes { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
164
173
|
.mode { display: flex; gap: 12px; align-items: flex-start; padding: 10px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; }
|