@meri-imperiumi/signalk-dead-reckoning 0.12.2 → 0.12.3

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
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.3] - 2026-10-03
11
+
12
+ ### Fixed
13
+ - **Tier-3 weather current now parses the fields providers actually
14
+ serve.** `parseWeatherCurrent` read the REST docs' shape
15
+ (`current.set` rad / `current.drift` m/s), but the Weather API server
16
+ transforms nothing — both the JS API (`app.weatherApi.getForecasts`)
17
+ and REST return the registered provider's data as-is — and the
18
+ providers on this install never emit that shape: the default GRIB
19
+ provider carries no currents at all, and signalk-weather-router-plus
20
+ answers `water.surfaceCurrentDirection` (rad, the set, towards) /
21
+ `water.surfaceCurrentSpeed` (m/s), the shape of `@signalk/server-api`'s
22
+ TS `WeatherData` interface (which contradicts the docs' TypeBox
23
+ `WeatherDataModel`). Every poll therefore died with "forecast carries
24
+ no current data" and DR silently ran on derived/zero current. The
25
+ parser now accepts both shapes (water.* preferred), verified against
26
+ the live server; wind-only entries without current coverage are
27
+ skipped so interpolation brackets between the entries that carry
28
+ current.
29
+
10
30
  ## [0.12.2] - 2026-09-28
11
31
 
12
32
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
package/plugin/current.js CHANGED
@@ -13,11 +13,17 @@
13
13
  * observation outranks model products (sea trial 2026-08-30→09-05:
14
14
  * ~11 nm DR error over 622 nm vs 47 nm for tier 3).
15
15
  * 3. **Signal K Weather API** (`/signalk/v2/api/weather/forecasts/point`)
16
- * — a provider (e.g. a GRIB another process already downloaded)
17
- * serves point forecasts carrying `current: {set (rad), drift (m/s)}`.
18
- * The client polls it at the vessel position on a slow interval and
19
- * caches the time-interpolated vector; the 1 Hz tick never touches
20
- * the network.
16
+ * — a weather provider carrying currents (e.g. signalk-weather-router-
17
+ * plus: Copernicus SMOC) answers point forecasts; the client polls it
18
+ * at the vessel position on a slow interval and caches the
19
+ * time-interpolated vector; the 1 Hz tick never touches the network.
20
+ * Current fields follow the provider: the REST docs' TypeBox model
21
+ * defines top-level `current.set` (rad, towards) / `current.drift`
22
+ * (m/s), while `@signalk/server-api`'s TS WeatherData and the real
23
+ * providers use `water.surfaceCurrentDirection` /
24
+ * `water.surfaceCurrentSpeed`. The server transforms nothing — the
25
+ * JS API and REST both return provider data as-is — so the parser
26
+ * accepts both shapes.
21
27
  * 4. Offline pilot charts (`offline_pilot_currents`) — table exists in
22
28
  * the schema; the lookup hook is reserved.
23
29
  * 5. **Zero vector** — pure inertial water track.
@@ -44,8 +50,11 @@ function normalizeDeg360(deg) {
44
50
 
45
51
  /**
46
52
  * Parses a Weather API forecast series into the current vector valid at
47
- * `nowMs`. Entries carrying `current.set` (rad, direction the current
48
- * flows *toward*) and `current.drift` (m/s) are interpolated as u/v
53
+ * `nowMs`. Entries carrying current data — `water.surfaceCurrentDirection`
54
+ * (rad, the direction the current flows *toward*) and
55
+ * `water.surfaceCurrentSpeed` (m/s), or the REST docs' top-level
56
+ * `current.set` / `current.drift` (same units, same semantics) — are
57
+ * interpolated as u/v
49
58
  * components between the entries bracketing `nowMs`; outside the series
50
59
  * the nearest endpoint is used (the client's TTL bounds staleness).
51
60
  *
@@ -59,8 +68,13 @@ function parseWeatherCurrent(points, nowMs) {
59
68
  const entries = [];
60
69
  for (const p of points) {
61
70
  const t = Date.parse(p?.date ?? "");
62
- const set = p?.current?.set;
63
- const drift = p?.current?.drift;
71
+ // Two shapes exist in the wild: the REST docs' TypeBox model (top-
72
+ // level current.set/drift) and the server-api TS WeatherData interface
73
+ // plus real providers (water.surfaceCurrent*). The server serves
74
+ // provider data untransformed on both the JS API and REST, so accept
75
+ // either; prefer the water.* fields.
76
+ const set = p?.water?.surfaceCurrentDirection ?? p?.current?.set;
77
+ const drift = p?.water?.surfaceCurrentSpeed ?? p?.current?.drift;
64
78
  if (!Number.isFinite(t)) continue;
65
79
  if (!Number.isFinite(set) || !Number.isFinite(drift)) continue;
66
80
  entries.push({ t, set, drift });
@@ -184,8 +198,14 @@ function resolveCurrent(input = {}) {
184
198
  * `/signalk/...` path. On servers without the Weather API the client
185
199
  * stays idle and the §6.2 resolver never sees tier 3.
186
200
  *
187
- * The response is a WeatherDataModel array; `current.set` is radians
188
- * and `current.drift` m/s (converted here to deg/kn).
201
+ * The response is a WeatherData array (the Signal K Weather API model).
202
+ * Currents appear either as `water.surfaceCurrentDirection` (rad, the
203
+ * set, towards) / `water.surfaceCurrentSpeed` (m/s) — what real
204
+ * providers like signalk-weather-router-plus serve — or as the REST
205
+ * docs' `current.set` / `current.drift` (same units); both are
206
+ * converted here to deg/kn. Entries without current coverage (e.g.
207
+ * wind-only steps, or points outside the provider's current sources)
208
+ * simply lack the fields and are skipped.
189
209
  */
190
210
  class WeatherCurrentClient {
191
211
  /**
package/plugin/index.js CHANGED
@@ -830,7 +830,7 @@ module.exports = (app) => {
830
830
  type: "boolean",
831
831
  title: "Use Signal K Weather API current (set/drift) for DR",
832
832
  description:
833
- "Polls the server's weather provider (typically a GRIB another process downloaded) for the point-forecast current at the vessel position and integrates it into the DR solution (SPEC §6.2 tier 3). Falls back to the zero vector when unavailable.",
833
+ "Polls the server's weather provider (e.g. signalk-weather-router-plus, serving ECMWF wind and Copernicus SMOC currents) for the point-forecast current at the vessel position and integrates it into the DR solution (SPEC §6.2 tier 3). Falls back to the zero vector when unavailable.",
834
834
  default: DEFAULT_CONFIG.weatherCurrent.enabled,
835
835
  },
836
836
  "weatherCurrent.intervalMs": {
@@ -958,8 +958,9 @@ module.exports = (app) => {
958
958
  : seedTs;
959
959
 
960
960
  // §6.2 tier 3: poll the Signal K Weather API for the point-forecast
961
- // current at the vessel position (typically a GRIB another process
962
- // downloaded). Off the 1 Hz hot path: slow interval, cached read.
961
+ // current at the vessel position (a current-capable provider such as
962
+ // signalk-weather-router-plus). Off the 1 Hz hot path: slow interval,
963
+ // cached read.
963
964
  if (config.weatherCurrent.enabled) {
964
965
  // In-process Weather API (mirrors signalk-energy-predictor):
965
966
  // `app.weatherApi` is the same instance the REST routes wrap,
@@ -16,11 +16,11 @@ const {
16
16
 
17
17
  const NOW = Date.UTC(2026, 6, 15, 12, 0, 0);
18
18
 
19
- /** forecast entry helper: set rad toward, drift m/s */
19
+ /** forecast entry helper: set rad toward, drift m/s (SK Weather API shape) */
20
20
  function entry(offsetMin, setRad, driftMs) {
21
21
  return {
22
22
  date: new Date(NOW + offsetMin * 60000).toISOString(),
23
- current: { set: setRad, drift: driftMs },
23
+ water: { surfaceCurrentDirection: setRad, surfaceCurrentSpeed: driftMs },
24
24
  };
25
25
  }
26
26
 
@@ -76,6 +76,47 @@ test("parseWeatherCurrent: null when no entry carries current data", () => {
76
76
  assert.strictEqual(parseWeatherCurrent(null, NOW), null);
77
77
  });
78
78
 
79
+ test("parseWeatherCurrent: entries without current data are skipped, not fatal", () => {
80
+ // Real providers emit wind-only entries too (e.g. ECMWF steps outside
81
+ // SMOC coverage, or points the current sources do not reach). Those
82
+ // entries must be skipped so interpolation still brackets `nowMs`
83
+ // between the entries that carry current.
84
+ const r = parseWeatherCurrent(
85
+ [
86
+ {
87
+ date: new Date(NOW - 60 * 60000).toISOString(),
88
+ wind: { speedTrue: 5 },
89
+ },
90
+ entry(0, 0, 1),
91
+ entry(60, Math.PI / 2, 1),
92
+ ],
93
+ NOW + 30 * 60000,
94
+ );
95
+ assert.ok(r);
96
+ assert.ok(r.setTrue > 0 && r.setTrue < 90, `setTrue ${r?.setTrue}`);
97
+ });
98
+
99
+ test("parseWeatherCurrent: accepts the REST docs' current.set/drift shape too", () => {
100
+ // @signalk/server-api ships two conflicting models: the TypeBox
101
+ // WeatherDataModel behind the REST docs (top-level current.set/drift)
102
+ // and the TS WeatherData interface (water.surfaceCurrent*). The server
103
+ // serves provider data untransformed, so both must parse identically.
104
+ const docShape = (offsetMin, setRad, driftMs) => ({
105
+ date: new Date(NOW + offsetMin * 60000).toISOString(),
106
+ current: { set: setRad, drift: driftMs },
107
+ });
108
+ const fromWater = parseWeatherCurrent(
109
+ [entry(-60, 0, 1), entry(60, Math.PI / 2, 1)],
110
+ NOW,
111
+ );
112
+ const fromDoc = parseWeatherCurrent(
113
+ [docShape(-60, 0, 1), docShape(60, Math.PI / 2, 1)],
114
+ NOW,
115
+ );
116
+ assert.ok(fromDoc);
117
+ assert.deepStrictEqual(fromDoc, fromWater);
118
+ });
119
+
79
120
  // --------------------------------------------------------------- resolver
80
121
 
81
122
  test("resolveCurrent: tier 5 zero vector when nothing better exists", () => {
@@ -3854,12 +3854,18 @@ test("tick integrates the Weather API current into the DR solution (tier 3)", as
3854
3854
  const app = new FakeSignalKApp();
3855
3855
  app.dataPath = dir;
3856
3856
  // In-process Weather API (the same object signalk-energy-predictor
3857
- // calls): 1 m/s due east → set 90° true, drift ≈ 1.944 kn.
3857
+ // calls): 1 m/s due east → set 90° true, drift ≈ 1.944 kn. The
3858
+ // response uses the Signal K WeatherData shape (currents under
3859
+ // `water`, as spec-conformant providers like
3860
+ // signalk-weather-router-plus serve them).
3858
3861
  app.weatherApi = {
3859
3862
  getForecasts: async () => [
3860
3863
  {
3861
3864
  date: new Date().toISOString(),
3862
- current: { set: Math.PI / 2, drift: 1 },
3865
+ water: {
3866
+ surfaceCurrentDirection: Math.PI / 2,
3867
+ surfaceCurrentSpeed: 1,
3868
+ },
3863
3869
  },
3864
3870
  ],
3865
3871
  };