signalk-performance-processor 0.0.0-stage → 0.0.2

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.
@@ -0,0 +1,331 @@
1
+ 'use strict'
2
+
3
+ // The processing pipeline, free of Signal K so it can be tested and replayed against
4
+ // logged data. Units throughout: degrees and knots. Angles are signed, + = starboard;
5
+ // heel + = starboard side down; leeway + = sliding to starboard.
6
+ //
7
+ // Order of work:
8
+ // input corrections (CORRECTIONS with stage 'input')
9
+ // true wind apparent wind minus boat velocity through the water
10
+ // true wind corrections (stage 'trueWind')
11
+ // derived TWD and current
12
+ //
13
+ // A new correction is one more entry in CORRECTIONS plus its defaults in defaults.js;
14
+ // the pipeline, the Live view and the enable switch pick it up from there.
15
+
16
+ const { DEFAULTS } = require('./defaults')
17
+
18
+ const MAX_LEEWAY_DEG = 15
19
+ const MIN_LEEWAY_STW_KN = 1 // below this K x heel / STW^2 blows up; no leeway
20
+
21
+ const norm360 = d => ((d % 360) + 360) % 360
22
+ const norm180 = d => { const n = norm360(d); return n > 180 ? n - 360 : n }
23
+ const rad = d => d * Math.PI / 180
24
+ const deg = r => r * 180 / Math.PI
25
+
26
+ // Linear interpolation, holding the end values beyond either end.
27
+ function interp (xs, ys, x) {
28
+ if (x <= xs[0]) return ys[0]
29
+ const n = xs.length - 1
30
+ if (x >= xs[n]) return ys[n]
31
+ let i = 0
32
+ while (xs[i + 1] < x) i++
33
+ const t = (x - xs[i]) / (xs[i + 1] - xs[i])
34
+ return ys[i] + (ys[i + 1] - ys[i]) * t
35
+ }
36
+
37
+ // Upwind row in full up to angles.upwind, reach row at angles.reach, downwind row from
38
+ // angles.downwind, linear in |TWA| between.
39
+ function blendRows (absTwa, angles, up, reach, down) {
40
+ if (absTwa <= angles.upwind) return up
41
+ if (absTwa <= angles.reach) return up + (reach - up) * (absTwa - angles.upwind) / (angles.reach - angles.upwind)
42
+ if (absTwa <= angles.downwind) return reach + (down - reach) * (absTwa - angles.reach) / (angles.downwind - angles.reach)
43
+ return down
44
+ }
45
+
46
+ function tableLookup (table, angles, absTwa, tws) {
47
+ 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))
51
+ }
52
+
53
+ const fmt = (v, d = 1) => (v > 0 ? '+' : '') + v.toFixed(d)
54
+
55
+ const CORRECTIONS = [
56
+ {
57
+ id: 'stw',
58
+ stage: 'input',
59
+ name: 'Boat speed (STW)',
60
+ 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))
68
+ const before = c.stw
69
+ c.stw = Math.max(0, c.stw + add1 + add2)
70
+ 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)' : ''),
73
+ output: `${c.stw.toFixed(2)} kn`
74
+ }
75
+ }
76
+ },
77
+ {
78
+ id: 'leeway',
79
+ stage: 'input',
80
+ name: 'Leeway',
81
+ apply (c, cfg) {
82
+ if (c.heel == null) return { skipped: 'no heel data' }
83
+ const k = cfg.k
84
+ c.leeway = c.stw < MIN_LEEWAY_STW_KN
85
+ ? 0
86
+ : Math.max(-MAX_LEEWAY_DEG, Math.min(MAX_LEEWAY_DEG, k * c.heel / (c.stw * c.stw)))
87
+ return {
88
+ input: `heel ${c.heel.toFixed(0)}°`,
89
+ detail: `K ${k} × heel ÷ STW²`,
90
+ output: `${Math.abs(c.leeway).toFixed(1)}° ${c.leeway < 0 ? 'port' : 'stbd'}`
91
+ }
92
+ }
93
+ },
94
+ {
95
+ id: 'twa',
96
+ stage: 'trueWind',
97
+ name: 'TWA table',
98
+ apply (c, cfg) {
99
+ const abs = Math.abs(c.twa)
100
+ const add = tableLookup(cfg, cfg.angles, abs, c.tws)
101
+ const before = c.twa
102
+ c.twa = Math.sign(c.twa || 1) * Math.max(0, Math.min(180, abs + add))
103
+ return {
104
+ input: `TWA ${Math.abs(before).toFixed(1)}° · TWS ${c.tws.toFixed(1)}`,
105
+ detail: `${fmt(add)}°`,
106
+ output: `TWA ${Math.abs(c.twa).toFixed(1)}°`
107
+ }
108
+ }
109
+ },
110
+ {
111
+ id: 'tws',
112
+ stage: 'trueWind',
113
+ name: 'TWS table',
114
+ apply (c, cfg, all) {
115
+ const pct = tableLookup(cfg, all.corrections.twa.angles, Math.abs(c.twa), c.tws)
116
+ const before = c.tws
117
+ c.tws = c.tws * (1 + pct / 100)
118
+ return {
119
+ input: `TWS ${before.toFixed(1)} kn`,
120
+ detail: `${fmt(pct)}%`,
121
+ output: `TWS ${c.tws.toFixed(1)} kn`
122
+ }
123
+ }
124
+ }
125
+ ]
126
+
127
+ function runStage (stage, c, config, steps) {
128
+ for (const corr of CORRECTIONS.filter(x => x.stage === stage)) {
129
+ const cfg = config.corrections[corr.id]
130
+ if (!cfg || !cfg.enabled) {
131
+ steps.push({ id: corr.id, name: corr.name, enabled: false })
132
+ continue
133
+ }
134
+ const r = corr.apply(c, cfg, config)
135
+ steps.push({ id: corr.id, name: corr.name, enabled: true, ...r })
136
+ }
137
+ }
138
+
139
+ // Input corrections only: STW and leeway from raw STW and heel. Used on its own to
140
+ // republish corrected STW as soon as the paddlewheel reports.
141
+ function correctInputs (input, config) {
142
+ const c = { stw: input.stw, heel: input.heel == null ? null : input.heel, leeway: 0 }
143
+ const steps = []
144
+ runStage('input', c, config, steps)
145
+ return { c, steps }
146
+ }
147
+
148
+ // input: { awa, aws, stw, heel?, headingMagnetic?, headingTrue?, variation?, sog?, cog? }
149
+ // returns { out: { stw, leeway, twa, tws, heading?, twd?, current? }, steps }
150
+ function process (input, config) {
151
+ const { c, steps } = correctInputs(input, config)
152
+
153
+ // True wind: apparent wind minus the boat's velocity through the water, which points
154
+ // along the heading turned by leeway.
155
+ const a = rad(input.awa)
156
+ const l = rad(c.leeway)
157
+ const x = input.aws * Math.cos(a) - c.stw * Math.cos(l)
158
+ const y = input.aws * Math.sin(a) - c.stw * Math.sin(l)
159
+ c.twa = deg(Math.atan2(y, x))
160
+ c.tws = Math.hypot(x, y)
161
+ steps.push({
162
+ id: 'truewind',
163
+ name: 'True wind',
164
+ enabled: true,
165
+ input: `AWA ${Math.abs(input.awa).toFixed(1)}° · ${input.aws.toFixed(1)} kn`,
166
+ detail: 'apparent wind − boat velocity',
167
+ output: `TWA ${Math.abs(c.twa).toFixed(1)}° · ${c.tws.toFixed(1)} kn`
168
+ })
169
+
170
+ runStage('trueWind', c, config, steps)
171
+
172
+ const out = { stw: c.stw, leeway: c.leeway, twa: c.twa, tws: c.tws }
173
+
174
+ // True heading: from a true-heading source if there is one, otherwise magnetic
175
+ // heading plus variation, taken from Signal K (the GPS) when available and from the
176
+ // configured fallback when not.
177
+ let heading = null
178
+ if (input.headingTrue != null) {
179
+ heading = input.headingTrue
180
+ out.headingFrom = 'true heading'
181
+ } else if (input.headingMagnetic != null) {
182
+ const fromSignalK = input.variation != null
183
+ out.variation = fromSignalK ? input.variation : config.variation
184
+ out.headingFrom = fromSignalK ? 'magnetic + variation from Signal K' : 'magnetic + fallback variation'
185
+ heading = input.headingMagnetic + out.variation
186
+ }
187
+ if (heading != null) {
188
+ out.heading = norm360(heading)
189
+ out.twd = norm360(heading + c.twa)
190
+ if (input.sog != null && input.cog != null) {
191
+ // Current = ground velocity - water velocity; set is where it flows to.
192
+ const cr = rad(input.cog)
193
+ const wr = rad(heading + c.leeway)
194
+ const e = input.sog * Math.sin(cr) - c.stw * Math.sin(wr)
195
+ const n = input.sog * Math.cos(cr) - c.stw * Math.cos(wr)
196
+ out.current = { drift: Math.hypot(e, n), set: norm360(deg(Math.atan2(e, n))) }
197
+ steps.push({
198
+ id: 'current',
199
+ name: 'Current',
200
+ enabled: true,
201
+ input: `SOG ${input.sog.toFixed(1)} · COG ${input.cog.toFixed(0)}°`,
202
+ detail: 'ground − water velocity',
203
+ output: `${out.current.drift.toFixed(2)} kn → ${out.current.set.toFixed(0)}°`
204
+ })
205
+ }
206
+ }
207
+ return { out, steps }
208
+ }
209
+
210
+ // ---- configuration --------------------------------------------------------
211
+
212
+ const clone = o => JSON.parse(JSON.stringify(o))
213
+
214
+ function isPlainObject (o) {
215
+ return o !== null && typeof o === 'object' && !Array.isArray(o)
216
+ }
217
+
218
+ // Defaults first, then whatever the stored config sets. Arrays replace, objects merge.
219
+ function merge (base, over) {
220
+ if (!isPlainObject(over)) return base
221
+ const out = clone(base)
222
+ for (const [k, v] of Object.entries(over)) {
223
+ if (isPlainObject(v) && isPlainObject(out[k])) out[k] = merge(out[k], v)
224
+ else if (v !== undefined) out[k] = v
225
+ }
226
+ return out
227
+ }
228
+
229
+ function checkAxis (errors, label, xs) {
230
+ if (!Array.isArray(xs) || xs.length < 1) { errors.push(`${label}: needs at least one value`); return }
231
+ if (xs.some(v => typeof v !== 'number' || !Number.isFinite(v))) { errors.push(`${label}: every value must be a number`); return }
232
+ for (let i = 1; i < xs.length; i++) {
233
+ if (!(xs[i] > xs[i - 1])) { errors.push(`${label}: must increase from left to right`); return }
234
+ }
235
+ }
236
+
237
+ function checkRow (errors, label, ys, n) {
238
+ 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`)
240
+ }
241
+
242
+ function checkNumber (errors, label, v) {
243
+ if (typeof v !== 'number' || !Number.isFinite(v)) errors.push(`${label}: must be a number`)
244
+ }
245
+
246
+ // ---- file format versions ----------------------------------------------------
247
+ //
248
+ // Every saved config carries `schema`, the version of its shape. When a release changes
249
+ // that shape, bump CONFIG_SCHEMA and add MIGRATIONS[old], a function that turns a
250
+ // config of schema `old` into schema `old + 1`. Older files are then upgraded step by
251
+ // step as they load and saved back in the current shape; nothing is lost or reset.
252
+ //
253
+ // Schema 1 (0.0.1): mode, variation, sources, corrections { stw, leeway, twa, tws }.
254
+
255
+ const CONFIG_SCHEMA = 1
256
+ const MIGRATIONS = {
257
+ // 1: cfg => ({ ...cfg, schema: 2, ... }),
258
+ }
259
+
260
+ // Bring a raw config up to the current schema. A config without `schema` predates the
261
+ // field and has the schema 1 shape. One from a newer release is refused rather than
262
+ // guessed at, since this version cannot know what its fields mean.
263
+ function migrate (raw, migrations = MIGRATIONS, current = CONFIG_SCHEMA) {
264
+ let cfg = clone(raw)
265
+ let v = cfg.schema == null ? 1 : cfg.schema
266
+ if (!Number.isInteger(v) || v < 1) return { error: `schema: "${cfg.schema}" is not a schema version` }
267
+ if (v > current) return { error: `schema ${v} was saved by a newer version of this plugin (this one reads up to ${current}); update the plugin` }
268
+ while (v < current) {
269
+ if (!migrations[v]) return { error: `schema: no upgrade from ${v} to ${v + 1}` }
270
+ cfg = migrations[v](cfg)
271
+ v++
272
+ }
273
+ cfg.schema = current
274
+ return { config: cfg }
275
+ }
276
+
277
+ // Merge a stored or submitted config over the defaults and check it. Numbers typed
278
+ // into a form arrive as strings, so coerce numeric-looking strings first.
279
+ function normalizeConfig (input) {
280
+ const coerce = v => {
281
+ if (Array.isArray(v)) return v.map(coerce)
282
+ if (isPlainObject(v)) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, coerce(x)]))
283
+ if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
284
+ return v
285
+ }
286
+ const migrated = migrate(isPlainObject(input) ? input : {})
287
+ if (migrated.error) return { config: { ...clone(DEFAULTS), schema: CONFIG_SCHEMA }, errors: [migrated.error] }
288
+ const raw = migrated.config
289
+ // Source names are text even when they look numeric.
290
+ const keepText = { sources: raw.sources, mode: raw.mode }
291
+ const config = merge({ schema: CONFIG_SCHEMA, ...DEFAULTS }, { ...coerce(raw), ...Object.fromEntries(Object.entries(keepText).filter(([, v]) => v !== undefined)) })
292
+ const errors = []
293
+ const k = config.corrections
294
+
295
+ if (!['shadow', 'replace'].includes(config.mode)) errors.push('mode: must be shadow or replace')
296
+ checkNumber(errors, 'variation', config.variation)
297
+
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')
303
+
304
+ checkNumber(errors, 'Leeway K', k.leeway.k)
305
+
306
+ for (const id of ['twa', 'tws']) {
307
+ const t = k[id]
308
+ const name = id.toUpperCase() + ' table'
309
+ 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)
311
+ }
312
+ const ang = k.twa.angles
313
+ ;['upwind', 'reach', 'downwind'].forEach(r => checkNumber(errors, `TWA table ${r} angle`, ang[r]))
314
+ if (!(ang.upwind < ang.reach && ang.reach < ang.downwind && ang.upwind > 0 && ang.downwind < 180)) {
315
+ errors.push('TWA table angles: need 0 < upwind < reach < downwind < 180')
316
+ }
317
+
318
+ for (const [id, c] of Object.entries(k)) {
319
+ if (typeof c.enabled !== 'boolean') errors.push(`${id}: enabled must be true or false`)
320
+ }
321
+ return { config, errors }
322
+ }
323
+
324
+ module.exports = {
325
+ process,
326
+ correctInputs,
327
+ normalizeConfig,
328
+ CORRECTIONS,
329
+ CONFIG_SCHEMA,
330
+ _internals: { interp, blendRows, tableLookup, merge, norm180, migrate }
331
+ }
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "signalk-performance-processor",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
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.",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "node --test",
8
+ "prepublishOnly": "npm test"
9
+ },
10
+ "keywords": [
11
+ "signalk-node-server-plugin",
12
+ "signalk-webapp",
13
+ "signalk-category-utility",
14
+ "signalk",
15
+ "sailing",
16
+ "racing",
17
+ "true-wind",
18
+ "calibration",
19
+ "leeway",
20
+ "speed-through-water"
21
+ ],
22
+ "signalk-plugin-enabled-by-default": false,
23
+ "signalk": {
24
+ "displayName": "Performance Processor",
25
+ "appIcon": "./icon.svg"
26
+ },
27
+ "files": [
28
+ "index.js",
29
+ "lib/",
30
+ "public/",
31
+ "CHANGELOG.md"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/ghotihook/signalk-performance-processor.git"
39
+ },
40
+ "homepage": "https://github.com/ghotihook/signalk-performance-processor#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/ghotihook/signalk-performance-processor/issues"
43
+ },
44
+ "author": "ghotihook",
45
+ "license": "MIT"
46
+ }