signalk-performance-processor 0.0.4 → 0.0.5
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 +9 -0
- package/README.md +14 -4
- package/index.js +67 -9
- package/package.json +1 -1
- package/public/app.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.5 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
Still a proof of concept: run it in Shadow mode alongside your instruments.
|
|
6
|
+
|
|
7
|
+
- **Fix: feedback loop with another STW plugin in Replace mode.** With `signalk-stw-heel-correction` (0.2 or earlier) on the same server, each plugin corrected the other's STW and handed it back, so STW and true wind ran away. Without a chosen STW source, STW is now read from sensors only (updates that carry a `source` object from a data connection), never from another plugin. Choosing a source in Settings still overrides this.
|
|
8
|
+
- **Rate limit as a circuit breaker:** no path is published more often than 20 Hz. A feedback loop is cut at its first repeat, and a backlog flushed after a dropout is thinned rather than stopped; nothing latches. While updates are being dropped, the status and the Live page say so, with a count and the source driving them, for 10 s after the last one; the first is logged.
|
|
9
|
+
- The status says when STW is arriving only from other plugins, and names them.
|
|
10
|
+
- Settings: the STW source's blank choice reads **Auto (sensors only)**.
|
|
11
|
+
|
|
3
12
|
## 0.0.4 — 2026-10-29
|
|
4
13
|
|
|
5
14
|
Still a proof of concept: run it in Shadow mode alongside your instruments. Saved configurations are upgraded to schema 2 on first load.
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ If your TWD swings 5–10° every time you tack or gybe, the wind hasn't shifted
|
|
|
8
8
|
|
|
9
9
|
This plugin does the calibration job of a B&G H5000-class instrument processor inside Signal K. It corrects boat speed and leeway, then applies H5000-style TWA and TWS correction tables, and publishes corrected true wind and current. You edit everything in a webapp and can watch each correction work live. Run it in **Shadow mode** first: it publishes alongside your existing instruments without changing anything they do.
|
|
10
10
|
|
|
11
|
-
> **Proof of concept (0.0.
|
|
11
|
+
> **Proof of concept (0.0.5).** Compare it against your instruments in Shadow mode before relying on it. See the [changelog](CHANGELOG.md).
|
|
12
12
|
|
|
13
13
|
## Why it exists
|
|
14
14
|
|
|
@@ -73,7 +73,17 @@ The corrections run in this order, each on the output of the one before:
|
|
|
73
73
|
|
|
74
74
|
True wind is worked out on every apparent wind update. Inputs older than 3 seconds are not used. Without STW there is no true wind. Without heel, the heel correction and leeway are skipped. Without heading, there is no TWD or current.
|
|
75
75
|
|
|
76
|
-
Feed it **measured** apparent wind and **raw** paddlewheel speed.
|
|
76
|
+
Feed it **measured** apparent wind and **raw** paddlewheel speed. Unless you choose an STW source in Settings, STW is read **from sensors only**: updates that came in through a data connection (NMEA 0183, NMEA 2000), which carry a `source` object, and not STW published by another plugin, which does not. See [Running alongside other STW plugins](#running-alongside-other-stw-plugins).
|
|
77
|
+
|
|
78
|
+
### Running alongside other STW plugins
|
|
79
|
+
|
|
80
|
+
This plugin publishes STW too, so it must never correct another plugin's STW and hand the result back. Two correctors on `navigation.speedThroughWater` that each take any source but their own correct each other's output without end: STW, and true wind with it, run away. Signal K's own pattern for correction plugins (subscribe with `sourcePolicy: 'all'`, skip your own `$source`, publish on the same path) does not cover this case, because neither plugin ever sees its own source come back.
|
|
81
|
+
|
|
82
|
+
- **By default it reads STW from sensors only**, so another corrector's output is ignored and no loop can form.
|
|
83
|
+
- **Choosing a source in Settings overrides this**, including choosing another plugin, for a deliberate chain such as `signalk-stw-heel-correction` → this plugin with the STW correction here turned off. That plugin must then read only the paddlewheel (its own default from 0.3.0).
|
|
84
|
+
- **Circuit breaker:** whatever the sources, the plugin never publishes a path more often than 20 Hz: an update less than 50 ms after the last one on that path is not published. A loop's next hop arrives within the same millisecond, so the chain ends at the first repeat instead of running away, and the server stays responsive. A backlog flushed after a dropout is thinned, not stopped. Nothing latches. While updates are being dropped the status and the Live page end with `Rate limited: N updates over 20 Hz not published in the last 10 s, driven by "…"`, naming the source. If it stays up, look for a loop with another STW plugin; a loop that is cut still leaves STW corrected twice.
|
|
85
|
+
|
|
86
|
+
Only one plugin should correct STW. Otherwise the correction is applied twice even without a loop. In Shadow mode, note that `signalk-stw-heel-correction` also uses `navigation.speedThroughWaterCorrected` for its separate-path output, so the two would overwrite each other there.
|
|
77
87
|
|
|
78
88
|
### Outputs
|
|
79
89
|
|
|
@@ -86,7 +96,7 @@ Feed it **measured** apparent wind and **raw** paddlewheel speed. If another plu
|
|
|
86
96
|
| Leeway (when on) | `navigation.leewayAngle` | `navigation.leewayAngleCorrected` |
|
|
87
97
|
| Current | `environment.current` (`drift`, `setTrue`) | `environment.currentCorrected` |
|
|
88
98
|
|
|
89
|
-
Everything is published under this plugin's own `$source`. Corrected STW goes out each time the paddlewheel reports, while the STW correction is on; the rest goes out with each apparent wind update. Shadow paths carry `meta` with units, since they are not in the Signal K schema.
|
|
99
|
+
Everything is published under this plugin's own `$source`. Corrected STW goes out each time the paddlewheel reports, while the STW correction is on; the rest goes out with each apparent wind update. No path goes out more often than 20 Hz (see **Circuit breaker** under [Running alongside other STW plugins](#running-alongside-other-stw-plugins)). Shadow paths carry `meta` with units, since they are not in the Signal K schema.
|
|
90
100
|
|
|
91
101
|
## Modes
|
|
92
102
|
|
|
@@ -101,7 +111,7 @@ Open **Webapps → Performance Processor**.
|
|
|
101
111
|
|
|
102
112
|
- **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
113
|
- **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.
|
|
104
|
-
- **Settings**: Shadow or Replace, the fallback magnetic variation, and which source to read for each input.
|
|
114
|
+
- **Settings**: Shadow or Replace, the fallback magnetic variation, and which source to read for each input. **Any** takes every source; for STW the blank choice is **Auto (sensors only)**.
|
|
105
115
|
|
|
106
116
|
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.
|
|
107
117
|
|
package/index.js
CHANGED
|
@@ -20,6 +20,12 @@ const MAX_PLAUSIBLE_HEEL_DEG = 90
|
|
|
20
20
|
// See signalk-stw-heel-correction: below this a correction is float noise, and recording
|
|
21
21
|
// it as an output would make the next identical raw sample look like an echo.
|
|
22
22
|
const ECHO_MIN_DELTA_MS = 1e-6
|
|
23
|
+
// Circuit breaker: no path is published more often than this (20 Hz). A feedback loop
|
|
24
|
+
// through another plugin delivers its next update within the same millisecond, so the
|
|
25
|
+
// chain ends at the first repeat rather than running away, and the server stays
|
|
26
|
+
// responsive. Nothing latches; the status says while it is happening.
|
|
27
|
+
const MIN_PUBLISH_INTERVAL_MS = 50
|
|
28
|
+
const RATE_NOTE_MS = 10000 // how long a dropped update keeps the status flagged
|
|
23
29
|
|
|
24
30
|
// Inputs, grouped the way they are chosen in Settings.
|
|
25
31
|
const INPUTS = {
|
|
@@ -84,10 +90,38 @@ module.exports = function (app) {
|
|
|
84
90
|
return s === plugin.id || s.startsWith(plugin.id + '.')
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
// A plugin publishes with no source object, so the server gives its update only a
|
|
94
|
+
// $source. Data from a connection (NMEA 0183, NMEA 2000) always carries one.
|
|
95
|
+
const fromSensor = update => update.source != null && typeof update.source === 'object'
|
|
96
|
+
|
|
87
97
|
// Backstop echo detector for the one path that is both input and output (STW in
|
|
88
98
|
// replace mode): remember published values that differ from their input.
|
|
89
99
|
const recentStw = []
|
|
90
100
|
let loopWarned = false
|
|
101
|
+
|
|
102
|
+
// Rate limit (see MIN_PUBLISH_INTERVAL_MS): when each path was last published, and
|
|
103
|
+
// when updates were dropped recently, for the status.
|
|
104
|
+
let publishedAt = {}
|
|
105
|
+
let dropsAt = []
|
|
106
|
+
let dropFrom = ''
|
|
107
|
+
function recentDrops (now) {
|
|
108
|
+
while (dropsAt.length && now - dropsAt[0] > RATE_NOTE_MS) dropsAt.shift()
|
|
109
|
+
return dropsAt.length
|
|
110
|
+
}
|
|
111
|
+
function noteDrop (now, from) {
|
|
112
|
+
const first = !recentDrops(now)
|
|
113
|
+
dropsAt.push(now)
|
|
114
|
+
dropFrom = from
|
|
115
|
+
if (!first) return
|
|
116
|
+
app.error(`Rate limited: "${from}" is driving updates faster than ${1000 / MIN_PUBLISH_INTERVAL_MS} Hz. Is another plugin correcting this plugin's output?`)
|
|
117
|
+
lastStatus = statusText + rateNote(now) // flag it now, not at the next refresh
|
|
118
|
+
app.setPluginStatus(lastStatus)
|
|
119
|
+
}
|
|
120
|
+
function rateNote (now) {
|
|
121
|
+
const n = recentDrops(now)
|
|
122
|
+
return n ? ` · Rate limited: ${n} update${n === 1 ? '' : 's'} over ${1000 / MIN_PUBLISH_INTERVAL_MS} Hz not published in the last ${RATE_NOTE_MS / 1000} s, ` +
|
|
123
|
+
`driven by "${dropFrom}". Usually a feedback loop with another STW plugin: check the STW source in Settings` : ''
|
|
124
|
+
}
|
|
91
125
|
function noteStw (ms) {
|
|
92
126
|
recentStw.push(ms)
|
|
93
127
|
if (recentStw.length > 32) recentStw.shift()
|
|
@@ -103,10 +137,13 @@ module.exports = function (app) {
|
|
|
103
137
|
// live numbers in the status keep moving without flooding the admin UI.
|
|
104
138
|
let statusAt = 0
|
|
105
139
|
let statusKind = null
|
|
140
|
+
let statusText = ''
|
|
106
141
|
function status (kind, text) {
|
|
107
142
|
const now = Date.now()
|
|
108
143
|
if (kind === statusKind && now - statusAt < STATUS_INTERVAL_MS) return
|
|
109
144
|
statusKind = kind
|
|
145
|
+
statusText = text
|
|
146
|
+
text += rateNote(now)
|
|
110
147
|
lastStatus = text
|
|
111
148
|
statusAt = now
|
|
112
149
|
app.setPluginStatus(text)
|
|
@@ -120,7 +157,15 @@ module.exports = function (app) {
|
|
|
120
157
|
return Math.abs(h) > MAX_PLAUSIBLE_HEEL_DEG ? null : h
|
|
121
158
|
}
|
|
122
159
|
|
|
123
|
-
function publish (values, timestamp) {
|
|
160
|
+
function publish (values, timestamp, from) {
|
|
161
|
+
const now = Date.now()
|
|
162
|
+
const kept = values.filter(v => {
|
|
163
|
+
if (now - (publishedAt[v.path] ?? -Infinity) < MIN_PUBLISH_INTERVAL_MS) return false
|
|
164
|
+
publishedAt[v.path] = now
|
|
165
|
+
return true
|
|
166
|
+
})
|
|
167
|
+
if (kept.length < values.length) noteDrop(now, from)
|
|
168
|
+
values = kept
|
|
124
169
|
if (!values.length) return
|
|
125
170
|
// No source object: the server stamps $source with plugin.id, which is what the
|
|
126
171
|
// loop guard and the user's Source Priorities entry match on.
|
|
@@ -128,7 +173,7 @@ module.exports = function (app) {
|
|
|
128
173
|
}
|
|
129
174
|
|
|
130
175
|
// Corrected STW goes out as soon as the paddlewheel reports, at its own rate.
|
|
131
|
-
function onStw (rawMs, timestamp, now) {
|
|
176
|
+
function onStw (rawMs, timestamp, now, from) {
|
|
132
177
|
const stw = config.corrections.stw
|
|
133
178
|
if (!stw.enabled) return
|
|
134
179
|
const stwKn = rawMs * MS_TO_KN
|
|
@@ -138,16 +183,21 @@ module.exports = function (app) {
|
|
|
138
183
|
const { c } = correctInputs({ stw: stwKn, heel: heelDeg(now) }, config)
|
|
139
184
|
const ms = c.stw / MS_TO_KN
|
|
140
185
|
if (config.mode === 'replace' && Math.abs(ms - rawMs) > ECHO_MIN_DELTA_MS) noteStw(ms)
|
|
141
|
-
publish([{ path: outPath('stw'), value: ms }], timestamp)
|
|
186
|
+
publish([{ path: outPath('stw'), value: ms }], timestamp, from)
|
|
142
187
|
}
|
|
143
188
|
|
|
144
189
|
// True wind, current and leeway go out on every apparent wind update.
|
|
145
|
-
function onWind (timestamp, now) {
|
|
190
|
+
function onWind (timestamp, now, from) {
|
|
146
191
|
const awa = fresh('environment.wind.angleApparent', now)
|
|
147
192
|
const aws = fresh('environment.wind.speedApparent', now)
|
|
148
193
|
const stw = fresh('navigation.speedThroughWater', now)
|
|
149
194
|
if (awa == null || aws == null) return status('nowind', 'Waiting for apparent wind')
|
|
150
|
-
if (stw == null)
|
|
195
|
+
if (stw == null) {
|
|
196
|
+
const others = !config.sources.stw && sourcesSeen.stw ? Array.from(sourcesSeen.stw) : []
|
|
197
|
+
return status('nostw', others.length
|
|
198
|
+
? `No speed through water from a sensor, only from ${others.join(', ')}. Choose the STW source in Settings`
|
|
199
|
+
: 'Waiting for speed through water — no true wind without it')
|
|
200
|
+
}
|
|
151
201
|
|
|
152
202
|
const awsKn = aws * MS_TO_KN
|
|
153
203
|
const stwKn = stw * MS_TO_KN
|
|
@@ -178,7 +228,7 @@ module.exports = function (app) {
|
|
|
178
228
|
if (o.twd != null) values.push({ path: outPath('twd'), value: o.twd / RAD_TO_DEG })
|
|
179
229
|
if (config.corrections.leeway.enabled) values.push({ path: outPath('leeway'), value: o.leeway / RAD_TO_DEG })
|
|
180
230
|
if (o.current) values.push({ path: outPath('current'), value: { drift: o.current.drift / MS_TO_KN, setTrue: o.current.set / RAD_TO_DEG } })
|
|
181
|
-
publish(values, timestamp)
|
|
231
|
+
publish(values, timestamp, from)
|
|
182
232
|
|
|
183
233
|
const missing = []
|
|
184
234
|
if (input.heel == null) missing.push('heel')
|
|
@@ -191,6 +241,7 @@ module.exports = function (app) {
|
|
|
191
241
|
const now = Date.now()
|
|
192
242
|
let windChanged = false
|
|
193
243
|
let windTimestamp = null
|
|
244
|
+
let windFrom = ''
|
|
194
245
|
for (const update of (delta.updates || [])) {
|
|
195
246
|
if (isOwnSource(update.$source)) continue
|
|
196
247
|
for (const v of (update.values || [])) {
|
|
@@ -199,9 +250,14 @@ module.exports = function (app) {
|
|
|
199
250
|
if (v.path !== 'navigation.attitude' && !Number.isFinite(v.value)) continue
|
|
200
251
|
|
|
201
252
|
const src = String(update.$source || '')
|
|
253
|
+
const sensor = fromSensor(update)
|
|
202
254
|
;(sourcesSeen[group] = sourcesSeen[group] || new Set()).add(src)
|
|
203
255
|
const wanted = config.sources[group]
|
|
204
256
|
if (wanted && src !== wanted) continue
|
|
257
|
+
// Without a chosen source, STW is read from sensors only. Another plugin's STW
|
|
258
|
+
// is already corrected, and this plugin publishes STW too: taking each other's
|
|
259
|
+
// output, two correctors feed each other without end.
|
|
260
|
+
if (!wanted && group === 'stw' && !sensor) continue
|
|
205
261
|
|
|
206
262
|
if (v.path === 'navigation.speedThroughWater' && config.mode === 'replace' && isStwEcho(v.value)) {
|
|
207
263
|
if (!loopWarned) {
|
|
@@ -212,16 +268,18 @@ module.exports = function (app) {
|
|
|
212
268
|
}
|
|
213
269
|
|
|
214
270
|
latest[v.path] = { value: v.value, source: src, at: now }
|
|
215
|
-
if (group === 'apparentWind') { windChanged = true; windTimestamp = update.timestamp }
|
|
216
|
-
if (group === 'stw') onStw(v.value, update.timestamp, now)
|
|
271
|
+
if (group === 'apparentWind') { windChanged = true; windTimestamp = update.timestamp; windFrom = src }
|
|
272
|
+
if (group === 'stw') onStw(v.value, update.timestamp, now, src)
|
|
217
273
|
}
|
|
218
274
|
}
|
|
219
|
-
if (windChanged) onWind(windTimestamp, now)
|
|
275
|
+
if (windChanged) onWind(windTimestamp, now, windFrom)
|
|
220
276
|
}
|
|
221
277
|
|
|
222
278
|
function startWith (cfg) {
|
|
223
279
|
stopEverything()
|
|
224
280
|
config = cfg
|
|
281
|
+
publishedAt = {}
|
|
282
|
+
dropsAt = []
|
|
225
283
|
latest = {}
|
|
226
284
|
sourcesSeen = {}
|
|
227
285
|
lastResult = null
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-performance-processor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
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": {
|
package/public/app.js
CHANGED
|
@@ -357,7 +357,7 @@
|
|
|
357
357
|
const chosen = state.draft.sources[key] || ''
|
|
358
358
|
const opts = Array.from(new Set([...(seen[key] || []), chosen].filter(Boolean)))
|
|
359
359
|
return `<label>${esc(label)}<select data-path="sources.${key}">` +
|
|
360
|
-
`<option value=""
|
|
360
|
+
`<option value="">${key === 'stw' ? 'Auto (sensors only)' : 'Any'}</option>` +
|
|
361
361
|
opts.map(s => `<option value="${esc(s)}" ${s === chosen ? 'selected' : ''}>${esc(s)}${(seen[key] || []).includes(s) ? '' : ' (not seen)'}</option>`).join('') +
|
|
362
362
|
'</select></label>'
|
|
363
363
|
}).join('')
|