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 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.4).** Compare it against your instruments in Shadow mode before relying on it. See the [changelog](CHANGELOG.md).
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. If another plugin already corrects STW (for example `signalk-stw-heel-correction`), choose the instrument as the STW source in Settings, or the correction is applied twice.
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) return status('nostw', 'Waiting for speed through water — no true wind without it')
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.4",
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="">Any</option>` +
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('')