sailkick-boat 0.30.0 → 0.31.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
@@ -785,10 +785,22 @@ A useful side-effect: the auto-coarsening that keeps the ring under `MAX_SAMPLES
785
785
  longer lossy. Only the *emit* rate coarsens, never the poll, so a 30-day passage emitting
786
786
  every ~52 s still carries the true min/max within each 52 s.
787
787
 
788
- **Compass channels never get a band** — `twd`, `twa`, `awa`, `cog`, `heading`, `wptBrg`.
789
- The mean of 359° and 1° is 180°, the exact opposite of the truth, so those carry a
790
- last-reading snapshot and no band, ever. It is the one error here that would look entirely
791
- plausible on screen, so the tests pin it.
788
+ **Compass channels are circular-meaned and never get a band** — `twd`, `twa`, `awa`,
789
+ `cog`, `heading`, `wptBrg`. The *arithmetic* mean of 359° and 1° is 180°, the exact
790
+ opposite of the truth, so these use the unit-vector mean `atan2(Σsin, Σcos)` instead: a
791
+ genuine average with no seam. A min/max stays meaningless on a circle, so they carry no
792
+ band, ever — the one error here that would look entirely plausible on screen.
793
+
794
+ Two things the tests pin hard. **`twa` and `awa` stay signed, −180..180**, port negative,
795
+ because that is what the number means; a circular mean returns 0..360, so they are folded
796
+ back — without it a port-side wind reads as 270°, which is not a rounding difference but a
797
+ different quantity. (Pinned as a fuzzed invariant, not two examples.) And **readings that
798
+ cancel report nothing rather than north**: two exactly opposite bearings have no average,
799
+ and `atan2(0, 0)` answers 0° with total confidence.
800
+
801
+ Before 0.31.0 these channels served the last reading in each bucket — correct, but one
802
+ sample per bucket, and the emit rate coarsens to ~52 s on a 30-day passage, so a direction
803
+ trace was visibly jumpier aboard than ashore for the same minute.
792
804
 
793
805
  `chans=sog,aws` narrows the answer to the channels actually plotted; `bands` appears only
794
806
  when `stats=1` was asked *and* the provider produced them, so every client degrades to the
@@ -0,0 +1,86 @@
1
+ // VENDORED from sailkick/shared/engine/angles.js @ a8c7153 sha256:6d001aa2e5a8f83b
2
+ // Do not edit here — fix upstream and re-vendor. ONE definition of compass maths, so the
3
+ // boat and the cloud answer "what was the average wind direction in this minute" the same
4
+ // way. test/history-ring.test.js exercises it through the ring.
5
+ //
6
+ // Converted ESM -> CommonJS ONLY (export keywords removed, module.exports appended).
7
+ // No logic changed.
8
+ //
9
+ // NB the upstream note on wrap180: it is deliberately duplicated here and in
10
+ // perf-live.js rather than imported, so that each stays a SINGLE-FILE vendor with its own
11
+ // pinned hash. The app fuzzes the two definitions against each other
12
+ // (tests/test-angles.mjs); the same discipline applies to this copy.
13
+
14
+ // Compass angle maths. Pure — no imports, no DOM — because shared/engine is the layer the
15
+ // boat plugin vendors (tests/test-layering.mjs enforces both).
16
+ //
17
+ // The one idea worth stating: a compass reading is a point on a CIRCLE, and almost every
18
+ // arithmetic operation you would reach for is wrong on a circle. The mean of 359° and 1° is
19
+ // 180°, the exact reciprocal. min/max across the seam are meaningless. A line drawn from
20
+ // 359° to 1° streaks the full height of a chart. Each of those has bitten this codebase.
21
+ //
22
+ // Two frames, and the whole file is about keeping them straight:
23
+ //
24
+ // WRAPPED — every value in [0,360). What instruments publish and what BoatState,
25
+ // /api/history/series and the dials all use. Correct for "where is it now",
26
+ // useless for "how did it change".
27
+ // UNWRAPPED — a continuous frame where 359 → 1 is recorded as 359 → 361. Differences and
28
+ // averages are ordinary arithmetic again. Correct for a series, meaningless
29
+ // as an absolute bearing until you wrap360 it at the very end.
30
+ //
31
+ // Same doctrine the routing field already uses for longitude — work in an unwrapped frame,
32
+ // wrap only at the boundary (shared/engine/route-field.js:48, docs/ROUTING-FIELD.md:85).
33
+
34
+ const wrap360 = (d) => ((d % 360) + 360) % 360;
35
+
36
+ // Signed shortest path, (-180, 180]. NOTE: deliberately duplicated from perf-live.js:16
37
+ // rather than imported. perf-live.js is vendored into sailkick-boat as a SINGLE FILE with a
38
+ // pinned hash (see server/history/influx-provider.js:22); giving it an import would turn a
39
+ // one-file copy into a two-file vendor contract. tests/test-angles.mjs fuzzes the two
40
+ // definitions against each other, which buys the anti-drift guarantee without the coupling.
41
+ const wrap180 = (d) => { const x = wrap360(d); return x > 180 ? x - 360 : x; };
42
+
43
+ // One step of the UNBOUNDED accumulate: the nearest equivalent of `deg` to `prev`.
44
+ // `prev` is the previous UNWRAPPED value, or null/undefined to seed the chain.
45
+ //
46
+ // Unbounded is the whole point, and is what separates this from the superficially identical
47
+ // accumulators in perf-live.js:43 and mobile/main.js:352 — both of those re-wrap on every
48
+ // step (bounded), because a dial must not spin away. A series must: after a full 360° veer
49
+ // the result is legitimately start+360, and clamping it would reintroduce the jump this
50
+ // exists to remove.
51
+ const unwrapStep = (prev, deg) => (prev == null ? wrap360(deg) : prev + wrap180(deg - prev));
52
+
53
+ // Lift a wrapped compass series into the unwrapped frame, in array order.
54
+ //
55
+ // Non-finite entries pass through untouched and DO NOT reset the chain. That is not
56
+ // politeness about bad data: re-seeding after a dropout picks a fresh branch of the circle,
57
+ // so every later point silently shifts by a multiple of 360 and the trace jumps for a reason
58
+ // no one will ever track down.
59
+ function unwrapDeg(degs) {
60
+ const out = new Array(degs.length);
61
+ let prev = null;
62
+ for (let i = 0; i < degs.length; i++) {
63
+ const d = degs[i];
64
+ if (!Number.isFinite(d)) { out[i] = d; continue; }
65
+ prev = unwrapStep(prev, d);
66
+ out[i] = prev;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ // Circular mean of wrapped degrees — the honest average of a set of directions, via the
72
+ // unit-vector sum. Returns wrapped degrees, or null when there is nothing to average or the
73
+ // directions cancel exactly (opposite readings have no meaningful mean, and atan2(0,0) would
74
+ // answer 0° with false confidence).
75
+ function circularMeanDeg(degs) {
76
+ let sx = 0, sy = 0, n = 0;
77
+ for (const d of degs) {
78
+ if (!Number.isFinite(d)) continue;
79
+ const r = d * Math.PI / 180;
80
+ sx += Math.cos(r); sy += Math.sin(r); n++;
81
+ }
82
+ if (!n || Math.hypot(sx, sy) < 1e-9) return null;
83
+ return wrap360(Math.atan2(sy, sx) * 180 / Math.PI);
84
+ }
85
+
86
+ module.exports = { wrap360, wrap180, unwrapStep, unwrapDeg, circularMeanDeg }
@@ -63,7 +63,14 @@ const CHANNELS = [
63
63
  // mistake here would look entirely plausible on screen, which is why the test pins it.
64
64
  const WRAPPED = new Set(['twd', 'twa', 'awa', 'cog', 'heading', 'wptBrg'])
65
65
 
66
- const wrap360 = (d) => ((d % 360) + 360) % 360
66
+ // Of those, TWA and AWA are SIGNED: -180..+180 with port negative, because "40° off the
67
+ // bow to port" is what the number means. The other four are absolute bearings, 0..360.
68
+ // A circular mean always comes back 0..360, so these two MUST be folded back or a
69
+ // port-side wind reads as 270° — nonsense on every screen that shows it.
70
+ const SIGNED = new Set(['twa', 'awa'])
71
+ const rewrap = (c, d) => (SIGNED.has(c) ? wrap180(d) : wrap360(d))
72
+
73
+ const { wrap360, wrap180, circularMeanDeg } = require('./angles')
67
74
  // Stored values are rounded to 3 decimals. Rows now carry a mean plus two extremes per
68
75
  // channel, so full float noise ("6.430000000000001") would inflate every persisted line
69
76
  // for precision no instrument has and no chart can draw.
@@ -134,7 +141,7 @@ class RingHistoryProvider {
134
141
  // polls counts readings seen since the last emit. Zero means the telemetry source
135
142
  // gave us nothing at all, and we push no row — a gap is the honest record, where a
136
143
  // row of nulls would be a claim that we looked and the boat had no data.
137
- this._acc = { polls: 0, sum: {}, cnt: {}, lo: {}, hi: {}, last: {}, lat: null, lon: null }
144
+ this._acc = { polls: 0, sum: {}, cnt: {}, lo: {}, hi: {}, last: {}, sinS: {}, cosS: {}, lat: null, lon: null }
138
145
  }
139
146
 
140
147
  // BoatState -> the flat channel map this ring records. Kept separate from the
@@ -169,7 +176,18 @@ class RingHistoryProvider {
169
176
  const v = raw[c]
170
177
  if (!Number.isFinite(v)) continue
171
178
  a.last[c] = v
172
- if (WRAPPED.has(c)) continue // a bearing has no meaningful mean or extreme
179
+ if (WRAPPED.has(c)) {
180
+ // A bearing still has no meaningful min/max — but it does have an average: the
181
+ // unit-vector (circular) mean, which is a genuine average with no seam. Serving
182
+ // one sample per bucket instead was correct but jumpy, and on a long passage the
183
+ // emit rate coarsens to ~52 s, so that would be one reading per bucket where the
184
+ // cloud averages every sample.
185
+ const r = v * DEG
186
+ a.sinS[c] = (a.sinS[c] || 0) + Math.sin(r)
187
+ a.cosS[c] = (a.cosS[c] || 0) + Math.cos(r)
188
+ a.cnt[c] = (a.cnt[c] || 0) + 1
189
+ continue
190
+ }
173
191
  a.sum[c] = (a.sum[c] || 0) + v
174
192
  a.cnt[c] = (a.cnt[c] || 0) + 1
175
193
  a.lo[c] = Math.min(a.lo[c] == null ? v : a.lo[c], v)
@@ -188,7 +206,15 @@ class RingHistoryProvider {
188
206
  if (!a.polls) return
189
207
  const row = { t: Date.now(), lo: {}, hi: {}, n: {} }
190
208
  for (const c of CHANNELS) {
191
- if (WRAPPED.has(c)) { row[c] = a.last[c] == null ? null : round3(a.last[c]); continue }
209
+ if (WRAPPED.has(c)) {
210
+ const n = a.cnt[c] || 0
211
+ // A near-zero resultant means the readings cancelled (exactly opposite bearings):
212
+ // there is no meaningful average, and atan2(0, 0) would answer 0° — due north,
213
+ // with total confidence. A gap is the honest record.
214
+ const mag = n ? Math.hypot(a.sinS[c], a.cosS[c]) : 0
215
+ row[c] = mag < 1e-9 ? null : round3(rewrap(c, wrap360(Math.atan2(a.sinS[c], a.cosS[c]) / DEG)))
216
+ continue
217
+ }
192
218
  const n = a.cnt[c] || 0
193
219
  if (!n) { row[c] = null; continue }
194
220
  row[c] = round3(a.sum[c] / n)
@@ -258,9 +284,9 @@ class RingHistoryProvider {
258
284
  if (v == null) continue
259
285
  const key = step ? Math.floor(r.t / step) * step : seq++
260
286
  let b = buckets.get(key)
261
- if (!b) buckets.set(key, (b = { t: step ? key + step : r.t, sum: 0, n: 0, lo: Infinity, hi: -Infinity, last: null }))
287
+ if (!b) buckets.set(key, (b = { t: step ? key + step : r.t, sum: 0, n: 0, lo: Infinity, hi: -Infinity, last: null, dirs: [] }))
262
288
  b.last = v
263
- if (wrapped) continue
289
+ if (wrapped) { b.dirs.push(v); continue }
264
290
  // Rows written before v0.29.0 carry no lo/hi/n. Reading them defensively means an
265
291
  // append-log from an older version still loads and simply yields a degenerate
266
292
  // band (the point value itself) rather than an empty chart or a crash.
@@ -273,8 +299,15 @@ class RingHistoryProvider {
273
299
  if (hi > b.hi) b.hi = hi
274
300
  }
275
301
  const out = [...buckets.values()].sort((a, b) => a.t - b.t)
302
+ // Wrapped channels re-bucket by circular mean too — unweighted, matching the
303
+ // cloud's provider: a row built from 15 polls counts the same as one built from 3.
304
+ // A small inaccuracy, kept deliberately, because the two providers answering the
305
+ // same question identically is worth more here than either being marginally righter
306
+ // alone. Buckets whose directions cancel drop out rather than reporting north.
276
307
  const pts = wrapped
277
- ? out.map((b) => [b.t, b.last])
308
+ ? out.map((b) => [b.t, circularMeanDeg(b.dirs)])
309
+ .filter(([, d]) => d != null)
310
+ .map(([t, d]) => [t, round3(rewrap(c, d))])
278
311
  : out.filter((b) => b.n > 0).map((b) => [b.t, round3(b.sum / b.n)])
279
312
  if (!pts.length) continue
280
313
  series[c] = pts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.30.0",
3
+ "version": "0.31.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 — info@sailkick.io",
5
5
  "main": "index.js",
6
6
  "scripts": {