@meri-imperiumi/signalk-dead-reckoning 0.12.1 → 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 +34 -0
- package/package.json +2 -2
- package/plugin/current.js +31 -11
- package/plugin/index.js +4 -3
- package/public/dr-app.js +7 -1
- package/public/dr-ext-host-core.js +32 -0
- package/public/dr-ext-host.js +5 -1
- package/public/icon-256.png +0 -0
- package/tests/current.test.js +43 -2
- package/tests/dr-ext-host-core.test.js +17 -0
- package/tests/dr-ext-host.test.js +10 -0
- package/tests/plugin.test.js +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,40 @@ 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
|
+
|
|
30
|
+
## [0.12.2] - 2026-09-28
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- **The top-right plotter-extension area now behaves as a top-right
|
|
34
|
+
anchor.** Two defects made it read as "top left of the GPS status
|
|
35
|
+
bar": the area rode its column's left edge instead of the GPS
|
|
36
|
+
panel's right edge (it now shares the panel's right alignment), and
|
|
37
|
+
the widget grid rendered logical col 0 at the physical left for
|
|
38
|
+
every anchor — so the first placed widget sat farthest from the
|
|
39
|
+
viewport corner with the free slots between it and the edge.
|
|
40
|
+
Right-hand anchors now mirror the column axis: col 0 hugs the
|
|
41
|
+
right-hand corner and widgets pack corner-inward, symmetric with
|
|
42
|
+
the top-left area.
|
|
43
|
+
|
|
10
44
|
## [0.12.1] - 2026-09-28
|
|
11
45
|
|
|
12
46
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meri-imperiumi/signalk-dead-reckoning",
|
|
3
|
-
"version": "0.12.
|
|
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": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"signalk-plugin-enabled-by-default": false,
|
|
28
28
|
"signalk": {
|
|
29
29
|
"displayName": "Dead Reckoning",
|
|
30
|
-
"appIcon": "./icon.png",
|
|
30
|
+
"appIcon": "./icon-256.png",
|
|
31
31
|
"requires": [
|
|
32
32
|
"@meri-imperiumi/signalk-internet"
|
|
33
33
|
],
|
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.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
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
|
|
48
|
-
* flows *toward*) and
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
188
|
-
*
|
|
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 (
|
|
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 (
|
|
962
|
-
//
|
|
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,
|
package/public/dr-app.js
CHANGED
|
@@ -256,7 +256,13 @@ template.innerHTML = /* html */ `
|
|
|
256
256
|
gap: 0.5rem;
|
|
257
257
|
align-items: flex-start;
|
|
258
258
|
}
|
|
259
|
-
|
|
259
|
+
/* The right column is as wide as its widest child (usually the GPS
|
|
260
|
+
panel), so without this both the panel and the top-right widget
|
|
261
|
+
area would need explicit right edges: the panel rides the right
|
|
262
|
+
edge, and the top-right area must follow it — left-aligned it
|
|
263
|
+
would park under the panel's left half, reading as "top left". */
|
|
264
|
+
.dr-top-col > .dr-gps,
|
|
265
|
+
.dr-top-col > dr-ext-widget-area[anchor="top-right"] {
|
|
260
266
|
align-self: flex-end;
|
|
261
267
|
}
|
|
262
268
|
/* Water-track readout: bottom-right panel — figures + the manual
|
|
@@ -151,6 +151,38 @@ export function occupiedCells(placements) {
|
|
|
151
151
|
return occ;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Do placements in this anchor mirror their column axis? Right-hand
|
|
156
|
+
* anchors render mirrored: logical col 0 is the viewport corner (the
|
|
157
|
+
* RIGHT edge), so widgets pack corner-inward — symmetric with left
|
|
158
|
+
* anchors, where col 0 is the left edge. Without the mirror the first
|
|
159
|
+
* placed widget lands in the cell farthest from the corner and the
|
|
160
|
+
* free space sits between it and the edge.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} anchor
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
export function isMirroredAnchor(anchor) {
|
|
166
|
+
return typeof anchor === "string" && anchor.endsWith("-right");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* CSS grid column line (1-based, physical left→right) where a
|
|
171
|
+
* placement's left edge renders for the given anchor. Mirrored
|
|
172
|
+
* (right-hand) anchors flip the column axis: a placement at logical
|
|
173
|
+
* column `col` spans lines `GRID_COLS - col - cols + 1 ..
|
|
174
|
+
* GRID_COLS - col`.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} anchor
|
|
177
|
+
* @param {number} col - 0-based logical column (0 = the anchor corner)
|
|
178
|
+
* @param {number} cols - placement column span
|
|
179
|
+
* @returns {number} 1-based CSS grid column line
|
|
180
|
+
*/
|
|
181
|
+
export function gridColLine(anchor, col, cols) {
|
|
182
|
+
if (isMirroredAnchor(anchor)) return GRID_COLS - col - cols + 1;
|
|
183
|
+
return col + 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
154
186
|
/**
|
|
155
187
|
* First position (packing from the grid origin, row-major) where a
|
|
156
188
|
* widget of this size fits without overlapping existing placements.
|
package/public/dr-ext-host.js
CHANGED
|
@@ -1032,7 +1032,11 @@ export class DrExtWidgetArea extends AreaBase {
|
|
|
1032
1032
|
const size = core.parseSize(p.size) ?? { cols: 1, rows: 1 };
|
|
1033
1033
|
cell = document.createElement("div");
|
|
1034
1034
|
cell.className = "cell";
|
|
1035
|
-
|
|
1035
|
+
// Column line via core.gridColLine: right-hand anchors mirror
|
|
1036
|
+
// the column axis so col 0 renders at the viewport corner.
|
|
1037
|
+
cell.style.gridArea =
|
|
1038
|
+
`${p.row + 1} / ${core.gridColLine(this.anchor, p.col, size.cols)} ` +
|
|
1039
|
+
`/ span ${size.rows} / span ${size.cols}`;
|
|
1036
1040
|
this.grid.appendChild(cell);
|
|
1037
1041
|
this.cells.set(p.instanceId, cell);
|
|
1038
1042
|
}
|
|
Binary file
|
package/tests/current.test.js
CHANGED
|
@@ -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
|
-
|
|
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", () => {
|
|
@@ -101,6 +101,23 @@ test("firstFreeSlot: packs row-major, skips overlaps", () => {
|
|
|
101
101
|
assert.equal(core.firstFreeSlot(full, "1x1"), null);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
test("gridColLine: right-hand anchors mirror the column axis", () => {
|
|
105
|
+
// Left anchor: col 0 is the leftmost CSS column (the corner).
|
|
106
|
+
assert.equal(core.isMirroredAnchor("top-left"), false);
|
|
107
|
+
assert.equal(core.gridColLine("top-left", 0, 1), 1);
|
|
108
|
+
assert.equal(core.gridColLine("top-left", 1, 1), 2);
|
|
109
|
+
// Right anchor: col 0 is the RIGHTMOST CSS column (the corner), so
|
|
110
|
+
// the first placed widget hugs the viewport edge and free space
|
|
111
|
+
// extends inward.
|
|
112
|
+
assert.equal(core.isMirroredAnchor("top-right"), true);
|
|
113
|
+
assert.equal(core.gridColLine("top-right", 0, 1), 2);
|
|
114
|
+
assert.equal(core.gridColLine("top-right", 1, 1), 1);
|
|
115
|
+
// Full-width spans cover both columns either way; a 1x2 in the
|
|
116
|
+
// corner column stays on the right edge for both rows.
|
|
117
|
+
assert.equal(core.gridColLine("top-right", 0, 2), 1);
|
|
118
|
+
assert.equal(core.gridColLine("top-right", 0, 1), core.GRID_COLS);
|
|
119
|
+
});
|
|
120
|
+
|
|
104
121
|
test("layoutFromJSON: validates shape, ignores unknown anchors", () => {
|
|
105
122
|
const good = core.layoutFromJSON({
|
|
106
123
|
version: 1,
|
|
@@ -353,6 +353,16 @@ test("dr-app: widget areas sit below the app's own top panels", () => {
|
|
|
353
353
|
);
|
|
354
354
|
assert.ok(colRight, "top-right area follows the GPS panel in its column");
|
|
355
355
|
assert.match(appSrc, /\.dr-top-col \{[^}]*flex-direction: column/s);
|
|
356
|
+
assert.match(
|
|
357
|
+
appSrc,
|
|
358
|
+
/\.dr-top-col > \.dr-gps,\s*\.dr-top-col > dr-ext-widget-area\[anchor="top-right"\]\s*\{\s*align-self: flex-end;/s,
|
|
359
|
+
"top-right area right-aligns with the GPS panel in its column",
|
|
360
|
+
);
|
|
361
|
+
assert.match(
|
|
362
|
+
hostSrc,
|
|
363
|
+
/core\.gridColLine\(this\.anchor, p\.col, size\.cols\)/,
|
|
364
|
+
"cell columns route through the anchor-aware mirroring helper",
|
|
365
|
+
);
|
|
356
366
|
assert.match(
|
|
357
367
|
appSrc,
|
|
358
368
|
/new PlotterExtHost\(\{\s*stream: window\.drSignalkStream,\s*mount: root,/s,
|
package/tests/plugin.test.js
CHANGED
|
@@ -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
|
-
|
|
3865
|
+
water: {
|
|
3866
|
+
surfaceCurrentDirection: Math.PI / 2,
|
|
3867
|
+
surfaceCurrentSpeed: 1,
|
|
3868
|
+
},
|
|
3863
3869
|
},
|
|
3864
3870
|
],
|
|
3865
3871
|
};
|