signalk-performance-processor 0.0.1 → 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.
- package/CHANGELOG.md +8 -0
- package/README.md +21 -5
- package/index.js +65 -45
- package/lib/defaults.js +4 -2
- package/lib/processor.js +51 -7
- package/package.json +1 -1
- package/public/app.js +10 -2
- package/public/index.html +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.2 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
Still a proof of concept: run it in Shadow mode alongside your instruments.
|
|
6
|
+
|
|
7
|
+
- Heading for TWD and current comes from `navigation.headingTrue` if present, otherwise magnetic heading plus `navigation.magneticVariation` (normally from the GPS). The variation in Settings is now only a fallback, and the Live page says which is in use.
|
|
8
|
+
- Configuration is edited only in the webapp and saved to `config.json` in the plugin's data directory. The admin Plugin Config form no longer shows the tables, so saving it cannot overwrite them. A configuration saved by 0.0.1 is picked up until the webapp first saves.
|
|
9
|
+
- The configuration file records its format version (`"schema": 1`). Future releases upgrade older files as they load; a file from a newer release is refused and never overwritten.
|
|
10
|
+
|
|
3
11
|
## 0.0.1 — 2026-10-29
|
|
4
12
|
|
|
5
13
|
First release, a proof of concept. Run it in Shadow mode alongside your instruments before relying on it.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# signalk-performance-processor
|
|
4
4
|
|
|
5
|
-
> **Proof of concept (0.0.
|
|
5
|
+
> **Proof of concept (0.0.2).** Run it in Shadow mode alongside your instruments and compare before relying on it.
|
|
6
6
|
|
|
7
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.
|
|
8
8
|
|
|
@@ -29,7 +29,7 @@ The corrections run in this order, each on the output of the one before:
|
|
|
29
29
|
|---|---|
|
|
30
30
|
| Apparent wind | `environment.wind.angleApparent`, `environment.wind.speedApparent` |
|
|
31
31
|
| Speed through water | `navigation.speedThroughWater` |
|
|
32
|
-
| Heading | `navigation.headingTrue
|
|
32
|
+
| Heading | `navigation.headingTrue`; otherwise `navigation.headingMagnetic` + `navigation.magneticVariation` (normally from the GPS); otherwise magnetic heading + the fallback variation in Settings |
|
|
33
33
|
| Heel | `navigation.attitude` (roll) |
|
|
34
34
|
| SOG and COG | `navigation.speedOverGround`, `navigation.courseOverGroundTrue` |
|
|
35
35
|
|
|
@@ -63,9 +63,9 @@ Open **Webapps → Performance Processor**.
|
|
|
63
63
|
|
|
64
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
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.
|
|
66
|
-
- **Settings**: Shadow or Replace, magnetic variation, and which source to read for each input.
|
|
66
|
+
- **Settings**: Shadow or Replace, the fallback magnetic variation, and which source to read for each input.
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
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
69
|
|
|
70
70
|
## Defaults for this boat
|
|
71
71
|
|
|
@@ -83,7 +83,23 @@ Fitted to the 2025 race logs and checked on Jan–Mar 2026 races not used in fit
|
|
|
83
83
|
|
|
84
84
|
The reach row is provisional: it rests on roundings only.
|
|
85
85
|
- **TWS table (%):** off. Downwind −11.1% at 15 kn and −14.4% from 20 kn; light air and upwind left at 0.
|
|
86
|
-
- **
|
|
86
|
+
- **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
|
+
|
|
88
|
+
## Configuration file
|
|
89
|
+
|
|
90
|
+
`config.json` in the plugin's data directory, written by the webapp:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"schema": 1,
|
|
95
|
+
"mode": "shadow",
|
|
96
|
+
"variation": 12.8,
|
|
97
|
+
"sources": { "apparentWind": "", "stw": "", "heading": "", "attitude": "", "gps": "", "variation": "" },
|
|
98
|
+
"corrections": { "stw": { … }, "leeway": { … }, "twa": { … }, "tws": { … } }
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
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.
|
|
87
103
|
|
|
88
104
|
## Adding a correction
|
|
89
105
|
|
package/index.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
3
5
|
const { DEFAULTS } = require('./lib/defaults')
|
|
4
|
-
const { process, correctInputs, normalizeConfig, CORRECTIONS } = require('./lib/processor')
|
|
6
|
+
const { process, correctInputs, normalizeConfig, CORRECTIONS, CONFIG_SCHEMA } = require('./lib/processor')
|
|
5
7
|
|
|
6
8
|
const MS_TO_KN = 1.94384
|
|
7
9
|
const RAD_TO_DEG = 180 / Math.PI
|
|
8
10
|
const STALE_MS = 3000 // an input older than this is not used
|
|
11
|
+
// Variation changes over days, not seconds; a GPS that drops it briefly should not
|
|
12
|
+
// push heading onto the fallback value.
|
|
13
|
+
const VARIATION_MAX_AGE_MS = 10 * 60 * 1000
|
|
14
|
+
const CONFIG_FILE = 'config.json'
|
|
9
15
|
const STATUS_INTERVAL_MS = 1000
|
|
10
16
|
// Bounds for sanity-checking sensor input. Anything outside these is a fault, not a reading.
|
|
11
17
|
const MAX_PLAUSIBLE_STW_KN = 60
|
|
@@ -21,7 +27,8 @@ const INPUTS = {
|
|
|
21
27
|
stw: ['navigation.speedThroughWater'],
|
|
22
28
|
heading: ['navigation.headingMagnetic', 'navigation.headingTrue'],
|
|
23
29
|
attitude: ['navigation.attitude'],
|
|
24
|
-
gps: ['navigation.speedOverGround', 'navigation.courseOverGroundTrue']
|
|
30
|
+
gps: ['navigation.speedOverGround', 'navigation.courseOverGroundTrue'],
|
|
31
|
+
variation: ['navigation.magneticVariation']
|
|
25
32
|
}
|
|
26
33
|
const INPUT_OF = Object.fromEntries(Object.entries(INPUTS).flatMap(([k, ps]) => ps.map(p => [p, k])))
|
|
27
34
|
|
|
@@ -56,7 +63,14 @@ module.exports = function (app) {
|
|
|
56
63
|
let lastResultAt = 0
|
|
57
64
|
let lastStatus = ''
|
|
58
65
|
|
|
59
|
-
plugin
|
|
66
|
+
// Everything is configured in the webapp and kept in this plugin's data directory,
|
|
67
|
+
// not in the plugin options: the admin form would otherwise show the tables, and
|
|
68
|
+
// saving it could overwrite the webapp's values.
|
|
69
|
+
plugin.schema = {
|
|
70
|
+
type: 'object',
|
|
71
|
+
description: 'Calibration tables and settings are edited in Webapps → Performance Processor.',
|
|
72
|
+
properties: {}
|
|
73
|
+
}
|
|
60
74
|
|
|
61
75
|
function outPath (key) {
|
|
62
76
|
return config.mode === 'shadow' ? OUTPUTS[key].path + SHADOW_SUFFIX : OUTPUTS[key].path
|
|
@@ -80,9 +94,9 @@ module.exports = function (app) {
|
|
|
80
94
|
}
|
|
81
95
|
const isStwEcho = ms => recentStw.some(o => Math.abs(o - ms) < 1e-12)
|
|
82
96
|
|
|
83
|
-
function fresh (
|
|
84
|
-
const l = latest[
|
|
85
|
-
return l && now - l.at <
|
|
97
|
+
function fresh (p, now, maxAge = STALE_MS) {
|
|
98
|
+
const l = latest[p]
|
|
99
|
+
return l && now - l.at < maxAge ? l.value : undefined
|
|
86
100
|
}
|
|
87
101
|
|
|
88
102
|
// Refresh on a change of state, and at most once a second within a state so the
|
|
@@ -148,6 +162,7 @@ module.exports = function (app) {
|
|
|
148
162
|
heel: heelDeg(now),
|
|
149
163
|
headingMagnetic: deg(fresh('navigation.headingMagnetic', now)),
|
|
150
164
|
headingTrue: deg(fresh('navigation.headingTrue', now)),
|
|
165
|
+
variation: deg(fresh('navigation.magneticVariation', now, VARIATION_MAX_AGE_MS)),
|
|
151
166
|
sog: (v => v == null ? undefined : v * MS_TO_KN)(fresh('navigation.speedOverGround', now)),
|
|
152
167
|
cog: deg(fresh('navigation.courseOverGroundTrue', now))
|
|
153
168
|
}
|
|
@@ -265,10 +280,43 @@ module.exports = function (app) {
|
|
|
265
280
|
unsubscribes = []
|
|
266
281
|
}
|
|
267
282
|
|
|
283
|
+
// ---- stored configuration ---------------------------------------------------
|
|
284
|
+
|
|
285
|
+
function configFile () {
|
|
286
|
+
return path.join(app.getDataDirPath(), CONFIG_FILE)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// The webapp's saved configuration. Before the file exists, take a configuration left
|
|
290
|
+
// in the plugin options by 0.0.1, which stored it there; otherwise start from defaults.
|
|
291
|
+
function loadStored (options) {
|
|
292
|
+
try {
|
|
293
|
+
return JSON.parse(fs.readFileSync(configFile(), 'utf8'))
|
|
294
|
+
} catch (e) {
|
|
295
|
+
if (e.code !== 'ENOENT') app.error(`could not read ${configFile()}: ${e.message}`)
|
|
296
|
+
}
|
|
297
|
+
return options && options.corrections ? options : {}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Write to a temporary file and rename, so a crash mid-write never leaves half a file.
|
|
301
|
+
// Never overwrite a file from a newer release: this version would drop what it
|
|
302
|
+
// does not understand.
|
|
303
|
+
function saveStored (cfg) {
|
|
304
|
+
const file = configFile()
|
|
305
|
+
let onDisk = null
|
|
306
|
+
try { onDisk = JSON.parse(fs.readFileSync(file, 'utf8')).schema } catch (e) {}
|
|
307
|
+
if (Number.isInteger(onDisk) && onDisk > CONFIG_SCHEMA) {
|
|
308
|
+
throw new Error(`the saved configuration is schema ${onDisk}, from a newer version of this plugin; update the plugin rather than overwrite it`)
|
|
309
|
+
}
|
|
310
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
311
|
+
fs.writeFileSync(file + '.tmp', JSON.stringify(cfg, null, 2))
|
|
312
|
+
fs.renameSync(file + '.tmp', file)
|
|
313
|
+
}
|
|
314
|
+
|
|
268
315
|
plugin.start = function (options) {
|
|
269
|
-
const { config: cfg, errors } = normalizeConfig(options
|
|
316
|
+
const { config: cfg, errors } = normalizeConfig(loadStored(options))
|
|
270
317
|
if (errors.length) {
|
|
271
|
-
app.setPluginError(`Configuration: ${errors.join('; ')}`)
|
|
318
|
+
app.setPluginError(`Configuration: ${errors.join('; ')} — fix it in the webapp`)
|
|
319
|
+
config = normalizeConfig({}).config // so the webapp can still load and repair it
|
|
272
320
|
return
|
|
273
321
|
}
|
|
274
322
|
startWith(cfg)
|
|
@@ -310,9 +358,7 @@ module.exports = function (app) {
|
|
|
310
358
|
plugin.registerWithRouter = function (router) {
|
|
311
359
|
router.get('/config', (req, res) => {
|
|
312
360
|
res.set('Cache-Control', 'no-store')
|
|
313
|
-
|
|
314
|
-
try { stored = app.readPluginOptions().configuration || {} } catch (e) {}
|
|
315
|
-
res.json({ config: config || normalizeConfig(stored).config, defaults: DEFAULTS })
|
|
361
|
+
res.json({ config: config || normalizeConfig(loadStored()).config, defaults: DEFAULTS })
|
|
316
362
|
})
|
|
317
363
|
|
|
318
364
|
router.put('/config', async (req, res) => {
|
|
@@ -320,11 +366,13 @@ module.exports = function (app) {
|
|
|
320
366
|
try { body = await readBody(req) } catch (e) { return res.status(400).json({ errors: ['body is not valid JSON'] }) }
|
|
321
367
|
const { config: cfg, errors } = normalizeConfig(body)
|
|
322
368
|
if (errors.length) return res.status(400).json({ errors })
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
res.json({
|
|
327
|
-
}
|
|
369
|
+
try {
|
|
370
|
+
saveStored(cfg)
|
|
371
|
+
} catch (e) {
|
|
372
|
+
return res.status(500).json({ errors: [`could not save: ${e.message}`] })
|
|
373
|
+
}
|
|
374
|
+
startWith(cfg)
|
|
375
|
+
res.json({ config: cfg })
|
|
328
376
|
})
|
|
329
377
|
|
|
330
378
|
router.get('/live', (req, res) => {
|
|
@@ -336,33 +384,5 @@ module.exports = function (app) {
|
|
|
336
384
|
return plugin
|
|
337
385
|
}
|
|
338
386
|
|
|
339
|
-
// JSON schema for the admin "Plugin Config" form, built from the defaults so the form
|
|
340
|
-
// is a working fallback editor and a fresh install starts from this boat's values.
|
|
341
|
-
function schemaFor (defaults) {
|
|
342
|
-
const titles = {
|
|
343
|
-
mode: 'Output: shadow publishes …Corrected paths alongside the instruments; replace publishes the standard paths (rank this plugin first in Source Priorities)',
|
|
344
|
-
variation: 'Magnetic variation (degrees, east positive)',
|
|
345
|
-
sources: 'Input sources ($source to read; blank = any source except this plugin)',
|
|
346
|
-
corrections: 'Corrections (easier to edit in the Performance Processor webapp)'
|
|
347
|
-
}
|
|
348
|
-
function node (value, title) {
|
|
349
|
-
if (Array.isArray(value)) return { type: 'array', title, items: { type: 'number' }, default: value }
|
|
350
|
-
if (value && typeof value === 'object') {
|
|
351
|
-
return {
|
|
352
|
-
type: 'object',
|
|
353
|
-
title,
|
|
354
|
-
properties: Object.fromEntries(Object.entries(value).map(([k, v]) => [k, node(v, k)]))
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
if (typeof value === 'boolean') return { type: 'boolean', title, default: value }
|
|
358
|
-
if (typeof value === 'number') return { type: 'number', title, default: value }
|
|
359
|
-
return { type: 'string', title, default: value }
|
|
360
|
-
}
|
|
361
|
-
const schema = node(defaults)
|
|
362
|
-
for (const [k, t] of Object.entries(titles)) schema.properties[k].title = t
|
|
363
|
-
schema.properties.mode.enum = ['shadow', 'replace']
|
|
364
|
-
return schema
|
|
365
|
-
}
|
|
366
|
-
|
|
367
387
|
// Exposed for the test suite only; not part of the Signal K plugin API.
|
|
368
|
-
module.exports._internals = {
|
|
388
|
+
module.exports._internals = { OUTPUTS, INPUTS, SHADOW_SUFFIX, CONFIG_FILE }
|
package/lib/defaults.js
CHANGED
|
@@ -8,8 +8,10 @@ const TWS_BINS = [5, 10, 15, 20, 25, 30]
|
|
|
8
8
|
|
|
9
9
|
const DEFAULTS = {
|
|
10
10
|
mode: 'shadow', // 'shadow' publishes …Corrected paths; 'replace' publishes the standard paths
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Degrees, east positive. Used only when nothing on the boat publishes
|
|
12
|
+
// navigation.headingTrue or navigation.magneticVariation. Sydney.
|
|
13
|
+
variation: 12.8,
|
|
14
|
+
sources: { apparentWind: '', stw: '', heading: '', attitude: '', gps: '', variation: '' }, // '' = any but this plugin
|
|
13
15
|
corrections: {
|
|
14
16
|
// Knots added to STW: linearity by speed, plus heel correction by |heel|.
|
|
15
17
|
// The 185-cell heel x STW table reduced to two rows (every cell within 0.03 kn).
|
package/lib/processor.js
CHANGED
|
@@ -145,7 +145,7 @@ function correctInputs (input, config) {
|
|
|
145
145
|
return { c, steps }
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
// input: { awa, aws, stw, heel?, headingMagnetic?, headingTrue?, sog?, cog? }
|
|
148
|
+
// input: { awa, aws, stw, heel?, headingMagnetic?, headingTrue?, variation?, sog?, cog? }
|
|
149
149
|
// returns { out: { stw, leeway, twa, tws, heading?, twd?, current? }, steps }
|
|
150
150
|
function process (input, config) {
|
|
151
151
|
const { c, steps } = correctInputs(input, config)
|
|
@@ -171,9 +171,19 @@ function process (input, config) {
|
|
|
171
171
|
|
|
172
172
|
const out = { stw: c.stw, leeway: c.leeway, twa: c.twa, tws: c.tws }
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
// True heading: from a true-heading source if there is one, otherwise magnetic
|
|
175
|
+
// heading plus variation, taken from Signal K (the GPS) when available and from the
|
|
176
|
+
// configured fallback when not.
|
|
177
|
+
let heading = null
|
|
178
|
+
if (input.headingTrue != null) {
|
|
179
|
+
heading = input.headingTrue
|
|
180
|
+
out.headingFrom = 'true heading'
|
|
181
|
+
} else if (input.headingMagnetic != null) {
|
|
182
|
+
const fromSignalK = input.variation != null
|
|
183
|
+
out.variation = fromSignalK ? input.variation : config.variation
|
|
184
|
+
out.headingFrom = fromSignalK ? 'magnetic + variation from Signal K' : 'magnetic + fallback variation'
|
|
185
|
+
heading = input.headingMagnetic + out.variation
|
|
186
|
+
}
|
|
177
187
|
if (heading != null) {
|
|
178
188
|
out.heading = norm360(heading)
|
|
179
189
|
out.twd = norm360(heading + c.twa)
|
|
@@ -233,6 +243,37 @@ function checkNumber (errors, label, v) {
|
|
|
233
243
|
if (typeof v !== 'number' || !Number.isFinite(v)) errors.push(`${label}: must be a number`)
|
|
234
244
|
}
|
|
235
245
|
|
|
246
|
+
// ---- file format versions ----------------------------------------------------
|
|
247
|
+
//
|
|
248
|
+
// Every saved config carries `schema`, the version of its shape. When a release changes
|
|
249
|
+
// that shape, bump CONFIG_SCHEMA and add MIGRATIONS[old], a function that turns a
|
|
250
|
+
// config of schema `old` into schema `old + 1`. Older files are then upgraded step by
|
|
251
|
+
// step as they load and saved back in the current shape; nothing is lost or reset.
|
|
252
|
+
//
|
|
253
|
+
// Schema 1 (0.0.1): mode, variation, sources, corrections { stw, leeway, twa, tws }.
|
|
254
|
+
|
|
255
|
+
const CONFIG_SCHEMA = 1
|
|
256
|
+
const MIGRATIONS = {
|
|
257
|
+
// 1: cfg => ({ ...cfg, schema: 2, ... }),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Bring a raw config up to the current schema. A config without `schema` predates the
|
|
261
|
+
// field and has the schema 1 shape. One from a newer release is refused rather than
|
|
262
|
+
// guessed at, since this version cannot know what its fields mean.
|
|
263
|
+
function migrate (raw, migrations = MIGRATIONS, current = CONFIG_SCHEMA) {
|
|
264
|
+
let cfg = clone(raw)
|
|
265
|
+
let v = cfg.schema == null ? 1 : cfg.schema
|
|
266
|
+
if (!Number.isInteger(v) || v < 1) return { error: `schema: "${cfg.schema}" is not a schema version` }
|
|
267
|
+
if (v > current) return { error: `schema ${v} was saved by a newer version of this plugin (this one reads up to ${current}); update the plugin` }
|
|
268
|
+
while (v < current) {
|
|
269
|
+
if (!migrations[v]) return { error: `schema: no upgrade from ${v} to ${v + 1}` }
|
|
270
|
+
cfg = migrations[v](cfg)
|
|
271
|
+
v++
|
|
272
|
+
}
|
|
273
|
+
cfg.schema = current
|
|
274
|
+
return { config: cfg }
|
|
275
|
+
}
|
|
276
|
+
|
|
236
277
|
// Merge a stored or submitted config over the defaults and check it. Numbers typed
|
|
237
278
|
// into a form arrive as strings, so coerce numeric-looking strings first.
|
|
238
279
|
function normalizeConfig (input) {
|
|
@@ -242,10 +283,12 @@ function normalizeConfig (input) {
|
|
|
242
283
|
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
|
|
243
284
|
return v
|
|
244
285
|
}
|
|
245
|
-
const
|
|
286
|
+
const migrated = migrate(isPlainObject(input) ? input : {})
|
|
287
|
+
if (migrated.error) return { config: { ...clone(DEFAULTS), schema: CONFIG_SCHEMA }, errors: [migrated.error] }
|
|
288
|
+
const raw = migrated.config
|
|
246
289
|
// Source names are text even when they look numeric.
|
|
247
290
|
const keepText = { sources: raw.sources, mode: raw.mode }
|
|
248
|
-
const config = merge(DEFAULTS, { ...coerce(raw), ...Object.fromEntries(Object.entries(keepText).filter(([, v]) => v !== undefined)) })
|
|
291
|
+
const config = merge({ schema: CONFIG_SCHEMA, ...DEFAULTS }, { ...coerce(raw), ...Object.fromEntries(Object.entries(keepText).filter(([, v]) => v !== undefined)) })
|
|
249
292
|
const errors = []
|
|
250
293
|
const k = config.corrections
|
|
251
294
|
|
|
@@ -283,5 +326,6 @@ module.exports = {
|
|
|
283
326
|
correctInputs,
|
|
284
327
|
normalizeConfig,
|
|
285
328
|
CORRECTIONS,
|
|
286
|
-
|
|
329
|
+
CONFIG_SCHEMA,
|
|
330
|
+
_internals: { interp, blendRows, tableLookup, merge, norm180, migrate }
|
|
287
331
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-performance-processor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
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
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
package/public/app.js
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
stw: 'Speed through water',
|
|
20
20
|
heading: 'Heading',
|
|
21
21
|
attitude: 'Heel (attitude)',
|
|
22
|
-
gps: 'SOG and COG'
|
|
22
|
+
gps: 'SOG and COG',
|
|
23
|
+
variation: 'Magnetic variation'
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const state = {
|
|
@@ -160,6 +161,13 @@
|
|
|
160
161
|
return { twa: Math.atan2(y, x) * 180 / Math.PI, tws: Math.hypot(x, y) }
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
// Where true heading came from, so a fallback variation is visible at a glance.
|
|
165
|
+
function headingNote (o) {
|
|
166
|
+
if (o.headingFrom === 'true heading') return 'from true heading'
|
|
167
|
+
const v = o.variation == null ? '' : ` ${Math.abs(o.variation).toFixed(1)}° ${o.variation < 0 ? 'W' : 'E'}`
|
|
168
|
+
return o.headingFrom === 'magnetic + fallback variation' ? `fallback variation${v}` : `variation${v} from GPS`
|
|
169
|
+
}
|
|
170
|
+
|
|
163
171
|
function tile (label, value, unit, raw) {
|
|
164
172
|
return `<div class="tile"><div class="label">${esc(label)}</div>` +
|
|
165
173
|
`<div class="value">${esc(value)}<span class="unit">${esc(unit)}</span></div>` +
|
|
@@ -182,7 +190,7 @@
|
|
|
182
190
|
$('tiles').innerHTML = [
|
|
183
191
|
tile('TWA', fmt(Math.abs(o.twa)), `° ${side(o.twa)}`, `uncorrected ${fmt(Math.abs(u.twa))}°`),
|
|
184
192
|
tile('TWS', fmt(o.tws), 'kn', `uncorrected ${fmt(u.tws)} kn`),
|
|
185
|
-
tile('TWD', o.twd == null ? '--' : fmt(o.twd, 0), '° T', o.twd == null ? 'no heading' :
|
|
193
|
+
tile('TWD', o.twd == null ? '--' : fmt(o.twd, 0), '° T', o.twd == null ? 'no heading' : headingNote(o)),
|
|
186
194
|
tile('STW', fmt(o.stw, 2), 'kn', `paddlewheel ${fmt(r.input.stw, 2)} kn`),
|
|
187
195
|
tile('Leeway', fmt(Math.abs(o.leeway)), `° ${side(o.leeway)}`, r.input.heel == null ? 'no heel' : `heel ${fmt(r.input.heel, 0)}°`),
|
|
188
196
|
tile('Current', o.current ? fmt(o.current.drift, 2) : '--', o.current ? `kn · ${fmt(o.current.set, 0)}°` : 'kn', o.current ? '' : 'no SOG/COG or heading')
|
package/public/index.html
CHANGED
|
@@ -68,8 +68,9 @@
|
|
|
68
68
|
</fieldset>
|
|
69
69
|
</div>
|
|
70
70
|
<div class="panel">
|
|
71
|
-
<h2>
|
|
72
|
-
<
|
|
71
|
+
<h2>Fallback magnetic variation</h2>
|
|
72
|
+
<p class="muted sub">Used only when nothing on the boat sends true heading or magnetic variation. Normally your GPS supplies variation and this is never used; the Live page shows which applies.</p>
|
|
73
|
+
<label class="field">Degrees east (west negative)
|
|
73
74
|
<input id="variation" type="number" step="0.1" data-path="variation">
|
|
74
75
|
</label>
|
|
75
76
|
</div>
|