sailkick-boat 0.22.2 → 0.23.0

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/README.md CHANGED
@@ -292,6 +292,78 @@ Items are matched by **name**, since ids are assigned independently on each side
292
292
  polar you refined in the web app appears as *cloud only* — or *differs* if the boat has an
293
293
  older one of the same name — and one click brings it aboard.
294
294
 
295
+ ## Live polar performance, computed on board
296
+
297
+ The plugin computes **percentage of polar target** (boat speed ÷ what the polar says you
298
+ should be doing) and emits it as two ordinary SignalK deltas:
299
+
300
+ ```
301
+ performance.polarSpeed target boat speed, m/s
302
+ performance.polarSpeedRatio achieved / target, 0–1
303
+ ```
304
+
305
+ Because they are deltas, everything downstream gets them for nothing: telemetry sync
306
+ forwards them to the cloud through the same store-and-forward spool as every raw channel
307
+ — so an offline passage replays them **gapless** rather than leaving a hole — NMEA
308
+ displays and other plugins can read them natively, and the local history ring records the
309
+ rounded percentage as a `perf` channel for offline Trends.
310
+
311
+ The maths is **vendored verbatim** from the app (`shared/engine/perf-live.js` and the pure
312
+ `Polar` evaluator), each file carrying its upstream commit and sha256. There is one
313
+ definition of "the %" — the boat and the screens must not quietly disagree — and
314
+ `test/perf.test.js` replays the upstream test suite against the vendored copy to prove it.
315
+ Fix the maths upstream and re-vendor; never edit the copy.
316
+
317
+ **Nothing is emitted unless the guards pass.** In irons (inside the no-go angle), under
318
+ 2 kt of wind, or against a near-zero target, the channel simply stops. A gap is the honest
319
+ representation; a zero would be a lie that drags down every average drawn over it.
320
+
321
+ **No paddlewheel?** The percentage falls back to SOG, which the screens do too — but SOG
322
+ is polluted by current, so the status line says `(from SOG — current-polluted)` rather
323
+ than presenting it as a through-water figure.
324
+
325
+ **Polar staleness.** The percentage is computed against whichever polar the boat has. If
326
+ you refine your polar ashore, the boat keeps using its own copy until you bring it across
327
+ on the **Sync polars & routes** page — this is a manual copy, not background sync. And a
328
+ catalogue polar has to have been fetched at least once while online before it can be used
329
+ at all. The raw channels are always recorded regardless, so the cloud can recompute the
330
+ history if the maths ever changes: the recorded channel is a materialisation, not the only
331
+ truth.
332
+
333
+ ## Several devices publishing the same value
334
+
335
+ A real N2K network usually has more than one device announcing a given path, and they do
336
+ not always agree. On the boat this was developed against: three sources for
337
+ `navigation.speedThroughWater`, one of them reporting a constant **0**; and two compasses
338
+ on `navigation.headingMagnetic` **7.5° apart**. Whichever delta arrived last won, so speed
339
+ dropped to zero intermittently and heading — which the app derives from magnetic heading
340
+ plus variation, and which feeds the true-wind calculation — wandered.
341
+
342
+ **The plugin does not arbitrate this, and deliberately so.** Signal K already resolves it
343
+ from `sourcePriorities` in `settings.json`, applied in its delta pipeline *before* any
344
+ consumer sees the value, so one setting fixes the app, KIP, the instruments, the local
345
+ history ring and the telemetry going to the cloud all at once. Set it under
346
+ **Server → Settings → Source Priorities**:
347
+
348
+ ```json
349
+ "navigation.speedThroughWater": [{ "sourceRef": "NMEA.27", "timeout": "" }],
350
+ "navigation.headingMagnetic": [{ "sourceRef": "NMEA.23", "timeout": "" }]
351
+ ```
352
+
353
+ To find the culprit, compare sources on one path:
354
+
355
+ ```bash
356
+ curl -s http://<boat>:3000/signalk/v1/api/vessels/self/navigation/speedThroughWater
357
+ ```
358
+
359
+ `values` lists every source and what each is reporting; `$source` is whichever last won.
360
+ A path where they disagree is worth pinning. Where a cross-check exists it settles which
361
+ is right — magnetic heading plus variation should equal the reported true heading, and on
362
+ that boat one compass matched to 0.25° while the other was 7.5° out.
363
+
364
+ Note that a de-prioritised source still appears **once** when a client subscribes: Signal K
365
+ replays current values on subscription. That is a single stale sample, not a live feed.
366
+
295
367
  ## Local history (offline Trends + track)
296
368
  **One source: a live ring**, sampled from the same BoatState that feeds `/ws/telemetry`
297
369
  — no database, works on a Victron GX with nothing else installed. `historyAvailable` is
package/index.js CHANGED
@@ -10,6 +10,7 @@ const { createBackfill } = require('./lib/backfill')
10
10
  const { createAis } = require('./lib/ais')
11
11
  const { createAisTargets } = require('./lib/ais/targets')
12
12
  const { createProfile } = require('./lib/profile')
13
+ const { createPerf } = require('./lib/perf')
13
14
  const { createCloud } = require('./lib/cloud')
14
15
  const { resolveAccountConfig } = require('./lib/account')
15
16
 
@@ -100,6 +101,7 @@ module.exports = function (app) {
100
101
  let aisTargets = null
101
102
  let profile = null
102
103
  let cloud = null
104
+ let perf = null
103
105
  let proxyPort = null // what the launcher page needs to build its links
104
106
  let pairedSlug = null
105
107
  let statusTimer = null
@@ -356,12 +358,31 @@ module.exports = function (app) {
356
358
  telemetry = null
357
359
  }
358
360
  }
361
+ // Live polar performance, computed here so it becomes a RECORDED channel: it rides
362
+ // the same spool as everything else, so an offline passage replays it gapless.
363
+ // Needs telemetry (it samples BoatState) and must exist before history (which
364
+ // samples it), hence the position.
365
+ if (telemetry) {
366
+ try {
367
+ perf = createPerf(app, {
368
+ source: telemetry,
369
+ pluginId: plugin.id,
370
+ storeDir: store,
371
+ profileFile: path.join((app.getDataDirPath && app.getDataDirPath()) || '.', 'profile.json')
372
+ })
373
+ perf.start()
374
+ } catch (e) {
375
+ (app.error || console.error)('[sailkick-boat] performance start failed: ' + e.message)
376
+ perf = null
377
+ }
378
+ }
379
+
359
380
  if (pOpts.history.enabled !== false) {
360
381
  try {
361
382
  // ringSource = the telemetry module: when no local InfluxDB token is set
362
383
  // (the common case, e.g. a Victron GX), history serves a DB-less ring from
363
384
  // live telemetry. storeDir puts the persistent ring log on the SSD.
364
- history = createHistory(app, { ...pOpts.history, ringSource: telemetry, storeDir: store })
385
+ history = createHistory(app, { ...pOpts.history, ringSource: telemetry, perfSource: perf, storeDir: store })
365
386
  history.start()
366
387
  pOpts.history = history // proxy dispatches /api/history to it when available()
367
388
  } catch (e) {
@@ -459,6 +480,7 @@ module.exports = function (app) {
459
480
  if (history) parts.push(history.status())
460
481
  if (aisTargets) parts.push(aisTargets.status())
461
482
  if (profile) parts.push(profile.status())
483
+ if (perf) parts.push(perf.status())
462
484
  if (ais) parts.push(ais.status())
463
485
  if (backfill) parts.push(backfill.status())
464
486
  try { app.setPluginStatus(parts.join(' | ') || 'idle (both features off)') } catch {}
@@ -473,6 +495,7 @@ module.exports = function (app) {
473
495
  try { if (aisTargets) aisTargets.stop() } catch {}
474
496
  try { if (profile) profile.stop() } catch {}
475
497
  try { if (cloud) cloud.stop() } catch {}
498
+ try { if (perf) perf.stop() } catch {}
476
499
  try { if (ais) ais.stop() } catch {}
477
500
  try { if (backfill) backfill.stop() } catch {}
478
501
  try { if (proxy) proxy.stop() } catch {}
@@ -484,6 +507,7 @@ module.exports = function (app) {
484
507
  aisTargets = null
485
508
  profile = null
486
509
  cloud = null
510
+ perf = null
487
511
  proxyPort = null
488
512
  pairedSlug = null
489
513
  proxy = null
@@ -36,7 +36,12 @@ const CHANNELS = [
36
36
  'tws', 'twd', 'aws', 'awa', 'twa', 'vmg',
37
37
  'sog', 'stw', 'cog', 'heading', 'depth',
38
38
  'seaTemp', 'airTemp', 'rpmPort', 'rpmStbd',
39
- 'wptBrg', 'wptDist', 'wptVmg', 'wptTtg'
39
+ 'wptBrg', 'wptDist', 'wptVmg', 'wptTtg',
40
+ // Computed on the boat (lib/perf) rather than read off the bus: percentage of polar
41
+ // target. Null whenever the guards do not pass — in irons, under 2 kt, no polar — so
42
+ // the channel GAPS instead of flat-lining at zero, which would drag every average
43
+ // drawn over it.
44
+ 'perf'
40
45
  ]
41
46
  const wrap360 = (d) => ((d % 360) + 360) % 360
42
47
  const MAX_SAMPLES = 50000
@@ -66,8 +71,9 @@ function trueWind (s) {
66
71
  }
67
72
 
68
73
  class RingHistoryProvider {
69
- constructor ({ source, sampleSec, windowSec, persistFile } = {}) {
74
+ constructor ({ source, perfSource, sampleSec, windowSec, persistFile } = {}) {
70
75
  this._source = source
76
+ this._perfSource = perfSource || null
71
77
  this._ring = [] // [{ t, ...CHANNELS, lat, lon }]
72
78
  const win = windowSec || 3600
73
79
  this._windowMs = win * 1000
@@ -107,6 +113,7 @@ class RingHistoryProvider {
107
113
  // rather than showing a flat line at zero.
108
114
  wptBrg: num(s.wptBrgDeg), wptDist: num(s.wptDistNm),
109
115
  wptVmg: num(s.wptVmgKt), wptTtg: num(s.wptTtgSec),
116
+ perf: this._perfSource ? num(this._perfSource.getPerf()) : null,
110
117
  lat: num(s.lat), lon: num(s.lon)
111
118
  }
112
119
  this._ring.push(row)
@@ -0,0 +1,182 @@
1
+ 'use strict'
2
+
3
+ // Live polar performance, computed on the boat.
4
+ //
5
+ // The app already shows a live "% of polar target" on the mobile Polar screen and the
6
+ // desktop ribbon, computed in the browser. Computing it HERE instead makes it a recorded
7
+ // channel: it rides the same store-and-forward spool as every other value, so an offline
8
+ // passage replays it gapless rather than leaving a hole; it sees full-rate SignalK rather
9
+ // than the cloud's 2 s Influx poll; and it keeps working in the boat-served offline mode.
10
+ //
11
+ // The maths is NOT reimplemented — perf-live.js and polar.js are vendored verbatim from
12
+ // the app (see their headers). One definition of "the %", or the boat and the screens
13
+ // would quietly disagree.
14
+ //
15
+ // Output is two ordinary SignalK deltas:
16
+ // performance.polarSpeed target boat speed, m/s (SI, as SignalK expects)
17
+ // performance.polarSpeedRatio achieved / target, 0–1
18
+ // Emitting deltas rather than writing our own measurement means everything downstream
19
+ // gets it for free: lib/sync already subscribes to '*' and forwards to the cloud bucket,
20
+ // NMEA displays and other plugins can read it, and the cloud maps
21
+ // performance.polarSpeedRatio -> the `perf` history channel with one line.
22
+ //
23
+ // ONLY when the guards pass. Inside the no-go wedge, in under 2 kt of wind, or against a
24
+ // near-zero target, nothing is emitted at all: a gap is the honest representation, and a
25
+ // zero would be a lie that pollutes every average drawn over it.
26
+
27
+ const fs = require('fs')
28
+ const path = require('path')
29
+ const { createLivePerf, perfPct } = require('./perf-live')
30
+ const { Polar } = require('./polar')
31
+
32
+ const OWN_PREFIX = 'own:'
33
+ const MS_TO_KT = 1.94384
34
+ const DEFAULTS = {
35
+ intervalMs: 1000, // the 5 s EMA makes this cadence-insensitive; 1 s matches the ring
36
+ polarReloadMs: 60000 // pick up an active-polar change without a restart
37
+ }
38
+
39
+ function createPerf (app, options = {}) {
40
+ const log = (m) => (app.debug ? app.debug('[perf] ' + m) : console.log('[sailkick-boat:perf]', m))
41
+ const warn = (m) => (app.error ? app.error('[sailkick-boat:perf] ' + m) : console.error('[sailkick-boat:perf]', m))
42
+
43
+ const cfg = { ...DEFAULTS, ...options }
44
+ let live = null
45
+ let polar = null
46
+ let polarId = null
47
+ let polarError = null
48
+ let timer = null
49
+ let reloadTimer = null
50
+ let stopped = false
51
+ let last = null // { pct, target, kind, usingSog }
52
+ let emitted = 0
53
+ let skipped = 0
54
+ let warnedSog = false
55
+
56
+ // --- resolving the active polar -------------------------------------------------
57
+ // The boat has its own profile mirror (lib/profile). `activePolar` is either an
58
+ // own:<id> — a polar the owner authored, whose CSV is in the profile itself — or a
59
+ // catalogue id, whose CSV the mirror has cached under store/polars/<id>.csv exactly as
60
+ // the app fetches it.
61
+ function readProfile () {
62
+ try { return JSON.parse(fs.readFileSync(cfg.profileFile, 'utf8')) } catch { return null }
63
+ }
64
+
65
+ function csvFor (id, profile) {
66
+ if (id.startsWith(OWN_PREFIX)) {
67
+ const pid = id.slice(OWN_PREFIX.length)
68
+ const item = (profile.polars || []).find((p) => p && p.id === pid)
69
+ return item && typeof item.csv === 'string' ? item.csv : null
70
+ }
71
+ // Catalogue polar: whatever the mirror cached. Not fetched on demand — this must
72
+ // work with no uplink, and an unseen polar simply means no % until the app has
73
+ // opened it once.
74
+ try { return fs.readFileSync(path.join(cfg.storeDir, 'polars', `${id}.csv`), 'utf8') } catch { return null }
75
+ }
76
+
77
+ function loadPolar () {
78
+ const profile = readProfile()
79
+ const id = profile && typeof profile.activePolar === 'string' ? profile.activePolar : null
80
+ if (!id) {
81
+ if (polarId !== null) log('no active polar selected — performance is not being computed')
82
+ polar = null; polarId = null; polarError = 'no active polar selected'
83
+ return
84
+ }
85
+ if (id === polarId && polar) return // unchanged
86
+ const csv = csvFor(id, profile || {})
87
+ if (!csv) {
88
+ polar = null; polarId = id
89
+ polarError = `the CSV for "${id}" is not on the boat yet`
90
+ warn(`${polarError} — open the polar once in the app while online, or copy it across on the Sync page`)
91
+ return
92
+ }
93
+ try {
94
+ polar = Polar.fromCSV(id, csv)
95
+ polarId = id
96
+ polarError = null
97
+ log(`active polar "${polar.name || id}" loaded (no-go ${polar.noGoTwa}°)`)
98
+ } catch (e) {
99
+ polar = null; polarId = id
100
+ polarError = `polar "${id}" will not parse: ${e.message}`
101
+ warn(polarError)
102
+ }
103
+ }
104
+
105
+ // --- the computation ------------------------------------------------------------
106
+ function tick () {
107
+ if (stopped || !cfg.source || !cfg.source.getState) return
108
+ const s = cfg.source.getState()
109
+ if (!s) return
110
+
111
+ // The SignalK timestamp, never wall clock: buffered or replayed computation has to
112
+ // be deterministic, and the EMA is time-weighted.
113
+ const now = Date.parse(s.updatedAt) || Date.now()
114
+ const r = live.update(s, now)
115
+ if (!r) { last = null; return } // wind or speed missing — the EMA reset itself
116
+
117
+ const tws = live.avgTws(now)
118
+ const res = perfPct(polar, tws, r.ema)
119
+
120
+ if (live.usingSog && !warnedSog) {
121
+ warnedSog = true
122
+ log('no speed through water — the percentage is computed from SOG, so it is polluted by current')
123
+ }
124
+
125
+ if (res.kind !== 'ok') { last = { kind: res.kind }; skipped++; return }
126
+ last = { kind: 'ok', pct: res.pct, target: res.target, usingSog: live.usingSog }
127
+ emitted++
128
+ emit(res, s.updatedAt)
129
+ }
130
+
131
+ // Standard SignalK paths, in SI. round() lives here so the ratio and the percentage
132
+ // can never disagree: the cloud takes round(ratio * 100).
133
+ function emit (res, timestamp) {
134
+ if (!app.handleMessage) return
135
+ try {
136
+ app.handleMessage(cfg.pluginId || 'sailkick-boat', {
137
+ updates: [{
138
+ timestamp: timestamp || new Date().toISOString(),
139
+ values: [
140
+ { path: 'performance.polarSpeed', value: res.target / MS_TO_KT },
141
+ { path: 'performance.polarSpeedRatio', value: res.pct / 100 }
142
+ ]
143
+ }]
144
+ })
145
+ } catch (e) { warn('could not emit deltas: ' + e.message) }
146
+ }
147
+
148
+ // --- lifecycle ------------------------------------------------------------------
149
+ function start () {
150
+ if (!cfg.source) { log('not started — no telemetry source'); return }
151
+ live = createLivePerf({})
152
+ loadPolar()
153
+ timer = setInterval(tick, cfg.intervalMs)
154
+ reloadTimer = setInterval(loadPolar, cfg.polarReloadMs)
155
+ if (timer.unref) timer.unref()
156
+ if (reloadTimer.unref) reloadTimer.unref()
157
+ log(`computing performance every ${Math.round(cfg.intervalMs / 1000)}s -> performance.polarSpeed{,Ratio}`)
158
+ }
159
+
160
+ function stop () {
161
+ stopped = true
162
+ clearInterval(timer); clearInterval(reloadTimer)
163
+ timer = reloadTimer = null
164
+ live = null; polar = null; polarId = null; last = null
165
+ }
166
+
167
+ // What the history ring samples. Null whenever the guards did not pass, so the channel
168
+ // gaps rather than flat-lining.
169
+ function getPerf () { return last && last.kind === 'ok' ? last.pct : null }
170
+
171
+ function status () {
172
+ if (!live) return 'perf: off'
173
+ if (polarError) return `perf: ${polarError}`
174
+ if (!last) return 'perf: waiting for wind and speed'
175
+ if (last.kind !== 'ok') return `perf: ${last.kind}`
176
+ return `perf: ${last.pct}% of "${polarId}"${last.usingSog ? ' (from SOG — current-polluted)' : ''}${skipped ? `; ${skipped} guarded` : ''}`
177
+ }
178
+
179
+ return { start, stop, status, getPerf, _tick: tick, _polar: () => polar, _loadPolar: loadPolar, _counts: () => ({ emitted, skipped }) }
180
+ }
181
+
182
+ module.exports = { createPerf }
@@ -0,0 +1,80 @@
1
+ // VENDORED from sailkick/shared/engine/perf-live.js @ 128cf97 sha256:7ce83a1df13926f0
2
+ // Do not edit here — fix upstream and re-vendor. One definition of "the %".
3
+ //
4
+ // Converted ESM -> CommonJS ONLY (export keywords removed, module.exports appended). No logic changed: the boat and every app
5
+ // surface must produce the same number, and test/perf.test.js replays the upstream
6
+ // suite's cases against this copy to prove it.
7
+ // Live polar performance — ONE definition of "the %", shared by every surface that
8
+ // shows or will compute it: the mobile Polar screen, the desktop ribbon polar, the
9
+ // cloud server (perfPct on the telemetry stream, when it lands) and — vendored, see
10
+ // the handoff when it happens — the boat plugin. Extracted from polar-screen.js /
11
+ // instrument-polar.js, which had drifted to two copies of the same five constants.
12
+ //
13
+ // Pure: no DOM, no fetch, no Date.now() of its own (callers pass `now` so the cloud
14
+ // can replay history deterministically). The polar object comes from polar.js
15
+ // (`speed(tws, signedTwa)` abs-clamps the angle; `noGoTwa` is the first table row).
16
+
17
+ const EMA_TAU_S = 5; // live-point smoothing time constant (s)
18
+ const TWS_AVG_SEC = 60; // "current wind" = 1-min mean TWS
19
+ const MIN_TWS = 2; // below this wind the % is noise…
20
+ const MIN_TARGET = 0.5; // …and below this target it divides by ~zero
21
+
22
+ const wrap180 = (d) => { const x = ((d % 360) + 360) % 360; return x > 180 ? x - 360 : x; };
23
+
24
+ // Stateful smoother over the BoatState stream. update() ingests one sample and
25
+ // returns { raw, ema } (raw feeds trails/recorders; ema feeds the % and the dot),
26
+ // or null while wind or speed is missing — which also RESETS the EMA, so a data
27
+ // gap doesn't get smoothed across.
28
+ function createLivePerf({ emaTauS = EMA_TAU_S, twsAvgSec = TWS_AVG_SEC } = {}) {
29
+ let ema = null, lastT = 0, usingSog = false;
30
+ let twsHist = []; // [{t, v}] for the windowed mean
31
+
32
+ return {
33
+ update(s, now) {
34
+ if (Number.isFinite(s?.twsKt)) twsHist.push({ t: now, v: s.twsKt });
35
+ // Prefer the boat's own TWA; derive from TWD − HDG until it's published.
36
+ const twa = Number.isFinite(s?.twaDeg) ? s.twaDeg
37
+ : (Number.isFinite(s?.twdDeg) && Number.isFinite(s?.headingDeg)) ? wrap180(s.twdDeg - s.headingDeg)
38
+ : null;
39
+ usingSog = !Number.isFinite(s?.stwKt);
40
+ const kt = Number.isFinite(s?.stwKt) ? s.stwKt : Number.isFinite(s?.sogKt) ? s.sogKt : null;
41
+ if (twa == null || kt == null) { ema = null; return null; }
42
+
43
+ const dt = ema && lastT ? Math.min(30, (now - lastT) / 1000) : emaTauS;
44
+ const a = dt / (emaTauS + dt);
45
+ // Smooth via the SHORTEST-PATH delta (correct across the stern), then re-wrap
46
+ // the accumulator: without the outer wrap180 a gybe (175°S → 175°P) walks the
47
+ // EMA past 180 and the label reads "190° S" (and any side test flips wrong).
48
+ ema = ema
49
+ ? { twa: wrap180(ema.twa + wrap180(twa - ema.twa) * a), kt: ema.kt + (kt - ema.kt) * a }
50
+ : { twa, kt };
51
+ lastT = now;
52
+ return { raw: { twa, kt }, ema };
53
+ },
54
+ avgTws(now) {
55
+ const cut = now - twsAvgSec * 1000;
56
+ twsHist = twsHist.filter((p) => p.t >= cut);
57
+ return twsHist.length ? twsHist.reduce((s, p) => s + p.v, 0) / twsHist.length : null;
58
+ },
59
+ get ema() { return ema; },
60
+ get usingSog() { return usingSog; },
61
+ };
62
+ }
63
+
64
+ // The % itself, with its guards. Discriminated by `kind` so callers can render
65
+ // each state distinctly instead of re-deriving the guards:
66
+ // nodata — no polar / no smoothed point / no wind average yet
67
+ // irons — inside the no-go wedge (a % against a 0-ish target is meaningless)
68
+ // weak — wind or target below the noise floor (target still reported)
69
+ // ok — { pct, target }
70
+ function perfPct(polar, tws, ema) {
71
+ if (!polar || !ema || !Number.isFinite(tws)) return { kind: 'nodata' };
72
+ const target = polar.speed(tws, ema.twa);
73
+ if (Math.abs(ema.twa) < polar.noGoTwa) return { kind: 'irons', target };
74
+ if (tws < MIN_TWS || target < MIN_TARGET) return { kind: 'weak', target };
75
+ return { kind: 'ok', target, pct: Math.round((ema.kt / target) * 100) };
76
+ }
77
+
78
+ module.exports = {
79
+ createLivePerf, perfPct, wrap180, EMA_TAU_S, TWS_AVG_SEC, MIN_TWS, MIN_TARGET
80
+ }
@@ -0,0 +1,133 @@
1
+ // VENDORED from sailkick/shared/engine/polar.js @ 128cf97 sha256:6a3166fe17ab5806
2
+ // Do not edit here — fix upstream and re-vendor. One definition of "the %".
3
+ //
4
+ // Converted ESM -> CommonJS ONLY (only the pure Polar class is taken — the getActivePolar/loadPolar store layer is app-side (fetch + localStorage) and has no meaning on the boat). No logic changed: the boat and every app
5
+ // surface must produce the same number, and test/perf.test.js replays the upstream
6
+ // suite's cases against this copy to prove it.
7
+ // The boat resolves the ACTIVE polar from its own profile mirror instead — see index.js.
8
+
9
+ class Polar {
10
+ constructor({ id, name, twaRows, twsCols, speeds }) {
11
+ this.id = id;
12
+ this.name = name;
13
+ this.twaRows = twaRows;
14
+ this.twsCols = twsCols;
15
+ this.speeds = speeds;
16
+ this.noGoTwa = twaRows[0];
17
+ this._maxSpeed = null;
18
+ }
19
+
20
+ // Boat speed in knots at wind speed `twsKn` and signed wind angle
21
+ // `twaDegSigned`. Polar is port/starboard-symmetric — take |twa|.
22
+ // Below noGoTwa → 0. Bilinear interpolation in (TWA, TWS), clamped at the
23
+ // table's TOP edge. Below the FIRST column the table is anchored to a virtual
24
+ // (0 wind → 0 boat speed) column: target = speed(col0, twa) × tws/col0.
25
+ // (The old behaviour linearly EXTRAPOLATED down the low-end gradient — a flat
26
+ // calm still "targeted" 1–2 kn, some tables went NEGATIVE upwind, and the
27
+ // perf% blew up as the target fell through the noise guard.)
28
+ speed(twsKn, twaDegSigned) {
29
+ const twa = Math.min(180, Math.abs(twaDegSigned));
30
+ if (twa < this.noGoTwa) return 0;
31
+ const { twaRows, twsCols, speeds } = this;
32
+ const col0 = twsCols[0];
33
+ if (twsKn < col0) return this.speed(col0, twa) * Math.max(0, twsKn) / col0;
34
+ const tws = Math.min(twsCols[twsCols.length - 1], twsKn);
35
+
36
+ let i = 0;
37
+ while (i < twaRows.length - 2 && twaRows[i + 1] < twa) i++;
38
+ const i1 = i + 1;
39
+ let j = 0;
40
+ while (j < twsCols.length - 2 && twsCols[j + 1] < tws) j++;
41
+ const j1 = j + 1;
42
+
43
+ const ta = (twa - twaRows[i]) / (twaRows[i1] - twaRows[i]);
44
+ const tb = (tws - twsCols[j]) / (twsCols[j1] - twsCols[j]);
45
+
46
+ const s00 = speeds[i][j];
47
+ const s01 = speeds[i][j1];
48
+ const s10 = speeds[i1][j];
49
+ const s11 = speeds[i1][j1];
50
+
51
+ const s0 = s00 * (1 - tb) + s01 * tb;
52
+ const s1 = s10 * (1 - tb) + s11 * tb;
53
+ return s0 * (1 - ta) + s1 * ta;
54
+ }
55
+
56
+ // Peak boat speed across the whole table — used to size the wind grid
57
+ // (we want it big enough to contain 24 h of travel at max polar speed).
58
+ maxSpeed() {
59
+ if (this._maxSpeed != null) return this._maxSpeed;
60
+ let max = 0;
61
+ for (const row of this.speeds) for (const s of row) if (s > max) max = s;
62
+ this._maxSpeed = max;
63
+ return max;
64
+ }
65
+
66
+ // A bound `speed` function, convenient for passing into the isochrone.
67
+ speedFn() {
68
+ return (tws, twa) => this.speed(tws, twa);
69
+ }
70
+
71
+ // ---- parsing -----------------------------------------------------
72
+
73
+ static fromCSV(id, text) {
74
+ let name = null;
75
+ const twaRows = [];
76
+ const twsCols = [];
77
+ const speeds = [];
78
+ let seenHeader = false;
79
+
80
+ for (let rawLine of text.split(/\r?\n/)) {
81
+ const line = rawLine.trim();
82
+ if (!line) continue;
83
+ if (line.startsWith('#')) {
84
+ const m = line.match(/^#\s*(.+?)\s*$/);
85
+ if (m && !name) {
86
+ // Strip trailing empty CSV cells that spreadsheet exports
87
+ // sometimes leave on the comment line ("Outremer 5X ,,,,,").
88
+ name = m[1].replace(/[\s,]+$/, '');
89
+ }
90
+ continue;
91
+ }
92
+ const cells = line.split(',').map(s => s.trim());
93
+ // Skip "ghost" all-commas rows from spreadsheet exports.
94
+ if (cells.every(c => c === '')) continue;
95
+ if (!seenHeader) {
96
+ if (cells.length < 2) throw new Error(`Polar "${id}": header has <2 columns`);
97
+ for (let k = 1; k < cells.length; k++) {
98
+ const v = Number(cells[k]);
99
+ if (!Number.isFinite(v)) throw new Error(`Polar "${id}": bad TWS header value "${cells[k]}"`);
100
+ twsCols.push(v);
101
+ }
102
+ if (twsCols.length < 2) throw new Error(`Polar "${id}": need at least 2 TWS columns`);
103
+ seenHeader = true;
104
+ continue;
105
+ }
106
+ if (cells.length !== twsCols.length + 1) {
107
+ throw new Error(`Polar "${id}": row "${cells[0]}" has ${cells.length - 1} speed cells, expected ${twsCols.length}`);
108
+ }
109
+ const twa = Number(cells[0]);
110
+ if (!Number.isFinite(twa)) throw new Error(`Polar "${id}": bad TWA value "${cells[0]}"`);
111
+ const row = [];
112
+ for (let k = 1; k < cells.length; k++) {
113
+ const v = Number(cells[k]);
114
+ if (!Number.isFinite(v) || v < 0) throw new Error(`Polar "${id}": bad speed "${cells[k]}" at TWA ${twa}, TWS ${twsCols[k - 1]}`);
115
+ row.push(v);
116
+ }
117
+ twaRows.push(twa);
118
+ speeds.push(row);
119
+ }
120
+
121
+ if (twaRows.length < 2) throw new Error(`Polar "${id}": need at least 2 TWA rows`);
122
+ for (let k = 1; k < twaRows.length; k++) if (twaRows[k] <= twaRows[k - 1]) {
123
+ throw new Error(`Polar "${id}": TWA rows not strictly ascending at ${twaRows[k]}`);
124
+ }
125
+ for (let k = 1; k < twsCols.length; k++) if (twsCols[k] <= twsCols[k - 1]) {
126
+ throw new Error(`Polar "${id}": TWS columns not strictly ascending at ${twsCols[k]}`);
127
+ }
128
+
129
+ return new Polar({ id, name: name || id, twaRows, twsCols, speeds });
130
+ }
131
+ }
132
+
133
+ module.exports = { Polar }
@@ -3,6 +3,24 @@
3
3
  const crypto = require('crypto')
4
4
  const { signalkValuesToPatch, resolveHeadingDeg } = require('./signalk-map')
5
5
 
6
+ // Choosing between competing sources is SIGNAL K'S JOB, not this module's. A boat
7
+ // commonly has several devices publishing the same path — this one carries two compasses
8
+ // 7.5 deg apart on navigation.headingMagnetic, and three log sources on
9
+ // speedThroughWater, one of which reports a constant 0. Signal K resolves that from
10
+ // `sourcePriorities` in settings.json, applied in its delta pipeline (deltaPriority.js,
11
+ // called at index.js:268) BEFORE anything downstream sees the delta. So by the time a
12
+ // value reaches here it has already been arbitrated, and every consumer on the boat —
13
+ // this plugin, KIP, the instruments — agrees.
14
+ //
15
+ // This module used to keep its own guard for navigation.headingMagnetic: lock onto the
16
+ // first $source seen and ignore the rest. That was a coin flip (it could equally lock
17
+ // onto the WRONG compass and be quietly 7.5 deg out for the whole session), and once
18
+ // priorities were configured it became actively harmful: Signal K replays current values
19
+ // when a client subscribes, so the first headingMagnetic delta after a restart can be a
20
+ // one-off from a de-prioritised device. Latching onto that would have discarded every
21
+ // real heading delta thereafter — heading frozen at a stale value rather than merely
22
+ // wrong. Removed in v0.22.3; set `sourcePriorities` instead.
23
+ //
6
24
  // Serves the sailkick app's /ws/telemetry bus FROM the boat's local SignalK, so
7
25
  // the app uses the identical telemetry contract whether it talks to the cloud
8
26
  // sailkick server or this on-boat plugin. Faithful port of the server's
@@ -26,7 +44,6 @@ function encodeTextFrame (str) {
26
44
  function createTelemetry (app, options = {}) {
27
45
  const log = (m) => (app.debug ? app.debug('[telemetry] ' + m) : console.log('[sailkick-boat:telemetry]', m))
28
46
  let state = null
29
- let magSource = null // lock the $source for navigation.headingMagnetic (dual-source guard)
30
47
  const clients = new Set()
31
48
  const unsubscribes = []
32
49
 
@@ -44,13 +61,7 @@ function createTelemetry (app, options = {}) {
44
61
  let ts = null
45
62
  for (const u of delta.updates) {
46
63
  if (!u || !Array.isArray(u.values)) continue
47
- const src = u.$source || (u.source && u.source.label) || ''
48
- const vals = u.values.filter((v) => {
49
- if (!v || v.path !== 'navigation.headingMagnetic') return true
50
- if (!magSource) magSource = src
51
- return src === magSource
52
- })
53
- Object.assign(patch, signalkValuesToPatch(vals))
64
+ Object.assign(patch, signalkValuesToPatch(u.values))
54
65
  if (u.timestamp) ts = u.timestamp
55
66
  }
56
67
  if (Object.keys(patch).length === 0) return
@@ -85,7 +96,6 @@ function createTelemetry (app, options = {}) {
85
96
  for (const s of clients) { try { s.destroy() } catch {} }
86
97
  clients.clear()
87
98
  state = null
88
- magSource = null
89
99
  }
90
100
 
91
101
  function status () {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.22.2",
3
+ "version": "0.23.0",
4
4
  "description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only \u2014 info@sailkick.io",
5
5
  "main": "index.js",
6
6
  "scripts": {