@meri-imperiumi/signalk-dead-reckoning 0.7.0 → 0.9.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/CHANGELOG.md +193 -0
- package/SPEC.md +3 -3
- package/package.json +3 -2
- package/plugin/celestial.js +41 -0
- package/plugin/current.js +73 -47
- package/plugin/db.js +101 -13
- package/plugin/derived-current.js +180 -0
- package/plugin/divergence.js +7 -5
- package/plugin/engine.js +11 -2
- package/plugin/fix-pipeline.js +99 -4
- package/plugin/index.js +732 -122
- package/plugin/logbook.js +17 -7
- package/plugin/resource-polar.js +316 -0
- package/plugin/training.js +138 -27
- package/plugin/uncertainty.js +132 -78
- package/public/dr-app.js +9 -3
- package/public/dr-fix-panel.js +5 -2
- package/public/dr-sight-panel.js +9 -1
- package/tests/celestial.test.js +67 -0
- package/tests/current.test.js +96 -27
- package/tests/derived-current.test.js +261 -0
- package/tests/divergence.test.js +15 -5
- package/tests/fake-app.js +11 -0
- package/tests/fix-pipeline.test.js +71 -0
- package/tests/logbook.test.js +47 -0
- package/tests/plugin.test.js +1060 -90
- package/tests/replay-passage.test.js +318 -0
- package/tests/resource-polar.test.js +279 -0
- package/tests/training.test.js +375 -177
- package/tests/uncertainty.test.js +168 -85
- package/tools/replay-passage.js +1164 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,198 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.9.0] - 2026-09-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Derived-current tier (SPEC §6.2 tier 2)**: an exponentially-weighted
|
|
14
|
+
mean of the boat's own GPS-vs-water-track residual, sampled every
|
|
15
|
+
tick while GPS is trusted and the water track is usable (STW floor,
|
|
16
|
+
interval, glitch/outlier bounds), carried forward with exponential
|
|
17
|
+
decay when GPS degrades and TTL-bounded (24 h) so the resolver falls
|
|
18
|
+
through to the model tiers after. Zero-configuration: it outranks
|
|
19
|
+
the Weather API and pilot charts as soon as ~10 samples exist (the
|
|
20
|
+
first minutes of a passage), `environment.current` and `/status`
|
|
21
|
+
surface it as source `derived`, and the uncertainty cone uses a
|
|
22
|
+
tighter per-tier residual (0.2 kn). Sea-trial replay
|
|
23
|
+
(Aitutaki→Niue 2026-08-30→09-05): final DR error 10.9 nm over 622 nm
|
|
24
|
+
vs 47.3 nm for the live tier-3 configuration and 85.2 nm for the
|
|
25
|
+
zero vector — landfall-visible DR on a trade-wind passage.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- **The weather-current client calls the Weather API in-process**
|
|
29
|
+
(`app.weatherApi.getForecasts()`, the same instance the REST routes
|
|
30
|
+
wrap — mirrors signalk-energy-predictor) instead of an HTTP loopback
|
|
31
|
+
that defaulted to `localhost:3000` — a port Grafana answers on this
|
|
32
|
+
install, so every poll 404ed and tier 3 never resolved
|
|
33
|
+
("Weather current fetch failed: weather API returned 404 — using
|
|
34
|
+
zero current"). No base URL, ports or auth tokens involved; the
|
|
35
|
+
`weatherCurrent.baseUrl` config option is gone.
|
|
36
|
+
- **The celestial sight plausibility gate measures the distance from
|
|
37
|
+
the DR origin, not from the sight's assumed position** (sea trial
|
|
38
|
+
2026-08-31 root-cause follow-up): a small intercept at a wrong
|
|
39
|
+
assumed position drew the LOP an ocean away and the gate never saw
|
|
40
|
+
it. Non-noon sights now gate on the perpendicular distance from the
|
|
41
|
+
DR origin to the LOP (floored by the intercept magnitude), mirroring
|
|
42
|
+
the `/fix/lop` bearing gate; noon sights gate on the origin-vs-
|
|
43
|
+
reduction latitude difference.
|
|
44
|
+
- **The fix-confirm sanity cap now grows with DR time-since-origin**
|
|
45
|
+
instead of being a flat 100 NM: legitimate fixes after days GPS-less
|
|
46
|
+
(DR drifts ~0.5–1 NM/h; the trials measured 47 NM in 4.5 days with a
|
|
47
|
+
current tier, 85 NM cold) were being rejected, training crews to
|
|
48
|
+
habitually force-confirm — defeating the guard for the teleport
|
|
49
|
+
case it exists for. The cap is now `max(100 NM, 1.5 kn × hours since
|
|
50
|
+
origin)`, mirrored in `/fix/resolve`'s `gross` preview flag.
|
|
51
|
+
- **Training Mode is suspended while the resolved current is tier 5
|
|
52
|
+
(zero vector — current unknown)** (SPEC §6.1/§6.2): training with an
|
|
53
|
+
unknown current bakes it into the leeway/speed bins as fake
|
|
54
|
+
corrections and over-applies them later (Huahine→Aitutaki backtest:
|
|
55
|
+
72 vs 65 nm cold; both trials' regressions). With the new tier-2
|
|
56
|
+
derived current self-bootstrapping in the first minutes of a
|
|
57
|
+
passage, a current-resolved matrix train no longer requires a
|
|
58
|
+
weather provider. The replay tool's learning variants accordingly
|
|
59
|
+
run with the derived current ("training with zero current" is no
|
|
60
|
+
longer a reachable live configuration), and a new `derived` variant
|
|
61
|
+
(cold + tier 2, no training) isolates the tier's contribution in
|
|
62
|
+
backtests.
|
|
63
|
+
- **Logbook write-through no longer floods the server when
|
|
64
|
+
authentication cannot be obtained** (reported 2026-09-11 on
|
|
65
|
+
lille-oe-pi: a continuous ~10 req/s stream of
|
|
66
|
+
`POST /plugins/signalk-logbook/logs` 401s at anchor; the 91-byte
|
|
67
|
+
bodies identified the server's plugin-route admin gate answering a
|
|
68
|
+
tokenless "open server" probe). The access-request client read
|
|
69
|
+
*any* non-OK response (403 disallowed device requests, 429 rate
|
|
70
|
+
limit, 400 duplicate, 503, …) as "no access-request flow", built a
|
|
71
|
+
tokenless client, ate the admin gate's 401, and re-ran the whole
|
|
72
|
+
cycle forever. The client now distinguishes: 403 → "forbidden"
|
|
73
|
+
(park; the plugin status names the two ways out — allow device
|
|
74
|
+
access requests on the server, or paste a token); 404/501 → a
|
|
75
|
+
single tokenless probe, terminal on rejection; everything else →
|
|
76
|
+
"unreachable", retried on an exponential backoff (30 s doubling to
|
|
77
|
+
a 16 min cap, configurable via `logbook.retryBackoffMs`) — never an
|
|
78
|
+
open-server assumption, never a loop. A rejected config token or
|
|
79
|
+
tokenless probe parks instead of resurrecting the same credentials
|
|
80
|
+
in a loop. A pending access request is persisted and resumed across
|
|
81
|
+
restarts instead of re-filed (the server rejects duplicates), and an
|
|
82
|
+
approval granted while the plugin was down is picked up on the
|
|
83
|
+
first poll. Queued entries are never dropped by any of this.
|
|
84
|
+
- **The "paddlewheel appears fouled" alert no longer fires on breeze
|
|
85
|
+
over a moored boat** while `navigation.state` hasn't landed in the
|
|
86
|
+
delta cache yet (or on installs without an autostate source): a live
|
|
87
|
+
SOG reading below the moving threshold is now an outright "not
|
|
88
|
+
making way" verdict, and wind only corroborates fouling when GPS is
|
|
89
|
+
silent.
|
|
90
|
+
|
|
91
|
+
### Added
|
|
92
|
+
- **Polar speed fallback now also works without
|
|
93
|
+
signalk-polar-performance-plugin**: when its `performance.polarSpeed`
|
|
94
|
+
feed is absent, DR computes the speed in-process from the active
|
|
95
|
+
`polars` resource (the signalk-polar-management contract — the
|
|
96
|
+
`polars.activePolar` pointer and `polars.performanceFactor` derating
|
|
97
|
+
multiplier) interpolated from water-referenced true wind
|
|
98
|
+
(`environment.wind.speedTrue` / `angleTrueWater`). The resource path
|
|
99
|
+
shares the existing running-average window and staleness cutoff, is
|
|
100
|
+
zero-configuration (engages when an active polar is selected, stays
|
|
101
|
+
dormant otherwise), and a live `performance.polarSpeed` feed still
|
|
102
|
+
outranks it. The DR state's `speedSource` distinguishes `polar`
|
|
103
|
+
(delta feed) from `polar-resource`, the sensor-health alert names the
|
|
104
|
+
active resource id, and GET /status mirrors the loaded polar. A dead
|
|
105
|
+
wind instrument ages the average out to the honest idle branch
|
|
106
|
+
instead of integrating a frozen wind at a frozen speed.
|
|
107
|
+
|
|
108
|
+
## [0.8.0] - 2026-09-09
|
|
109
|
+
|
|
110
|
+
### Fixed
|
|
111
|
+
- **Celestial sights are no longer silently forced through the noon
|
|
112
|
+
(meridian-altitude) reducer** (sea trial 2026-08-31, Aitutaki→Niue:
|
|
113
|
+
both Sun sights reduced to −69.3°/−31.9°). The sight panel's
|
|
114
|
+
`readForm()` read checkboxes via `el.value` — always the string
|
|
115
|
+
`"on"` regardless of checked state — so every sight was POSTed with
|
|
116
|
+
`noon: true`. Checkboxes are now read via `el.checked` (both panels).
|
|
117
|
+
- **The webapp no longer overwrites its own-vessel GPS position with
|
|
118
|
+
the DR shadow boat's** (sea trial: the map boat flipped ~50 NM
|
|
119
|
+
between GPS and the ghost, and fix #9 recorded the ghost position as
|
|
120
|
+
a "GNSS" fix 87.5 km from the boat). Deltas whose context is the
|
|
121
|
+
shadow vessel now route to neither the AIS target store nor the
|
|
122
|
+
own-vessel view-model.
|
|
123
|
+
|
|
124
|
+
### Changed
|
|
125
|
+
- **The uncertainty cone now grows with current-knowledge**, not just
|
|
126
|
+
distance run (sea trial: 88 km of DR-vs-GPS divergence while the
|
|
127
|
+
polygon "expected" ~2 NM). Radius combines the distance-run error
|
|
128
|
+
and a per-tier current residual (manual 0.25 kn, weather/pilot
|
|
129
|
+
0.3 kn, zero-vector 1.0 kn) root-sum-square; the fallback margin
|
|
130
|
+
rose from 1° to 4° per NM run (measured open-loop rates were
|
|
131
|
+
7–12× the old value); the empirical rate is now a median with a
|
|
132
|
+
2 kn per-row cap (the spec's original intent — a garbage 3058 NM
|
|
133
|
+
correction previously poisoned an EWMA at "145 kn"); the floor rose
|
|
134
|
+
to 0.05 NM and the origin's own error radius seeds it (a celestial
|
|
135
|
+
fix is realistically ~5 nm — the cone never claims GPS confidence
|
|
136
|
+
below that).
|
|
137
|
+
- **The divergence advisory no longer flaps after every fix or print
|
|
138
|
+
"0.27 nm exceeds expected 0.27 nm"**: the exceedance deadband is
|
|
139
|
+
instrument-scale (0.005 NM) instead of float epsilon, and the alert
|
|
140
|
+
message states the exceedance margin.
|
|
141
|
+
- **"Tack/gybe in progress" no longer latches open for the whole
|
|
142
|
+
passage** in ss3–4 seaway: rate-of-turn is measured over a 6 s
|
|
143
|
+
rolling window (a single second of wave yaw can't open it), the
|
|
144
|
+
re-stabilization tolerances scale with sea state, and a window still
|
|
145
|
+
open after 5 minutes force-closes without classifying a maneuver.
|
|
146
|
+
The pre-maneuver AWA for tack/gybe classification is now taken from
|
|
147
|
+
the base of the ROT window (the previous tick's AWA has already
|
|
148
|
+
flipped with the bow by the time the window opens).
|
|
149
|
+
|
|
150
|
+
### Added
|
|
151
|
+
- **Passage replay backtest tool** (`tools/replay-passage.js`, minimal
|
|
152
|
+
SPEC §10.2 scope): replays a historical passage from the Signal K
|
|
153
|
+
History API through the real DR engine and reports how the shadow
|
|
154
|
+
boat diverged from GPS. Fetches a range in chunks at native 10s
|
|
155
|
+
resolution, forward-fills sparse sensors (unwrapping the signed AWA
|
|
156
|
+
across ±π through gybes), queries `navigation.attitude.roll`
|
|
157
|
+
(radians) directly — the attitude object itself is not recorded, only
|
|
158
|
+
its component paths — so heel reaches the matrix bins, seeds the DR
|
|
159
|
+
origin at the first GPS fix and never re-anchors it. Four variants run side by side: cold (no
|
|
160
|
+
training, tier-5 zero current), learning (Training Mode on, mirroring
|
|
161
|
+
a first live passage), plus the same two with a SCUD satellite-derived
|
|
162
|
+
current source — daily 0.25° fields from the PacIOOS ERDDAP,
|
|
163
|
+
nearest-cell in space, time-interpolated over source holes (and held
|
|
164
|
+
at the span ends), resolved at the shadow boat's own position. Also
|
|
165
|
+
derives the implied current (ground vector minus water-track vector)
|
|
166
|
+
as a diagnostic of what DR is missing. Outputs an hourly divergence
|
|
167
|
+
table, an implied-vs-SCUD current comparison, a GeoJSON track file,
|
|
168
|
+
full-resolution samples, and a self-contained SVG HTML report. A
|
|
169
|
+
`--stw-scale` option multiplies STW end-to-end (engine, training,
|
|
170
|
+
matrix lookup) to test paddlewheel-calibration hypotheses against a
|
|
171
|
+
passage.
|
|
172
|
+
Smoketests cover URL building, row parsing/filling, the current-grid
|
|
173
|
+
interpolation, and three synthetic passages (no-drift, known-current,
|
|
174
|
+
leeway absorbed by training).
|
|
175
|
+
- **Observation submission is speed-plausibility-gated**: a sight,
|
|
176
|
+
bearing or vertical-angle observation whose reduction implies the
|
|
177
|
+
vessel traveled faster than 50 kn since the last fix (e.g. the
|
|
178
|
+
trial's 3058 NM Antarctica sight ≈ 140 kn) is rejected with an
|
|
179
|
+
explanatory message in the form, so the user can fix the entry.
|
|
180
|
+
Displacements within realistic observation quality (~5 NM) always
|
|
181
|
+
pass.
|
|
182
|
+
- **Noon sights carry meridian and sanity guards**: a sight whose Sun
|
|
183
|
+
is more than 20° from the meridian (LHA), or whose computed latitude
|
|
184
|
+
lands more than 12° from the assumed/DR position, is refused rather
|
|
185
|
+
than reduced into garbage.
|
|
186
|
+
- **Fix confirmation carries a gross-displacement guard**: confirming
|
|
187
|
+
a fix more than 100 NM (configurable, `fixes.maxDisplacementNm`)
|
|
188
|
+
from the current DR origin is rejected with 422 unless the request
|
|
189
|
+
carries `force: true`; `/fix/resolve` previews the candidate's
|
|
190
|
+
displacement and flags gross candidates.
|
|
191
|
+
- **Raw sight inputs are persisted** (`raw_hs_deg`, index correction,
|
|
192
|
+
eye height, limb, computed Ho/Hc on lines_of_position; angle/height
|
|
193
|
+
on circular_position_lines) so reductions can be re-run and
|
|
194
|
+
backtested — reconstructing the trial's sights from the stored
|
|
195
|
+
noon results required algebraic archaeology.
|
|
196
|
+
- **`elapsedSinceOriginS` and the origin error radius survive plugin
|
|
197
|
+
restarts** (persisted alongside `last_known_good_fix`), so "since
|
|
198
|
+
last fix" and the cone no longer reset mid-excursion on a restart.
|
|
199
|
+
|
|
8
200
|
## [0.7.0] - 2026-08-29
|
|
9
201
|
|
|
10
202
|
### Added
|
|
@@ -137,6 +329,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
137
329
|
anchors: a from-scratch Meeus reduction for the Sun (≤0.7′, the
|
|
138
330
|
anchor's own accuracy class), the AA low-precision series for the
|
|
139
331
|
Moon (≤20′), and paper-almanac star values.
|
|
332
|
+
|
|
140
333
|
- **The webapp's "Ghost Track" heading above the map is gone.** It
|
|
141
334
|
wasted vertical space the map could use — the map card now opens with
|
|
142
335
|
no chrome above it, so the chart starts higher on the page. The
|
package/SPEC.md
CHANGED
|
@@ -269,7 +269,7 @@ Worker Thread (DR Physics)
|
|
|
269
269
|
|
|
270
270
|
### 6.1 Training Mode vs. Inference Mode
|
|
271
271
|
|
|
272
|
-
**Training Mode** — active when `isGpsReliable = true` AND `propulsion.main.state = stopped` AND paddlewheel not fouled (§6.3):
|
|
272
|
+
**Training Mode** — active when `isGpsReliable = true` AND `propulsion.main.state = stopped` AND paddlewheel not fouled (§6.3) AND the resolved current is not the zero vector (§6.2 tier < 5 — training with an unknown current bakes it into the leeway/speed bins as fake corrections):
|
|
273
273
|
- Computes error vectors: GPS SOG/COG vs. sensor STW/heading, minus the resolved current vector (§6.2), updated into matching `dr_matrix_bins` via EMA, learning rate modulated by effective `hit_count`.
|
|
274
274
|
|
|
275
275
|
**Inference Mode** — active when `isGpsReliable = false` OR OVERRIDE is manually engaged:
|
|
@@ -286,8 +286,8 @@ The paddlewheel-failure fallback is a distinct branch from the "GPS unreliable"
|
|
|
286
286
|
### 6.2 Current Hierarchy of Truth
|
|
287
287
|
|
|
288
288
|
1. Manual Override — watchstander input with valid TTL (`environment.current`).
|
|
289
|
-
2.
|
|
290
|
-
3. Sparse Forecast — bilinear/temporal-interpolated radio GRIB vectors.
|
|
289
|
+
2. Derived Residual — exponentially-weighted mean of the boat's own GPS-vs-water-track residual (`derived-current.js`), sampled while GPS is trusted and the water track is usable; carried forward with exponential decay when GPS degrades, TTL-bounded. The boat's own observation outranks model products (sea trial 2026-08-30→09-05, Aitutaki→Niue: ~11 nm DR error over 622 nm vs 47 nm for tier 3, 85 nm for tier 5).
|
|
290
|
+
3. Sparse Forecast — bilinear/temporal-interpolated radio GRIB vectors (Signal K Weather API).
|
|
291
291
|
4. Offline Pilot Charts — static SQLite monthly historical averages (`offline_pilot_currents`).
|
|
292
292
|
5. Zero Vector — pure inertial water track (U: 0, V: 0).
|
|
293
293
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meri-imperiumi/signalk-dead-reckoning",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"recommends": [
|
|
35
35
|
"@meri-imperiumi/signalk-autostate",
|
|
36
36
|
"@meri-imperiumi/signalk-logbook",
|
|
37
|
-
"signalk-polar-performance-plugin"
|
|
37
|
+
"signalk-polar-performance-plugin",
|
|
38
|
+
"signalk-polar-management"
|
|
38
39
|
],
|
|
39
40
|
"screenshots": [
|
|
40
41
|
"./doc/dr-map.png",
|
package/plugin/celestial.js
CHANGED
|
@@ -57,6 +57,22 @@ const MOON_RADIUS_KM = 1737.4;
|
|
|
57
57
|
/** Earth equatorial radius, km. */
|
|
58
58
|
const EARTH_EQ_RADIUS_KM = 6378.14;
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
/**
|
|
62
|
+
* Maximum distance of the Sun's LHA from the meridian (deg) for a
|
|
63
|
+
* sight to be accepted as a noon sight (sea trial 2026-08-31 guard).
|
|
64
|
+
* ~20° ≈ 80 minutes from transit — generous versus the ~30 min a
|
|
65
|
+
* navigator would actually shoot around LAN, but far below the ~100°
|
|
66
|
+
* the trial's 07:05-local sights carried.
|
|
67
|
+
*/
|
|
68
|
+
const NOON_MAX_LHA_DEG = 20;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Maximum computed-vs-assumed latitude discrepancy (deg) accepted from
|
|
72
|
+
* a noon sight before it is refused as implausible (~720 NM).
|
|
73
|
+
*/
|
|
74
|
+
const NOON_MAX_LAT_DELTA_DEG = 12;
|
|
75
|
+
|
|
60
76
|
/**
|
|
61
77
|
* Reduces an ecliptic longitude/latitude to right ascension (degrees),
|
|
62
78
|
* for a given obliquity (degrees).
|
|
@@ -437,6 +453,18 @@ function reduceNoonSight(input) {
|
|
|
437
453
|
const gp = sunGeographicPosition(epochMs);
|
|
438
454
|
const dec = gp.declination_deg;
|
|
439
455
|
|
|
456
|
+
// 1b. Meridian-transit guard (sea trial 2026-08-31): a sight taken
|
|
457
|
+
// hours from local noon reduces to garbage latitudes (both trial
|
|
458
|
+
// sights were morning sights that landed at −69°/−32°). LHA is 0
|
|
459
|
+
// (or 360) exactly on the meridian; refuse sights far from transit.
|
|
460
|
+
const lhaDeg = normalizeDeg360(gp.gha_deg + assumed.longitude);
|
|
461
|
+
const fromMeridianDeg = Math.min(lhaDeg, 360 - lhaDeg);
|
|
462
|
+
if (fromMeridianDeg > NOON_MAX_LHA_DEG) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`Sun is ${fromMeridianDeg.toFixed(0)}° from the meridian (LHA ${lhaDeg.toFixed(1)}°) at the sight time — not a noon sight. Uncheck "Noon sight" for an intercept-method reduction, or verify the sight time`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
440
468
|
// 2. Ho from Hs (same corrections as a normal sight; limb sights apply).
|
|
441
469
|
const sd = 0.2666;
|
|
442
470
|
const semiDiameterDeg =
|
|
@@ -462,6 +490,17 @@ function reduceNoonSight(input) {
|
|
|
462
490
|
const sunSouth = dec < assumed.latitude;
|
|
463
491
|
const latitude = sunSouth ? dec + z : dec - z;
|
|
464
492
|
|
|
493
|
+
// 3b. Computed-vs-assumed sanity (sea trial 2026-08-31): even a sight
|
|
494
|
+
// near transit can be mis-entered (wrong limb, IC, time). A computed
|
|
495
|
+
// latitude a whole ocean away from the assumed/DR position is far
|
|
496
|
+
// more likely a bad sight than a 700 NM DR error — refuse it.
|
|
497
|
+
const latDeltaDeg = Math.abs(latitude - assumed.latitude);
|
|
498
|
+
if (latDeltaDeg > NOON_MAX_LAT_DELTA_DEG) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`Noon sight latitude ${latitude.toFixed(1)}° is ${latDeltaDeg.toFixed(1)}° from the assumed ${assumed.latitude.toFixed(1)}° — check the sight altitude, limb and index correction`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
465
504
|
// Azimuth: 180° (due south) when the Sun is south of the observer,
|
|
466
505
|
// 0° (due north) when it's north. Either yields an east-west LOP.
|
|
467
506
|
const azimuth = sunSouth ? 180 : 0;
|
|
@@ -484,6 +523,8 @@ function reduceNoonSight(input) {
|
|
|
484
523
|
}
|
|
485
524
|
|
|
486
525
|
module.exports = {
|
|
526
|
+
NOON_MAX_LHA_DEG,
|
|
527
|
+
NOON_MAX_LAT_DELTA_DEG,
|
|
487
528
|
raFromEcliptic,
|
|
488
529
|
decFromEcliptic,
|
|
489
530
|
sunGeographicPosition,
|
package/plugin/current.js
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* 1. **Manual override** — watchstander input with a valid TTL
|
|
8
8
|
* (`environment.current`). Not yet wired to an input path; the
|
|
9
9
|
* resolver accepts it so the precedence is explicit and testable.
|
|
10
|
-
* 2.
|
|
10
|
+
* 2. **Derived residual** — EWMA of the boat's own GPS-vs-water-track
|
|
11
|
+
* residual (`derived-current.js`), learned while GPS is trusted and
|
|
12
|
+
* carried forward with decay when GPS degrades. The boat's own
|
|
13
|
+
* observation outranks model products (sea trial 2026-08-30→09-05:
|
|
14
|
+
* ~11 nm DR error over 622 nm vs 47 nm for tier 3).
|
|
11
15
|
* 3. **Signal K Weather API** (`/signalk/v2/api/weather/forecasts/point`)
|
|
12
16
|
* — a provider (e.g. a GRIB another process already downloaded)
|
|
13
17
|
* serves point forecasts carrying `current: {set (rad), drift (m/s)}`.
|
|
@@ -102,13 +106,17 @@ function parseWeatherCurrent(points, nowMs) {
|
|
|
102
106
|
* @param {object} [input]
|
|
103
107
|
* @param {{setTrue: number, drift: number, validUntilMs: number}|null} [input.manual]
|
|
104
108
|
* tier 1: watchstander override, honored while its TTL lasts
|
|
109
|
+
* @param {{setTrue: number, drift: number, validUntilMs: number}|null} [input.derived]
|
|
110
|
+
* tier 2: EWMA of the boat's own GPS-vs-water-track residual
|
|
111
|
+
* (`derived-current.js`), TTL-bounded and decayed while GPS is
|
|
112
|
+
* degraded — the boat's own observation outranks model products
|
|
105
113
|
* @param {{setTrue: number, drift: number, validUntilMs: number}|null} [input.weather]
|
|
106
114
|
* tier 3: Weather API cache entry from `WeatherCurrentClient.currentAt`
|
|
107
115
|
* @param {((ctx: object) => ({setTrue: number, drift: number}|null))|null} [input.pilotLookup]
|
|
108
116
|
* tier 4: (month,lat,lon)→vector lookup into `offline_pilot_currents`
|
|
109
117
|
* @param {object} [input.pilotCtx] - context for the pilot lookup
|
|
110
118
|
* @param {number} [input.nowMs] - epoch ms; defaults to Date.now()
|
|
111
|
-
* @returns {{setTrue: number, drift: number, tier: 1|3|4|5, source: string}}
|
|
119
|
+
* @returns {{setTrue: number, drift: number, tier: 1|2|3|4|5, source: string}}
|
|
112
120
|
* drift in knots, setTrue in deg true (direction the current flows toward)
|
|
113
121
|
*/
|
|
114
122
|
function resolveCurrent(input = {}) {
|
|
@@ -125,6 +133,15 @@ function resolveCurrent(input = {}) {
|
|
|
125
133
|
source: "manual",
|
|
126
134
|
};
|
|
127
135
|
}
|
|
136
|
+
// Tier 2: derived residual — the boat's own GPS-vs-water-track EWMA.
|
|
137
|
+
if (valid(input.derived)) {
|
|
138
|
+
return {
|
|
139
|
+
setTrue: normalizeDeg360(input.derived.setTrue ?? 0),
|
|
140
|
+
drift: input.derived.drift,
|
|
141
|
+
tier: 2,
|
|
142
|
+
source: "derived",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
128
145
|
// Tier 3: Weather API (sparse forecast GRIB via a weather provider).
|
|
129
146
|
if (valid(input.weather)) {
|
|
130
147
|
return {
|
|
@@ -157,34 +174,40 @@ function resolveCurrent(input = {}) {
|
|
|
157
174
|
* 30 min) with a hard timeout, failures keep the previous cache until
|
|
158
175
|
* its TTL lapses, and `currentAt` is a synchronous cache read.
|
|
159
176
|
*
|
|
177
|
+
* The call is **in-process** (mirrors signalk-energy-predictor): the
|
|
178
|
+
* plugin always talks to the Signal K server it runs inside —
|
|
179
|
+
* `app.weatherApi.getForecasts()` is the same WeatherApi instance the
|
|
180
|
+
* `/signalk/v2/api/weather` REST routes wrap — so weather providers
|
|
181
|
+
* (e.g. a GRIB provider) registered by other plugins answer without
|
|
182
|
+
* HTTP, auth tokens or port guessing. The old HTTP loopback defaulted
|
|
183
|
+
* to `localhost:3000`, which on this install is Grafana and 404s every
|
|
184
|
+
* `/signalk/...` path. On servers without the Weather API the client
|
|
185
|
+
* stays idle and the §6.2 resolver never sees tier 3.
|
|
186
|
+
*
|
|
160
187
|
* The response is a WeatherDataModel array; `current.set` is radians
|
|
161
188
|
* and `current.drift` m/s (converted here to deg/kn).
|
|
162
189
|
*/
|
|
163
190
|
class WeatherCurrentClient {
|
|
164
191
|
/**
|
|
165
192
|
* @param {object} [opts]
|
|
166
|
-
* @param {
|
|
193
|
+
* @param {object} [opts.weatherApi] - the server's `app.weatherApi`
|
|
194
|
+
* (needs a `getForecasts` function)
|
|
167
195
|
* @param {number} [opts.intervalMs=1800000] - poll interval
|
|
168
196
|
* @param {number} [opts.count=6] - forecast entries to request
|
|
169
|
-
* @param {number} [opts.timeoutMs=10000] - per-fetch
|
|
197
|
+
* @param {number} [opts.timeoutMs=10000] - per-fetch timeout
|
|
170
198
|
* @param {number} [opts.validityFactor=4] - cache TTL = interval × this
|
|
171
199
|
* @param {() => ({latitude: number, longitude: number}|null)} [opts.getPosition]
|
|
172
200
|
* @param {(message: string) => void} [opts.onStatus]
|
|
173
|
-
* @param {typeof fetch} [opts.fetchFn=globalThis.fetch]
|
|
174
201
|
* @param {() => number} [opts.now=Date.now]
|
|
175
202
|
*/
|
|
176
203
|
constructor(opts = {}) {
|
|
177
|
-
this.
|
|
178
|
-
/\/+$/,
|
|
179
|
-
"",
|
|
180
|
-
);
|
|
204
|
+
this.weatherApi = opts.weatherApi ?? null;
|
|
181
205
|
this.intervalMs = opts.intervalMs ?? 30 * 60 * 1000;
|
|
182
206
|
this.count = opts.count ?? 6;
|
|
183
207
|
this.timeoutMs = opts.timeoutMs ?? 10000;
|
|
184
208
|
this.validityFactor = opts.validityFactor ?? 4;
|
|
185
209
|
this.getPosition = opts.getPosition ?? (() => null);
|
|
186
210
|
this.onStatus = opts.onStatus ?? null;
|
|
187
|
-
this.fetchFn = opts.fetchFn ?? globalThis.fetch?.bind(globalThis);
|
|
188
211
|
this.now = opts.now ?? (() => Date.now());
|
|
189
212
|
/** @type {{setTrue: number, drift: number, fetchedAt: number, validUntilMs: number}|null} */
|
|
190
213
|
this.cache = null;
|
|
@@ -226,48 +249,51 @@ class WeatherCurrentClient {
|
|
|
226
249
|
* @returns {Promise<void>}
|
|
227
250
|
*/
|
|
228
251
|
async poll() {
|
|
229
|
-
if (this.fetching
|
|
252
|
+
if (this.fetching) return;
|
|
253
|
+
if (
|
|
254
|
+
!this.weatherApi ||
|
|
255
|
+
typeof this.weatherApi.getForecasts !== "function"
|
|
256
|
+
) {
|
|
257
|
+
return; // server has no Weather API — tier 3 simply never resolves
|
|
258
|
+
}
|
|
230
259
|
const pos = this.getPosition();
|
|
231
260
|
if (!pos) return; // no position yet — retry on the next interval
|
|
232
261
|
this.fetching = true;
|
|
233
262
|
try {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
const now = this.now();
|
|
259
|
-
this.cache = {
|
|
260
|
-
setTrue: parsed.setTrue,
|
|
261
|
-
drift: parsed.drift,
|
|
262
|
-
fetchedAt: now,
|
|
263
|
-
validUntilMs: now + this.intervalMs * this.validityFactor,
|
|
264
|
-
};
|
|
265
|
-
this.onStatus?.(
|
|
266
|
-
`Weather current: set ${parsed.setTrue.toFixed(0)}° true, drift ${parsed.drift.toFixed(2)} kn`,
|
|
267
|
-
);
|
|
268
|
-
} finally {
|
|
269
|
-
clearTimeout(timeoutId);
|
|
263
|
+
// In-process (same object the REST routes wrap): providers
|
|
264
|
+
// registered by other plugins answer directly. The timeout guards
|
|
265
|
+
// a provider doing its own network fetch under the hood.
|
|
266
|
+
let timeoutId;
|
|
267
|
+
const data = await Promise.race([
|
|
268
|
+
this.weatherApi.getForecasts(
|
|
269
|
+
{ latitude: pos.latitude, longitude: pos.longitude },
|
|
270
|
+
"point",
|
|
271
|
+
{ maxCount: this.count },
|
|
272
|
+
),
|
|
273
|
+
new Promise((_resolve, reject) => {
|
|
274
|
+
timeoutId = setTimeout(
|
|
275
|
+
() => reject(new Error("weather API timed out")),
|
|
276
|
+
this.timeoutMs,
|
|
277
|
+
);
|
|
278
|
+
}),
|
|
279
|
+
]).finally(() => clearTimeout(timeoutId));
|
|
280
|
+
if (!Array.isArray(data)) {
|
|
281
|
+
throw new Error("weather API response is not an array");
|
|
282
|
+
}
|
|
283
|
+
const parsed = parseWeatherCurrent(data, this.now());
|
|
284
|
+
if (!parsed) {
|
|
285
|
+
throw new Error("forecast carries no current data");
|
|
270
286
|
}
|
|
287
|
+
const now = this.now();
|
|
288
|
+
this.cache = {
|
|
289
|
+
setTrue: parsed.setTrue,
|
|
290
|
+
drift: parsed.drift,
|
|
291
|
+
fetchedAt: now,
|
|
292
|
+
validUntilMs: now + this.intervalMs * this.validityFactor,
|
|
293
|
+
};
|
|
294
|
+
this.onStatus?.(
|
|
295
|
+
`Weather current: set ${parsed.setTrue.toFixed(0)}° true, drift ${parsed.drift.toFixed(2)} kn`,
|
|
296
|
+
);
|
|
271
297
|
} catch (err) {
|
|
272
298
|
// Keep any previous cache; the TTL decides when it stops being
|
|
273
299
|
// trusted. Surface the failure once per poll cycle.
|
package/plugin/db.js
CHANGED
|
@@ -88,6 +88,12 @@ const SCHEMA_DDL = [
|
|
|
88
88
|
body_or_object TEXT,
|
|
89
89
|
confirmed_by TEXT,
|
|
90
90
|
used_in_fix_id INTEGER,
|
|
91
|
+
raw_hs_deg REAL,
|
|
92
|
+
index_correction_deg REAL,
|
|
93
|
+
eye_height_m REAL,
|
|
94
|
+
limb TEXT,
|
|
95
|
+
ho_deg REAL,
|
|
96
|
+
hc_deg REAL,
|
|
91
97
|
FOREIGN KEY (used_in_fix_id) REFERENCES fixes(fix_id)
|
|
92
98
|
)`,
|
|
93
99
|
|
|
@@ -102,6 +108,8 @@ const SCHEMA_DDL = [
|
|
|
102
108
|
source_object TEXT,
|
|
103
109
|
confirmed_by TEXT,
|
|
104
110
|
used_in_fix_id INTEGER,
|
|
111
|
+
raw_angle_deg REAL,
|
|
112
|
+
object_height_m REAL,
|
|
105
113
|
FOREIGN KEY (used_in_fix_id) REFERENCES fixes(fix_id)
|
|
106
114
|
)`,
|
|
107
115
|
|
|
@@ -193,20 +201,88 @@ function openDatabase(dbPath) {
|
|
|
193
201
|
for (const stmt of SCHEMA_DDL) {
|
|
194
202
|
db.exec(stmt);
|
|
195
203
|
}
|
|
196
|
-
|
|
197
|
-
// get the column from the DDL above; existing ones are altered in
|
|
198
|
-
// place. The stored version is read *after* the DDL (it creates
|
|
199
|
-
// dr_state_store on a fresh database, where there is nothing to
|
|
200
|
-
// migrate) and *before* the new version is recorded below.
|
|
201
|
-
const storedVersion = getState(db, "schema_version");
|
|
202
|
-
if (storedVersion != null && Number(storedVersion) < 2) {
|
|
203
|
-
db.exec("ALTER TABLE fixes ADD COLUMN derived_from_fix_id INTEGER");
|
|
204
|
-
}
|
|
204
|
+
migrate(db);
|
|
205
205
|
// Record schema version so future migrations can branch on it.
|
|
206
206
|
setState(db, "schema_version", String(SCHEMA_VERSION));
|
|
207
207
|
return db;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Column additions applied via ALTER TABLE, guarded by PRAGMA table_info
|
|
212
|
+
* so each is idempotent. Run unconditionally on open — the sea-trial
|
|
213
|
+
* database carried a schema_version written by a build whose numbering
|
|
214
|
+
* doesn't match this tree's history, so version-gated migrations would
|
|
215
|
+
* silently skip. Each step is cheap (one PRAGMA read).
|
|
216
|
+
*
|
|
217
|
+
* @type {Array<{table: string, column: string, ddl: string}>}
|
|
218
|
+
*/
|
|
219
|
+
const COLUMN_MIGRATIONS = [
|
|
220
|
+
// Schema v2 (running fix): provenance for single-observation running
|
|
221
|
+
// fixes advanced from a previous confirmed fix.
|
|
222
|
+
{
|
|
223
|
+
table: "fixes",
|
|
224
|
+
column: "derived_from_fix_id",
|
|
225
|
+
ddl: "ALTER TABLE fixes ADD COLUMN derived_from_fix_id INTEGER",
|
|
226
|
+
},
|
|
227
|
+
// Schema v2 (sea trial 2026-09-06): persist the raw user-entered sight
|
|
228
|
+
// inputs so reductions can be re-run/backtested without algebraic
|
|
229
|
+
// archaeology.
|
|
230
|
+
{
|
|
231
|
+
table: "lines_of_position",
|
|
232
|
+
column: "raw_hs_deg",
|
|
233
|
+
ddl: "ALTER TABLE lines_of_position ADD COLUMN raw_hs_deg REAL",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
table: "lines_of_position",
|
|
237
|
+
column: "index_correction_deg",
|
|
238
|
+
ddl: "ALTER TABLE lines_of_position ADD COLUMN index_correction_deg REAL",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
table: "lines_of_position",
|
|
242
|
+
column: "eye_height_m",
|
|
243
|
+
ddl: "ALTER TABLE lines_of_position ADD COLUMN eye_height_m REAL",
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
table: "lines_of_position",
|
|
247
|
+
column: "limb",
|
|
248
|
+
ddl: "ALTER TABLE lines_of_position ADD COLUMN limb TEXT",
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
table: "lines_of_position",
|
|
252
|
+
column: "ho_deg",
|
|
253
|
+
ddl: "ALTER TABLE lines_of_position ADD COLUMN ho_deg REAL",
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
table: "lines_of_position",
|
|
257
|
+
column: "hc_deg",
|
|
258
|
+
ddl: "ALTER TABLE lines_of_position ADD COLUMN hc_deg REAL",
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
table: "circular_position_lines",
|
|
262
|
+
column: "raw_angle_deg",
|
|
263
|
+
ddl: "ALTER TABLE circular_position_lines ADD COLUMN raw_angle_deg REAL",
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
table: "circular_position_lines",
|
|
267
|
+
column: "object_height_m",
|
|
268
|
+
ddl: "ALTER TABLE circular_position_lines ADD COLUMN object_height_m REAL",
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Applies pending column migrations.
|
|
274
|
+
*
|
|
275
|
+
* @param {import("node:sqlite").DatabaseSync} db
|
|
276
|
+
* @returns {void}
|
|
277
|
+
*/
|
|
278
|
+
function migrate(db) {
|
|
279
|
+
for (const step of COLUMN_MIGRATIONS) {
|
|
280
|
+
const columns = db.prepare(`PRAGMA table_info(${step.table})`).all();
|
|
281
|
+
const exists = columns.some((c) => c.name === step.column);
|
|
282
|
+
if (!exists) db.exec(step.ddl);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
210
286
|
/**
|
|
211
287
|
* Reads a scalar value from `dr_state_store`.
|
|
212
288
|
*
|
|
@@ -348,8 +424,9 @@ function recordLineOfPosition(db, r) {
|
|
|
348
424
|
const stmt = db.prepare(
|
|
349
425
|
`INSERT INTO lines_of_position (
|
|
350
426
|
timestamp, lop_type, assumed_lat, assumed_lon, azimuth_true,
|
|
351
|
-
intercept_nm, body_or_object, confirmed_by
|
|
352
|
-
|
|
427
|
+
intercept_nm, body_or_object, confirmed_by,
|
|
428
|
+
raw_hs_deg, index_correction_deg, eye_height_m, limb, ho_deg, hc_deg
|
|
429
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
353
430
|
);
|
|
354
431
|
const info = stmt.run(
|
|
355
432
|
r.timestamp,
|
|
@@ -360,6 +437,14 @@ function recordLineOfPosition(db, r) {
|
|
|
360
437
|
r.intercept_nm ?? null,
|
|
361
438
|
r.body_or_object ?? null,
|
|
362
439
|
r.confirmed_by ?? null,
|
|
440
|
+
// Raw user-entered sight inputs (schema v2): so a reduction can be
|
|
441
|
+
// re-run/backtested later without reconstructing Hs from the result.
|
|
442
|
+
r.raw_hs_deg ?? null,
|
|
443
|
+
r.index_correction_deg ?? null,
|
|
444
|
+
r.eye_height_m ?? null,
|
|
445
|
+
r.limb ?? null,
|
|
446
|
+
r.ho_deg ?? null,
|
|
447
|
+
r.hc_deg ?? null,
|
|
363
448
|
);
|
|
364
449
|
return Number(info.lastInsertRowid);
|
|
365
450
|
}
|
|
@@ -386,8 +471,9 @@ function recordCircularPositionLine(db, r) {
|
|
|
386
471
|
const stmt = db.prepare(
|
|
387
472
|
`INSERT INTO circular_position_lines (
|
|
388
473
|
timestamp, cpl_type, center_lat, center_lon, radius_nm,
|
|
389
|
-
radius_uncertainty_nm, source_object, confirmed_by
|
|
390
|
-
|
|
474
|
+
radius_uncertainty_nm, source_object, confirmed_by,
|
|
475
|
+
raw_angle_deg, object_height_m
|
|
476
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
391
477
|
);
|
|
392
478
|
const info = stmt.run(
|
|
393
479
|
r.timestamp,
|
|
@@ -398,6 +484,8 @@ function recordCircularPositionLine(db, r) {
|
|
|
398
484
|
r.radius_uncertainty_nm ?? null,
|
|
399
485
|
r.source_object ?? null,
|
|
400
486
|
r.confirmed_by ?? null,
|
|
487
|
+
r.raw_angle_deg ?? null,
|
|
488
|
+
r.object_height_m ?? null,
|
|
401
489
|
);
|
|
402
490
|
return Number(info.lastInsertRowid);
|
|
403
491
|
}
|