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 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
- > **Proof of concept (0.0.2).** Run it in Shadow mode alongside your instruments and compare before relying on it.
5
+ **True wind direction that holds steady when you tack.**
6
6
 
7
- Signal K plugin and webapp that does the calibration job of an instrument processor such as a B&G H5000: it corrects boat speed and leeway, works out true wind from apparent wind, applies H5000-style TWA and TWS correction tables, and publishes true wind and current.
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
- Each correction can be switched on or off and edited in the webapp. A fresh install starts with values fitted to this boat's 2025 race logs.
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
- See the [changelog](CHANGELOG.md) for what changed in each release.
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: a **linearity** row by speed plus a **heel correction** row by heel, the same split as the H5000 | On |
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 | Percent applied to TWS by TWS, same rows and blending | Off |
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, what each step is doing right now, and which source and how fresh each input is.
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 fitted values for that correction.
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
- Fitted to the 2025 race logs and checked on Jan–Mar 2026 races not used in fitting, by how little true wind direction and current jump through tacks, gybes and roundings.
110
+ What a fresh install starts with. Every value can be changed in the webapp.
73
111
 
74
- - **STW:** linearity +0.58 kn at 0.5 kn falling to 0 at 8.5 kn (tapering to 0 below 0.5 kn); heel 0 to −0.29 kn at 30°. This is the owner's measured heel × STW table, reduced to two rows within 0.03 kn of every cell.
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 | −12.0 | −13.4 | −5.7 | −4.3 | −4.3 | −4.3 |
81
- | Reach | −10.7 | −10.7 | −10.7 | −10.7 | −10.7 | −10.7 |
82
- | Downwind | −4.2 | −3.5 | −1.6 | −1.3 | −1.3 | −1.3 |
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
- The reach row is provisional: it rests on roundings only.
85
- - **TWS table (%):** off. Downwind −11.1% at 15 kn and −14.4% from 20 kn; light air and upwind left at 0.
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": 1,
132
+ "schema": 2,
95
133
  "mode": "shadow",
96
134
  "variation": 12.8,
97
135
  "sources": { "apparentWind": "", "stw": "", "heading": "", "attitude": "", "gps": "", "variation": "" },
98
- "corrections": { "stw": { … }, "leeway": { … }, "twa": { … }, "tws": { … } }
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 /config` returns the configuration in use and the defaults.
113
- - `PUT /config` validates, saves and restarts with a new configuration. It returns 400 with the reasons if the configuration is invalid.
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: 'H5000-style calibration: corrects STW and leeway, then true wind angle and speed, and publishes true wind and current'
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
- router.get('/config', (req, res) => {
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('/config', async (req, res) => {
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
- linearity: { stw: [0.5, 1, 2, 3, 4, 6, 8.5], add: [0.58, 0.61, 0.38, 0.19, 0.09, 0.02, 0.00] },
21
- heel: { heel: [0, 10, 20, 30, 35], add: [0.00, -0.03, -0.13, -0.29, -0.29] }
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
- // Rows are blended by TWA: upwind in full to angles.upwind, reach at angles.reach,
28
- // downwind from angles.downwind. The TWS table shares these angles.
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: [-12.0, -13.4, -5.7, -4.3, -4.3, -4.3],
33
- reach: [-10.7, -10.7, -10.7, -10.7, -10.7, -10.7],
34
- downwind: [-4.2, -3.5, -1.6, -1.3, -1.3, -1.3],
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
- // Percent applied to TWS by TWS, same rows and blending as the TWA table.
38
- // Off by default: light air fitted to implausible values, so it is zeroed here.
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: [0, 0, 0, 0, 0, 0],
43
- reach: [0, 0, 0, 0, 0, 0],
44
- downwind: [0, 0, -11.1, -14.4, -14.4, -14.4]
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
- interp(table.tws, table.upwind, tws),
49
- interp(table.tws, table.reach, tws),
50
- interp(table.tws, table.downwind, tws))
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
- // Linearity starts at its first bin; below that taper to zero at 0 kn, so a
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 + add1 + add2)
127
+ c.stw = Math.max(0, c.stw + add)
70
128
  return {
71
- input: `${before.toFixed(2)} kn` + (c.heel == null ? '' : ` · heel ${Math.abs(c.heel).toFixed(0)}°`),
72
- detail: `${fmt(add1, 2)} linearity, ${fmt(add2, 2)} heel` + (c.heel == null ? ' (no heel data)' : ''),
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
- const pct = tableLookup(cfg, all.corrections.twa.angles, Math.abs(c.twa), c.tws)
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 * (1 + pct / 100)
181
+ c.tws = Math.max(0, c.tws + kn)
118
182
  return {
119
183
  input: `TWS ${before.toFixed(1)} kn`,
120
- detail: `${fmt(pct)}%`,
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
- name: 'True wind',
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
- if (ys.some(v => typeof v !== 'number' || !Number.isFinite(v))) errors.push(`${label}: every value must be a number`)
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 = 1
325
+ const CONFIG_SCHEMA = 2
256
326
  const MIGRATIONS = {
257
- // 1: cfg => ({ ...cfg, schema: 2, ... }),
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 linearity speeds', k.stw.linearity.stw)
299
- checkRow(errors, 'STW linearity values', k.stw.linearity.add, (k.stw.linearity.stw || []).length)
300
- checkAxis(errors, 'STW heel angles', k.stw.heel.heel)
301
- checkRow(errors, 'STW heel values', k.stw.heel.add, (k.stw.heel.heel || []).length)
302
- if (Array.isArray(k.stw.linearity.stw) && !(k.stw.linearity.stw[0] > 0)) errors.push('STW linearity speeds: must start above 0 kn')
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.2",
4
- "description": "H5000-style calibration for Signal K: corrects boat speed and leeway, applies TWA and TWS correction tables, and publishes true wind and current. Proof of concept.",
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: 'Paddlewheel linearity at low speed, plus the loss when heeled. Knots added to STW before anything else uses it.' },
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: 'Percent applied to TWS by TWS, upwind, reaching and downwind. Blended at the TWA table’s angles. H5000 default is −10% downwind.' }
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('/config')
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
- $('conn').hidden = true
58
+ state.configError = null
58
59
  } catch (e) {
59
- $('conn').textContent = `Cannot reach the plugin (${e.message}). Is Performance Processor enabled?`
60
- $('conn').hidden = false
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 + '/config', {
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
- if (el.value === '') return // keep the old value until something is typed
121
- value = Number(el.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="4" class="muted center">${esc(live ? live.status || 'Waiting for data…' : 'Waiting for data…')}</td></tr>`
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
- $('steps').innerHTML = r.steps.map(s => s.enabled
199
- ? `<tr><th scope="row">${esc(s.name)}</th><td class="mono">${esc(s.input || '')}</td><td>${esc(s.skipped ? 'skipped — ' + s.skipped : s.detail || '')}</td><td class="mono">${esc(s.output || '')}</td></tr>`
200
- : `<tr class="off"><th scope="row">${esc(s.name)}</th><td></td><td>off</td><td></td></tr>`
201
- ).join('')
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 = grid('Linearity (kn added)', 'STW kn', `corrections.stw.linearity`, 'stw', [['Add', 'add']], c.linearity) +
236
- grid('Heel correction (kn added)', 'Heel °', `corrections.stw.heel`, 'heel', [['Add', 'add']], c.heel)
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|' : 'Percent applied to TWS', 'TWS kn', `corrections.${id}`, 'tws', rows, c)
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 first row is its axis (editable) and whose other rows are values.
256
- function grid (title, axisLabel, base, axisKey, rows, obj) {
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, cls) => `<td><input type="number" step="any" class="${cls || ''}" data-path="${esc(path)}" value="${esc(v)}"></td>`
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((v, i) => cell(`${base}.${axisKey}.${i}`, v, 'axis')).join('')}</tr>` +
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>Out</th></tr></thead>
40
- <tbody id="steps"><tr><td colspan="4" class="muted center">Waiting for data…</td></tr></tbody>
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 input.axis { background: var(--bg); color: var(--muted); }
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; }