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.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +130 -2
- package/index.js +388 -0
- package/lib/defaults.js +49 -0
- package/lib/processor.js +331 -0
- package/package.json +44 -4
- package/public/app.js +336 -0
- package/public/icon.svg +15 -0
- package/public/index.html +87 -0
- package/public/style.css +208 -0
package/lib/processor.js
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|