@meri-imperiumi/signalk-dead-reckoning 0.14.2 → 0.15.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 +18 -0
- package/README.md +14 -3
- package/package.json +2 -2
- package/plugin/db.js +85 -0
- package/plugin/engine.js +6 -0
- package/plugin/fix-pipeline.js +13 -2
- package/plugin/index.js +144 -9
- package/plugin/plotterext.js +400 -11
- package/public/dr-current-panel.js +4 -0
- package/public/dr-current-widget.html +29 -0
- package/public/dr-current-widget.js +152 -0
- package/public/dr-ext-chartwork.js +781 -0
- package/public/dr-ext-host-core.js +346 -4
- package/public/dr-ext-host-overlays.js +21 -11
- package/public/dr-ext-host-windows.js +1027 -0
- package/public/dr-ext-host.js +80 -31
- package/public/dr-ext-model.js +34 -0
- package/public/dr-ext-overlay-style.json +215 -0
- package/public/dr-ext-panel.html +143 -0
- package/public/dr-ext-panel.js +370 -0
- package/public/dr-ext-runtime.html +10 -0
- package/public/dr-ext-runtime.js +654 -0
- package/public/dr-fix-panel.js +5 -0
- package/public/dr-pending-list.js +108 -32
- package/public/dr-sight-panel.js +93 -4
- package/public/dr-viewmodel.js +30 -0
- package/tests/db.test.js +42 -0
- package/tests/dr-app-layout.test.js +3 -1
- package/tests/dr-ext-chartwork.test.js +958 -0
- package/tests/dr-ext-host-windows.test.js +808 -0
- package/tests/dr-ext-model.test.js +41 -0
- package/tests/dr-plotter-map.test.js +8 -8
- package/tests/dr-viewmodel.test.js +29 -0
- package/tests/fix-pipeline.test.js +19 -0
- package/tests/plotterext.test.js +46 -3
- package/tests/plugin.test.js +3 -1
- package/tests/public-syntax.test.js +55 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.15.0] - 2026-10-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Current set & drift in the plotter** (work doc #38 review): the manual current override — the last webapp-only surface that mattered — now lives in Freeboard too. A **Current set & drift window** (`?view=current` mount of the extension panel) embeds the webapp's `<dr-current-panel>` verbatim: the resolved §6.2 vector with its source and TTL, a manual set/drift + TTL form (PUT `/current/manual`), and clear (DELETE) — reached from an always-visible *Current set & drift* toolbar button or by tapping the new **Current set & drift widget**, a 1×1 tile showing the resolved vector and the winning source by name (Manual / Weather API / Pilot chart / Derived / None — the source enrichment rides REST `/status`; the standard SK current paths carry only the SI vector), accenting orange while a manual override is active and opening the window on tap. Named "Current set & drift" rather than "DR current" on purpose: the widget shows the resolved hierarchy winner whatever source it came from.
|
|
14
|
+
- **The sanity cap grows with the origin's age** (work doc #38 review, from sea-trial data: a DR origin set in the Cook Islands in August vetoed a legitimate fix in Tonga — 806 NM, the exact crossing — because the cap grew only with *underway* hours, which stay zero on a test server that mostly sits at anchor and restarts; the state even showed the elapsed counters reset to zero by the restart's restore-snap). The engine now tracks **when the origin was set** (`engine.originSetMs`, persisted as `dr_origin_set_ms`, restored across restarts with a fallback to the newest confirmed fix's timestamp for pre-existing installs), and the sanity cap relaxes **linearly with the origin's age** — `base × ageDays` joins `base` and `1.5 NM/underway-hour` as terms — so a month-old origin no longer vetoes the fix that re-establishes truth, while a fresh origin still catches teleports at the flat cap. From the same sea trial: a **Confirm GNSS fix** toolbar button (shown while a GNSS position is available) confirms the latest GNSS position as a fix and snaps the DR origin to truth — the bootstrap a first-time user needs before sight fixes can pass the cap at all.
|
|
15
|
+
- **Fixes while stationary: the running-fix advance no longer needs a DR track at anchor or moored** (work doc #38 review — the first-try scenario is exactly an anchored boat). While the engine freezes displacement (`navigation.state` anchored/moored), zero displacement is correct by construction — the anchor holds the ground position and the DR engine deliberately freezes with it — so the plugin now records **stationary spans** (new `dr_stationary_spans` table, opened when the engine freezes, closed when it resumes, persisted at the transition so a hard kill can't lose an edge) and the fix pipeline's advancement accepts any interval fully inside a span with zero displacement, no track samples required. Fixes taken at anchor now resolve and survive restarts and track gaps; a weighed-anchor edge closes the span, so moving time is never mistaken for stationary. Also carries the advancement diagnostics in the preview response (`track_coverage`: where the DR track actually reaches, and whether the engine is holding) so the pending list's "could not be advanced" warning can say why.
|
|
16
|
+
- **Dead Reckoning in the plotter: the plugin drives Freeboard-SK as a plotter extension (work doc #38).** The whole at-sea loop — mark a sight, see its position line on the chart, accumulate, resolve, confirm, snap DR — now runs inside Freeboard-SK without opening the standalone webapp. The extension manifest grows from the status tile to the full surface, reusing the webapp's own components verbatim: a **Sight / Fix panel** (`keepAlive`) that embeds `<dr-sight-panel>` and `<dr-fix-panel>` (all their same-origin REST calls, edit and running-fix advancement included), opened as a **floating window** from a new *DR Sight / Fix* toolbar button (drawer fallback on hosts without `windows`); a **Pending observations window** split out onto its own panel (the same page mounted `?view=pending`), whose *DR Pending observations* toolbar button appears only while unresolved observations are actually pending (the list's own 24 h freshness rule) — the API has no button badges, so the live count rides the open window's title ("DR Pending observations (3)", updated by the runtime on every data refresh); deletion there works by two-tap confirm (arm → red *Sure?* → delete), since extension iframes are sandboxed without `allow-modals` and a `confirm()`-guarded delete silently never fires; a **background runtime** that keeps the plotter in step with the engine without any panel open; a **DR chartwork data overlay** (`overlays.data`, Freeboard ≥ 3.4) rendering the ghost track with its dark casing, uncertainty circle, LoPs (bearing rays from the charted object, celestial position lines), CPL arcs aimed at the live DR position, confirmed fixes colored per source type, snap-to-fix vectors, the candidate-fix preview ring and the DR position marker — from the same REST rows and stream paths the webapp consumes, with stable feature ids (`lop:<id>`…) so used-observations recolor, `overlay.setData` upserts stay cheap, and LoPs/CPLs/fixes are selectable (`titleProperty: name`, the webapp's tooltip convention) with an *Edit this observation* action; **chart-object sight marking** — four context-menu actions (bearing, vertical angle, radar range, horizontal angle) on positions, chart features, waypoints and AtoNs that open the panel with the object's position and charted name pre-seeded, plus a *⊙ Pick on chart* button in the panel using `map.pick`; **OVERRIDE engage/release as visibility-switched toolbar buttons** (`buttons.visibility`) mirroring `engine.active`, publishing to the runtime which PUTs the same REST endpoint (visibility learned the hard way to speak the method's one-`{ids, visible}`-object-per-call shape — a batched array is `params must be object`, and every state silently never applied; buttons and actions are separate surfaces with separate id namespaces); and **night-mode recoloring** of the chartwork via `overlay.setLayer` (warm low-blue palette, halo included). The panel wrapper seeds the components exactly as the webapp does — default position (DR first, GPS fallback), magnetic variation, GNSS quality, position format from the plugin configuration, celestial body list — and bridges their events to the runtime over extension-scoped topics (`observations-changed`, `candidate`), with a 10 s REST poll as the fallback sync so chartwork stays honest even when the change comes from the standalone webapp or another station. Overlay pushes **diff against what the host already holds** (`overlay.setData` `upsert`/`remove`) — a full-source replacement every DR tick churned the host's vector source hard enough for chartwork to flicker out of view through zoom gestures; and the pending list's candidate event reaches the runtime as the candidate itself, so the chart preview ring and the confirm button appear the moment a preview resolves. The chart-driven loop is completed by **confirmation surfaced the way OVERRIDE is**: a *Confirm DR fix* toolbar button (`buttons.visibility`) that appears while a resolved candidate is pending — and the candidate ring itself confirms with a tap (a `default` overlayFeature action), with *Resolve fix from this* offered on unresolved LoPs/CPLs (right-click / popover) when a previous confirmed fix exists to advance from, plus *Delete this observation* on the same toolbar, so a running fix never requires opening a window at all; the **candidate ring is the loud, clickable preview** — enlarged red ring with a center dot (the traditional chartwork fix mark, and the pixel hit target: a fully transparent ring interior is not hit-testable, so a click on the ring's middle used to miss), and a primary select opens the fix-resolution window — with the runtime reacting to the invocation itself as a fallback, since the host's own window path must never leave a chart click dead; a preview that cannot resolve now guides instead of verdicting — one sight alone explains the running-fix/pairing paths, crossing failures name parallel lines and missed circles (the confirm path shares the resolve path's displacement provider, so a span-covered preview confirms identically); chart-driven actions that fail — a confirm the server rejected, a resolve without coverage, a press with nothing pending — are **drawn on the chart itself** as a transient red note at the DR position (eight seconds), because a failure only an open panel can see is not a surfaced failure; the pending window stands tall like the sight windows; chart-driven actions the server rejects — a running fix without a previous fix — surface as a calm amber note in the extension panels instead of a silent console line (red stays for real failures), and the running-fix gate follows the server's own all-time-latest-fix rule rather than the display window. Sight actions open **focused single-form windows**: the four chart-object actions open tall (`90%`) windows reusing the open Sight/Fix window (`single`, per-mode `restoreKey`) pinned to just that method's form — a new `single-tab` attribute on `<dr-sight-panel>` drops the tab strip and the wrapper hides the pending list and fix-at section — instead of a small window full of unrelated chrome. Chartwork fetches carry the **same history window as the webapp** (max 7 days, anchored at the trip boundary from `/status`), so months-old position lines don't clutter the plotter. Chartwork geometry is **clipped to the view**: the runtime tracks the chart viewport (`map.getView` + settled `map.view` events) and lines, arcs, the ghost track and the uncertainty circle are clipped to the inflated view bounds before every push — a 60 nm LoP spans ±46,500 canvas pixels at zoom 16, past the ~32,767 px precision range where canvas paths silently break (the circle-shaped candidate kept rendering while the lines vanished, which pinned it to the line builder), so long chartwork now re-clips as the view moves and stays drawn at every zoom. The clip's first cut also *folded* any line whose far end lay past the box's frame edge — the vertex unwrap is sequential relative to the previous vertex (the webapp's own antimeridian pattern), pinned by a bearing-collinearity regression. Inside host windows the components' own heading rows (title + close ✕) hide behind a new `embedded` attribute — Freeboard supplies both decorations — and the extension asset route serves code with `Cache-Control: no-cache` plus `ETag`/`Last-Modified` revalidation (images keep their lifetime): the manifest updates instantly via the resources API, and a cached hour-old iframe once wrapped that fresh manifest around stale code. A new parse smoketest (`vm.SourceTextModule` over every shipped `public/*.js`, armed by `--experimental-vm-modules` in the npm test script) keeps that other classic zero-build failure — a Markdown backtick inside a template literal killing a whole component — out of the tree; it exists because exactly that shipped in this entry's first cut. Everything beyond Freeboard 3.0's surface is capability-gated (`hasCapability`): older hosts simply offer fewer contributions, the tile everywhere, the full experience on 3.4+. Geometry and conventions live in the pure, Node-tested `public/dr-ext-chartwork.js` (24 new tests; suite 891), the style in `public/dr-ext-overlay-style.json`, the runtime in `dr-ext-runtime.js`, the panel adapter in `dr-ext-panel.js`.
|
|
17
|
+
- **Plotter-extension floating windows: the host implements the `windows` capability** (work doc #27 window phase; spec: freeboard-sk plotter-extensions-api *Windows*). An extension's iframe panels can now open as floating windows over the chart — several at once, each its own bus context of kind `window` carrying `windowId` and the `params` it was opened with in its handshake, plus the five `ui.*Window` methods (`openWindow`/`updateWindow`/`focusWindow`/`closeWindow`/`listWindows`) and the `window.bounds` / `window.state` / `window.closed` events delivered to the owning extension's subscribed contexts. Windows belong to the extension (any of its contexts manages them; in a window context the windowId may be omitted to mean the window itself, so a window can close itself); the host owns the chrome — a translucent title bar with the window title and a close control the extension cannot remove (an en dash that hides under `userClose: "hide"`, a cross that closes), pointer-drag moving and south-east-handle resizing with `window.bounds` published once per settled change, raise-on-interaction, and a host window limit (`windows.limit`) of six. Geometry speaks the API's anchored form (nine anchors, inward offsets, px or % sizes, min/max limits, host floor 160×120): requests resolve against the window area (the full app viewport), the host clamps and reports the truth in `bounds`, and `restoreKey` remembers the settled geometry per extension and device in `dr.plotterext.windows.v1` — the user drags the sounder somewhere once, it opens there from then on. Hidden windows (`visible: false`) stay loaded and running with no host control over them; modal windows (`modal: true`) take a backdrop above every other window with Escape always dismissing (one at a time, `windows.modalOpen`), and at the ≤600px phone breakpoint windows present `fullscreen` instead of floating, re-evaluated on resize with bounds re-clamped and the changes published. Toolbar buttons gain the `openWindow` / `toggleWindow` actions (the button tracks the window it opened: press again shows and raises; `toggleWindow` does what the window's own close control does). Errors: `windows.badRequest`, `windows.unknownId`, `windows.limit`, `windows.modalOpen`, `UNKNOWN_PANEL`. An extension leaving the collection loses its windows silently, and its remembered geometry goes with it. New pure geometry/policy in `dr-ext-host-core.js` (parse/resolve/clamp, presentation breakpoint, geometry store), chrome and bus glue in `dr-ext-host-windows.js`; 25 new tests.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **The sight forms carry their ⊙ Pick button beside the coordinate inputs** (the standalone header button is gone): with the `pick` attribute, each coordinate fieldset's legend gains a small pick control that asks the host for a chart pick and lands the result in that fieldset — the horizontal-angle form's pick fills its own A/B/C slot. **The pending window stands tall** via a bumped restore key (the remembered geometry of the first, shorter open would otherwise defeat every later height request).
|
|
21
|
+
- **The pending-observations list previews automatically as the selection changes** — the *Preview selected* button is gone. Resolving a candidate is cheap, local and reversible (no rows are written), so it now rides the selection: debounced ~600 ms after the last checkbox toggle, sequence-guarded so a stale response can't overwrite a newer candidate, and the preview ring on the chart stays put while the selection still spans it. The deliberate step stays deliberate: *Confirm fix* is the human confirmation that writes the fix and snaps DR (SPEC §9.1). The "could not be advanced" warning now says why — the DR track's actual coverage window, with clock skew and mid-passage restarts called out as the likely culprits.
|
|
22
|
+
- **Panel `whileEnabled` is no longer honored — upstream dropped it** (freeboard-sk #921): a panel loads when first opened (`onOpen`, the default) and dies on close; only `keepAlive` parks. The host no longer preloads `whileEnabled` panels inside closed dialogs — work that must run while unseen belongs in a background runtime (`background.iframe`), which the host has offered since the chart-layer phase.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **Stale observations no longer clog the pending-observations list.** Un-resolved LOPs/CPLs sat in the pending drawer forever no matter their age — a 36-day-old bearing from a port stay kept showing as work in progress. The list now drops un-resolved observations older than 24 h (`vm.isPendingStale`, `PENDING_MAX_AGE_MS`): a fix is a here-and-now operation — the running fix advances each observation to the current DR position — so anything older is chart history, not a constraint waiting for a partner. Chart/history surfaces are unaffected.
|
|
26
|
+
- **Windows CI: source-structure tests no longer fail on CRLF checkouts.** The map/layout smoketests assert on `public/*.js` source structure with newline-sensitive regexes, which never matched once git's `autocrlf` converted the files to CRLF on a Windows runner. Both test readers now normalize CRLF → LF before matching.
|
|
27
|
+
|
|
10
28
|
## [0.14.2] - 2026-10-19
|
|
11
29
|
|
|
12
30
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Signal K Dead Reckoning
|
|
2
2
|
|
|
3
|
-
An offline-first dead reckoning and sensor fusion engine for Signal K that maintains a continuously computed "shadow boat" position from water-track sensors (speed through water, compass heading, and learned leeway and current corrections), so you always have a navigational fallback when GPS becomes unreliable — whether from jamming, spoofing, or plain receiver failure. While GPS is trusted, the engine learns vessel-specific calibration corrections against ground truth and watches for GPS anomalies; when it isn't, the same learned model keeps the dead-reckoned position, its uncertainty polygon, and a water-track log going. Fixes from celestial sights, compass bearings, radar ranges, vertical angles, and horizontal sextant angles are entered through a unified pipeline and can snap dead reckoning back on track, with optional write-through to the vessel's logbook (any Signal K `logentries` resource provider, such as `signalk-logbook`).
|
|
3
|
+
An offline-first dead reckoning and sensor fusion engine for Signal K that maintains a continuously computed "shadow boat" position from water-track sensors (speed through water, compass heading, and learned leeway and current corrections), so you always have a navigational fallback when GPS becomes unreliable — whether from jamming, spoofing, or plain receiver failure. While GPS is trusted, the engine learns vessel-specific calibration corrections against ground truth and watches for GPS anomalies; when it isn't, the same learned model keeps the dead-reckoned position, its uncertainty polygon, and a water-track log going. Fixes from celestial sights, compass bearings, radar ranges, vertical angles, and horizontal sextant angles are entered through a unified pipeline and can snap dead reckoning back on track, with optional write-through to the vessel's logbook (any Signal K `logentries` resource provider, such as `signalk-logbook`). The whole toolset — sight entry, fix resolution, chartwork, override control — also runs inside [Freeboard-SK](https://github.com/SignalK/freeboard-sk) as a plotter extension, so you can work without leaving the plotter.
|
|
4
4
|
|
|
5
5
|
**Note:** This is just a toy. Make your own navigation calculations and decisions.
|
|
6
6
|
|
|
@@ -31,9 +31,20 @@ Planning for life without GPS is no longer hypothetical: the UK Maritime and Coa
|
|
|
31
31
|
|
|
32
32
|
## Daily use
|
|
33
33
|
|
|
34
|
-
Open the webapp from your Signal K server's *Web apps* menu. It shows the vessel and the shadow boat on the chart, GPS↔DR divergence, the uncertainty polygon, and the water-track log.
|
|
34
|
+
Open the webapp from your Signal K server's *Web apps* menu. It shows the vessel and the shadow boat on the chart, GPS↔DR divergence, the uncertainty polygon, and the water-track log. The same tools are available inside Freeboard-SK — see *In Freeboard-SK* below.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
### In Freeboard-SK
|
|
37
|
+
|
|
38
|
+
With this plugin installed and enabled, [Freeboard-SK](https://github.com/SignalK/freeboard-sk) discovers it as a plotter extension — no extra configuration. What you get depends on the Freeboard version:
|
|
39
|
+
|
|
40
|
+
* **any version** — the shadow boat itself: enable *Show the DR shadow boat on chart plotters* in the plugin configuration and the DR position is published as a synthetic vessel (labelled *DR Shadow* by default), so the plotter renders it alongside your own vessel with a projected COG line — on Freeboard-SK it shows as a green buddy target
|
|
41
|
+
* **≥ 3.0** — a *Dead Reckoning* status tile for the widget grid: GPS↔DR divergence, uncertainty radius and DR health at a glance (place it by pressing and holding an empty widget spot)
|
|
42
|
+
* **≥ 3.3** — a *DR Sight / Fix* floating window with the full sight entry, pending-observations list and fix resolution, opened from a toolbar button (or a drawer on small screens), plus *Engage/Release DR override* toolbar buttons that follow the engine state
|
|
43
|
+
* **≥ 3.4** — the *DR chartwork* map layer, drawn by Freeboard itself above routes and waypoints but never covering your own vessel, AIS targets or alarms: the DR ghost track, uncertainty circle, lines and circles of position, confirmed fixes, snap-to-fix vectors and the live DR position. It can be shown, hidden and faded in the chart list's *Plugin layers*, follows night mode, and comes with chart-driven sight entry — right-click a charted object for *Bearing sight of this*, *Radar range to this* and friends (coordinates and the object's charted name pre-filled), and right-click an unresolved position line to edit it
|
|
44
|
+
|
|
45
|
+
The standalone webapp stays unchanged — both front ends read the same engine and can be used side by side.
|
|
46
|
+
|
|
47
|
+
**Entering fixes.** Open *⊕ Sight / LOP* in the webapp, or the *DR Sight / Fix* window in Freeboard, and pick the method:
|
|
37
48
|
|
|
38
49
|
* **Bearing** — bearing to a known object (lighthouse, tower) gives a line of position; the instrument selector records whether it came from a hand-bearing compass or the radar
|
|
39
50
|
* **Vert. Angle** — vertical angle to a known object of known height gives a circular position line
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meri-imperiumi/signalk-dead-reckoning",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"format": "npx @biomejs/biome check --use-editorconfig=true --write plugin/ tests/ public/",
|
|
8
8
|
"lint": "npx @biomejs/biome check --use-editorconfig=true plugin/ public/",
|
|
9
|
-
"test": "node --test --test-force-exit \"tests/*.test.js\""
|
|
9
|
+
"test": "node --test --test-force-exit --experimental-vm-modules \"tests/*.test.js\""
|
|
10
10
|
},
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
package/plugin/db.js
CHANGED
|
@@ -185,6 +185,19 @@ const SCHEMA_DDL = [
|
|
|
185
185
|
latitude REAL NOT NULL,
|
|
186
186
|
longitude REAL NOT NULL
|
|
187
187
|
)`,
|
|
188
|
+
|
|
189
|
+
// Fixes while stationary (work doc #38 review): while the engine
|
|
190
|
+
// freezes displacement (anchored/moored), a running-fix advance
|
|
191
|
+
// needs no track at all — zero displacement is correct by
|
|
192
|
+
// construction (the anchor holds the ground position and the DR
|
|
193
|
+
// engine deliberately freezes with it). Persisting the stationary
|
|
194
|
+
// spans keeps that true across restarts and track gaps — the
|
|
195
|
+
// first-try scenario is exactly an anchored boat. An open span
|
|
196
|
+
// (ended_ms NULL) is the current one.
|
|
197
|
+
`CREATE TABLE IF NOT EXISTS dr_stationary_spans (
|
|
198
|
+
started_ms INTEGER PRIMARY KEY,
|
|
199
|
+
ended_ms INTEGER
|
|
200
|
+
)`,
|
|
188
201
|
];
|
|
189
202
|
|
|
190
203
|
/**
|
|
@@ -1155,6 +1168,74 @@ function loadTrackSamplesSince(db, sinceMs) {
|
|
|
1155
1168
|
}));
|
|
1156
1169
|
}
|
|
1157
1170
|
|
|
1171
|
+
/**
|
|
1172
|
+
* Opens a stationary span (the engine froze displacement — anchored or
|
|
1173
|
+
* moored). Idempotent on the start timestamp; a span open since the
|
|
1174
|
+
* same instant is left alone.
|
|
1175
|
+
*
|
|
1176
|
+
* @param {import("node:sqlite").DatabaseSync|null} db
|
|
1177
|
+
* @param {number} startedMs
|
|
1178
|
+
* @returns {void}
|
|
1179
|
+
*/
|
|
1180
|
+
function openStationarySpan(db, startedMs) {
|
|
1181
|
+
if (!db) return;
|
|
1182
|
+
db.prepare(
|
|
1183
|
+
"INSERT OR IGNORE INTO dr_stationary_spans (started_ms, ended_ms) VALUES (?, NULL)",
|
|
1184
|
+
).run(startedMs);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Closes the open stationary span at `endedMs` — the engine resumed
|
|
1189
|
+
* displacement (underway again).
|
|
1190
|
+
*
|
|
1191
|
+
* @param {import("node:sqlite").DatabaseSync|null} db
|
|
1192
|
+
* @param {number} startedMs
|
|
1193
|
+
* @param {number} endedMs
|
|
1194
|
+
* @returns {void}
|
|
1195
|
+
*/
|
|
1196
|
+
function closeStationarySpan(db, startedMs, endedMs) {
|
|
1197
|
+
if (!db) return;
|
|
1198
|
+
db.prepare(
|
|
1199
|
+
"UPDATE dr_stationary_spans SET ended_ms = ? WHERE started_ms = ? AND ended_ms IS NULL",
|
|
1200
|
+
).run(endedMs, startedMs);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Loads stationary spans overlapping `sinceMs`, oldest first. Open
|
|
1205
|
+
* spans (ended_ms NULL) are the engine's current one.
|
|
1206
|
+
*
|
|
1207
|
+
* @param {import("node:sqlite").DatabaseSync|null} db
|
|
1208
|
+
* @param {number} sinceMs
|
|
1209
|
+
* @returns {{started_ms: number, ended_ms: number|null}[]}
|
|
1210
|
+
*/
|
|
1211
|
+
function loadStationarySpans(db, sinceMs) {
|
|
1212
|
+
if (!db) return [];
|
|
1213
|
+
return db
|
|
1214
|
+
.prepare(
|
|
1215
|
+
"SELECT started_ms, ended_ms FROM dr_stationary_spans WHERE COALESCE(ended_ms, started_ms) >= ? ORDER BY started_ms",
|
|
1216
|
+
)
|
|
1217
|
+
.all(sinceMs)
|
|
1218
|
+
.map((r) => ({
|
|
1219
|
+
started_ms: Number(r.started_ms),
|
|
1220
|
+
ended_ms: r.ended_ms == null ? null : Number(r.ended_ms),
|
|
1221
|
+
}));
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Prunes *closed* stationary spans older than the cutoff. Open spans
|
|
1226
|
+
* are never pruned — they are the engine's present state.
|
|
1227
|
+
*
|
|
1228
|
+
* @param {import("node:sqlite").DatabaseSync|null} db
|
|
1229
|
+
* @param {number} cutoffMs
|
|
1230
|
+
* @returns {void}
|
|
1231
|
+
*/
|
|
1232
|
+
function pruneStationarySpansBefore(db, cutoffMs) {
|
|
1233
|
+
if (!db) return;
|
|
1234
|
+
db.prepare(
|
|
1235
|
+
"DELETE FROM dr_stationary_spans WHERE ended_ms IS NOT NULL AND ended_ms < ?",
|
|
1236
|
+
).run(cutoffMs);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1158
1239
|
module.exports = {
|
|
1159
1240
|
SCHEMA_VERSION,
|
|
1160
1241
|
SCHEMA_DDL,
|
|
@@ -1172,6 +1253,10 @@ module.exports = {
|
|
|
1172
1253
|
recordTrackSamples,
|
|
1173
1254
|
pruneTrackSamplesBefore,
|
|
1174
1255
|
loadTrackSamplesSince,
|
|
1256
|
+
openStationarySpan,
|
|
1257
|
+
closeStationarySpan,
|
|
1258
|
+
loadStationarySpans,
|
|
1259
|
+
pruneStationarySpansBefore,
|
|
1175
1260
|
listLogbookPending,
|
|
1176
1261
|
dequeueLogbookPending,
|
|
1177
1262
|
listFixes,
|
package/plugin/engine.js
CHANGED
|
@@ -59,6 +59,11 @@ class DeadReckoningEngine {
|
|
|
59
59
|
constructor(opts = {}) {
|
|
60
60
|
/** @type {{latitude: number, longitude: number}|null} */
|
|
61
61
|
this.origin = opts.origin ?? null;
|
|
62
|
+
/** @type {number|null} ms epoch of when the origin was set — the
|
|
63
|
+
* sanity cap's age term (a months-old origin must not veto a real
|
|
64
|
+
* fix with fresh sightwork). Null when the origin's age is unknown;
|
|
65
|
+
* persisted and restored across restarts. */
|
|
66
|
+
this.originSetMs = opts.originSetMs ?? null;
|
|
62
67
|
/** @type {number} */
|
|
63
68
|
this.logNm = opts.logNm ?? 0;
|
|
64
69
|
/** @type {number} */
|
|
@@ -96,6 +101,7 @@ class DeadReckoningEngine {
|
|
|
96
101
|
*/
|
|
97
102
|
snapToFix(fix, originErrorNm = 0) {
|
|
98
103
|
this.origin = { latitude: fix.latitude, longitude: fix.longitude };
|
|
104
|
+
this.originSetMs = Date.now();
|
|
99
105
|
this.elapsedSinceOriginS = 0;
|
|
100
106
|
this.underwaySinceOriginS = 0;
|
|
101
107
|
this.logNmSinceOrigin = 0;
|
package/plugin/fix-pipeline.js
CHANGED
|
@@ -274,11 +274,22 @@ function advanceToLatest(observations, advance) {
|
|
|
274
274
|
* origin was set (integration time)
|
|
275
275
|
* @returns {number} cap in nm
|
|
276
276
|
*/
|
|
277
|
-
function fixSanityCapNm(
|
|
277
|
+
function fixSanityCapNm(
|
|
278
|
+
maxDisplacementNm,
|
|
279
|
+
underwaySinceOriginS,
|
|
280
|
+
originAgeMs = 0,
|
|
281
|
+
) {
|
|
278
282
|
const hours = Number.isFinite(underwaySinceOriginS)
|
|
279
283
|
? Math.max(0, underwaySinceOriginS) / 3600
|
|
280
284
|
: 0;
|
|
281
|
-
|
|
285
|
+
// Age term (work doc #38 review): the cap must grow with the
|
|
286
|
+
// origin's *age*, not just under-way hours — an origin weeks old
|
|
287
|
+
// (a test server, a season's layup, a plugin down for passage) has
|
|
288
|
+
// no authority over where the boat is now, and must not veto the
|
|
289
|
+
// fix that re-establishes truth. Linear in days: a month old
|
|
290
|
+
// relaxes the cap to 30× the base.
|
|
291
|
+
const ageDays = Math.max(0, originAgeMs) / 86400000;
|
|
292
|
+
return Math.max(maxDisplacementNm, 1.5 * hours, maxDisplacementNm * ageDays);
|
|
282
293
|
}
|
|
283
294
|
|
|
284
295
|
function resolveCandidateFix(input) {
|
package/plugin/index.js
CHANGED
|
@@ -42,6 +42,10 @@ const {
|
|
|
42
42
|
recordTrackSamples,
|
|
43
43
|
pruneTrackSamplesBefore,
|
|
44
44
|
loadTrackSamplesSince,
|
|
45
|
+
openStationarySpan,
|
|
46
|
+
closeStationarySpan,
|
|
47
|
+
loadStationarySpans,
|
|
48
|
+
pruneStationarySpansBefore,
|
|
45
49
|
} = require("./db.js");
|
|
46
50
|
const { MatrixStore } = require("./matrix.js");
|
|
47
51
|
const { DeadReckoningEngine } = require("./engine.js");
|
|
@@ -442,6 +446,10 @@ const deps = {
|
|
|
442
446
|
recordTrackSamples,
|
|
443
447
|
pruneTrackSamplesBefore,
|
|
444
448
|
loadTrackSamplesSince,
|
|
449
|
+
openStationarySpan,
|
|
450
|
+
closeStationarySpan,
|
|
451
|
+
loadStationarySpans,
|
|
452
|
+
pruneStationarySpansBefore,
|
|
445
453
|
listFixes,
|
|
446
454
|
listLinesOfPosition,
|
|
447
455
|
listCircularPositionLines,
|
|
@@ -510,6 +518,16 @@ module.exports = (app) => {
|
|
|
510
518
|
*/
|
|
511
519
|
let trackFlushedTs = 0;
|
|
512
520
|
|
|
521
|
+
/** Stationary spans (fixes while stationary, work doc #38 review):
|
|
522
|
+
* while the engine freezes displacement (anchored/moored), a
|
|
523
|
+
* running-fix advance needs no track — zero displacement is correct
|
|
524
|
+
* by construction. Spans persist so the stationary case survives
|
|
525
|
+
* restarts and track gaps; `stationarySince` is the open span's
|
|
526
|
+
* start (null when moving). @type {number|null} */
|
|
527
|
+
let stationarySince = null;
|
|
528
|
+
/** @type {{started_ms: number, ended_ms: number|null}[]} */
|
|
529
|
+
let stationarySpans = [];
|
|
530
|
+
|
|
513
531
|
/** @type {WeatherCurrentClient|null} §6.2 tier-3 weather current poller */
|
|
514
532
|
let weatherClient = null;
|
|
515
533
|
|
|
@@ -984,6 +1002,16 @@ module.exports = (app) => {
|
|
|
984
1002
|
? groundTrack.samples[groundTrack.samples.length - 1].timestamp
|
|
985
1003
|
: seedTs;
|
|
986
1004
|
|
|
1005
|
+
// Stationary spans (fixes while stationary): restore the recent
|
|
1006
|
+
// spans; an open one stays open — the engine froze displacement
|
|
1007
|
+
// before the restart and (until it says otherwise) still holds.
|
|
1008
|
+
stationarySpans = deps.loadStationarySpans(
|
|
1009
|
+
db,
|
|
1010
|
+
Date.now() - 8 * 24 * 3600 * 1000,
|
|
1011
|
+
);
|
|
1012
|
+
stationarySince =
|
|
1013
|
+
stationarySpans.find((s) => s.ended_ms == null)?.started_ms ?? null;
|
|
1014
|
+
|
|
987
1015
|
// §6.2 tier 3: poll the Signal K Weather API for the point-forecast
|
|
988
1016
|
// current at the vessel position (a current-capable provider such as
|
|
989
1017
|
// signalk-weather-router-plus). Off the 1 Hz hot path: slow interval,
|
|
@@ -1045,6 +1073,25 @@ module.exports = (app) => {
|
|
|
1045
1073
|
const fix = JSON.parse(lastFix);
|
|
1046
1074
|
if (fix && fix.latitude != null && fix.longitude != null) {
|
|
1047
1075
|
engine.snapToFix(fix);
|
|
1076
|
+
// The origin's set-time survives restarts too: the sanity
|
|
1077
|
+
// cap's age term must see the REAL age (a months-old
|
|
1078
|
+
// origin relaxes), not a fresh stamp from this restart.
|
|
1079
|
+
// Falls back to the newest confirmed fix's timestamp when
|
|
1080
|
+
// the key predates this feature.
|
|
1081
|
+
const persistedSetMs = Number(
|
|
1082
|
+
deps.getState(db, "dr_origin_set_ms"),
|
|
1083
|
+
);
|
|
1084
|
+
const latestFix = deps.listFixes(db, { limit: 1 })[0];
|
|
1085
|
+
const fallbackMs = latestFix
|
|
1086
|
+
? Date.parse(latestFix.timestamp)
|
|
1087
|
+
: NaN;
|
|
1088
|
+
const setMs =
|
|
1089
|
+
Number.isFinite(persistedSetMs) && persistedSetMs > 0
|
|
1090
|
+
? persistedSetMs
|
|
1091
|
+
: Number.isFinite(fallbackMs)
|
|
1092
|
+
? fallbackMs
|
|
1093
|
+
: null;
|
|
1094
|
+
if (setMs != null) engine.originSetMs = setMs;
|
|
1048
1095
|
}
|
|
1049
1096
|
} catch {
|
|
1050
1097
|
// Corrupt persisted state — start from scratch, the next GPS fix
|
|
@@ -1541,6 +1588,23 @@ module.exports = (app) => {
|
|
|
1541
1588
|
config.tickIntervalMs / 1000,
|
|
1542
1589
|
config.tripBoundary,
|
|
1543
1590
|
);
|
|
1591
|
+
|
|
1592
|
+
// Stationary spans (fixes while stationary, work doc #38 review):
|
|
1593
|
+
// record the anchored/moored stretches — while the engine freezes
|
|
1594
|
+
// displacement, a running-fix advance is zero by construction and
|
|
1595
|
+
// needs no track, so fixes taken at anchor resolve (and survive
|
|
1596
|
+
// restarts) without any DR history. Transitions persist at once:
|
|
1597
|
+
// a hard kill must not lose a span edge.
|
|
1598
|
+
if (underway === false && stationarySince == null) {
|
|
1599
|
+
stationarySince = Date.now();
|
|
1600
|
+
deps.openStationarySpan(db, stationarySince);
|
|
1601
|
+
stationarySpans.push({ started_ms: stationarySince, ended_ms: null });
|
|
1602
|
+
} else if (underway === true && stationarySince != null) {
|
|
1603
|
+
deps.closeStationarySpan(db, stationarySince, Date.now());
|
|
1604
|
+
const open = stationarySpans.find((s) => s.ended_ms == null);
|
|
1605
|
+
if (open) open.ended_ms = Date.now();
|
|
1606
|
+
stationarySince = null;
|
|
1607
|
+
}
|
|
1544
1608
|
if (tripEdge.transition === "raise") {
|
|
1545
1609
|
if (engine.tripLogNm > 0) {
|
|
1546
1610
|
engine.resetTrip();
|
|
@@ -2593,9 +2657,13 @@ module.exports = (app) => {
|
|
|
2593
2657
|
if (fresh.length > 0) {
|
|
2594
2658
|
trackFlushedTs = fresh[fresh.length - 1].timestamp;
|
|
2595
2659
|
}
|
|
2660
|
+
// Closed stationary spans older than the retention window go too;
|
|
2661
|
+
// an open span is the engine's present state and never prunes.
|
|
2662
|
+
deps.pruneStationarySpansBefore(db, Date.now() - 8 * 24 * 3600 * 1000);
|
|
2596
2663
|
}
|
|
2597
2664
|
if (engine.origin) {
|
|
2598
2665
|
deps.setState(db, "last_known_good_fix", JSON.stringify(engine.origin));
|
|
2666
|
+
deps.setState(db, "dr_origin_set_ms", String(engine.originSetMs ?? ""));
|
|
2599
2667
|
}
|
|
2600
2668
|
deps.setState(db, "dr_log_nm", String(engine.logNm));
|
|
2601
2669
|
deps.setState(db, "dr_trip_log_nm", String(engine.tripLogNm));
|
|
@@ -2625,6 +2693,55 @@ module.exports = (app) => {
|
|
|
2625
2693
|
|
|
2626
2694
|
// --- REST API ----------------------------------------------------------
|
|
2627
2695
|
|
|
2696
|
+
/**
|
|
2697
|
+
* The ground-track displacement provider shared by the resolve and
|
|
2698
|
+
* confirm paths — the track first, then stationary-span coverage
|
|
2699
|
+
* (fixes while stationary, work doc #38 review): while the engine
|
|
2700
|
+
* freezes displacement (anchored/moored), zero displacement is
|
|
2701
|
+
* correct by construction — the anchor holds the ground position
|
|
2702
|
+
* and the engine deliberately freezes with it. An interval fully
|
|
2703
|
+
* inside a stationary span resolves without any track, so fixes
|
|
2704
|
+
* taken at anchor survive restarts and track gaps. Both paths MUST
|
|
2705
|
+
* use the same provider: a preview that resolved via a span must
|
|
2706
|
+
* not confirm differently.
|
|
2707
|
+
*
|
|
2708
|
+
* @param {number} t0
|
|
2709
|
+
* @param {number} t1
|
|
2710
|
+
* @returns {{bearingTrue: number, distanceNm: number}|null}
|
|
2711
|
+
*/
|
|
2712
|
+
function advanceDisplacement(t0, t1) {
|
|
2713
|
+
const disp = groundTrack?.displacementBetween(t0, t1) ?? null;
|
|
2714
|
+
if (disp) return disp;
|
|
2715
|
+
const from = Math.min(t0, t1);
|
|
2716
|
+
const to = Math.max(t0, t1);
|
|
2717
|
+
for (const span of stationarySpans) {
|
|
2718
|
+
if (from >= span.started_ms && to <= (span.ended_ms ?? Date.now())) {
|
|
2719
|
+
return { bearingTrue: 0, distanceNm: 0 };
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
return null;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
/**
|
|
2726
|
+
* Guiding failure text for an unresolvable observation set — the
|
|
2727
|
+
* watchkeeper needs the next step, not a verdict (work doc #38
|
|
2728
|
+
* review).
|
|
2729
|
+
*
|
|
2730
|
+
* @param {number[]} lopIds
|
|
2731
|
+
* @param {number[]} cplIds
|
|
2732
|
+
* @param {object[]|undefined} observations
|
|
2733
|
+
* @returns {string}
|
|
2734
|
+
*/
|
|
2735
|
+
function unresolvableMessage(lopIds, cplIds, observations) {
|
|
2736
|
+
const total =
|
|
2737
|
+
lopIds.length +
|
|
2738
|
+
cplIds.length +
|
|
2739
|
+
(Array.isArray(observations) ? observations.length : 0);
|
|
2740
|
+
return total === 1
|
|
2741
|
+
? "one sight alone can't make a fix — it resolves as a running fix against the last confirmed fix (the DR track must cover the time between), or pair it with a second sight."
|
|
2742
|
+
: "these sights don't cross — parallel lines, or a circle that misses them. Check the sight details, or add another sight.";
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2628
2745
|
/**
|
|
2629
2746
|
* Parses the optional `since` query parameter (ISO-8601 string or
|
|
2630
2747
|
* epoch ms) of the overlay endpoints into the db layer's `sinceMs`
|
|
@@ -3219,14 +3336,16 @@ module.exports = (app) => {
|
|
|
3219
3336
|
getLineOfPosition: deps.getLineOfPosition,
|
|
3220
3337
|
getCircularPositionLine: deps.getCircularPositionLine,
|
|
3221
3338
|
},
|
|
3222
|
-
advance:
|
|
3339
|
+
advance: advanceDisplacement,
|
|
3223
3340
|
});
|
|
3224
3341
|
} catch (err) {
|
|
3225
3342
|
res.status(400).json({ message: err.message });
|
|
3226
3343
|
return;
|
|
3227
3344
|
}
|
|
3228
3345
|
if (!candidate) {
|
|
3229
|
-
res.status(400).json({
|
|
3346
|
+
res.status(400).json({
|
|
3347
|
+
message: unresolvableMessage(lopIds, cplIds, b.observations),
|
|
3348
|
+
});
|
|
3230
3349
|
return;
|
|
3231
3350
|
}
|
|
3232
3351
|
// Preview context (sea trial 2026-08-31): surface how far the
|
|
@@ -3238,11 +3357,24 @@ module.exports = (app) => {
|
|
|
3238
3357
|
const resolveCapNm = deps.fixSanityCapNm(
|
|
3239
3358
|
config.fixes.maxDisplacementNm,
|
|
3240
3359
|
engine.underwaySinceOriginS,
|
|
3360
|
+
engine.originSetMs != null ? Date.now() - engine.originSetMs : 0,
|
|
3241
3361
|
);
|
|
3242
3362
|
res.json({
|
|
3243
3363
|
candidate,
|
|
3244
3364
|
displacement_nm: displacementNm,
|
|
3245
3365
|
gross: displacementNm != null && displacementNm > resolveCapNm,
|
|
3366
|
+
// Advancement diagnostics (work doc #38 review): when an
|
|
3367
|
+
// observation could not be advanced, the honest message needs
|
|
3368
|
+
// the WHY — where the DR track actually reaches, and whether
|
|
3369
|
+
// the engine has been holding position (stationary spans
|
|
3370
|
+
// cover their intervals regardless of the track).
|
|
3371
|
+
track_coverage: {
|
|
3372
|
+
oldest_ms: groundTrack?.samples[0]?.timestamp ?? null,
|
|
3373
|
+
newest_ms:
|
|
3374
|
+
groundTrack?.samples[groundTrack.samples.length - 1]?.timestamp ??
|
|
3375
|
+
null,
|
|
3376
|
+
stationary_since: stationarySince,
|
|
3377
|
+
},
|
|
3246
3378
|
});
|
|
3247
3379
|
});
|
|
3248
3380
|
|
|
@@ -3304,6 +3436,8 @@ module.exports = (app) => {
|
|
|
3304
3436
|
// Build the candidate: either a passed-back pre-resolved candidate,
|
|
3305
3437
|
// a point fix, or a fresh LOP/CPL resolution.
|
|
3306
3438
|
let candidate;
|
|
3439
|
+
let lopIds = [];
|
|
3440
|
+
let cplIds = [];
|
|
3307
3441
|
try {
|
|
3308
3442
|
if (b.candidate?.source_type) {
|
|
3309
3443
|
candidate = b.candidate;
|
|
@@ -3324,12 +3458,11 @@ module.exports = (app) => {
|
|
|
3324
3458
|
getLineOfPosition: deps.getLineOfPosition,
|
|
3325
3459
|
getCircularPositionLine: deps.getCircularPositionLine,
|
|
3326
3460
|
},
|
|
3327
|
-
advance:
|
|
3328
|
-
groundTrack?.displacementBetween(t0, t1) ?? null,
|
|
3461
|
+
advance: advanceDisplacement,
|
|
3329
3462
|
});
|
|
3330
3463
|
} else {
|
|
3331
|
-
|
|
3332
|
-
|
|
3464
|
+
lopIds = Array.isArray(b.lop_ids) ? b.lop_ids : [];
|
|
3465
|
+
cplIds = Array.isArray(b.cpl_ids) ? b.cpl_ids : [];
|
|
3333
3466
|
const runningFixEligible =
|
|
3334
3467
|
!(Array.isArray(b.observations) && b.observations.length > 0) &&
|
|
3335
3468
|
lopIds.length + cplIds.length === 1;
|
|
@@ -3347,8 +3480,7 @@ module.exports = (app) => {
|
|
|
3347
3480
|
getLineOfPosition: deps.getLineOfPosition,
|
|
3348
3481
|
getCircularPositionLine: deps.getCircularPositionLine,
|
|
3349
3482
|
},
|
|
3350
|
-
advance:
|
|
3351
|
-
groundTrack?.displacementBetween(t0, t1) ?? null,
|
|
3483
|
+
advance: advanceDisplacement,
|
|
3352
3484
|
});
|
|
3353
3485
|
}
|
|
3354
3486
|
} catch (err) {
|
|
@@ -3356,7 +3488,9 @@ module.exports = (app) => {
|
|
|
3356
3488
|
return;
|
|
3357
3489
|
}
|
|
3358
3490
|
if (!candidate) {
|
|
3359
|
-
res.status(400).json({
|
|
3491
|
+
res.status(400).json({
|
|
3492
|
+
message: unresolvableMessage(lopIds, cplIds, b.observations),
|
|
3493
|
+
});
|
|
3360
3494
|
return;
|
|
3361
3495
|
}
|
|
3362
3496
|
|
|
@@ -3371,6 +3505,7 @@ module.exports = (app) => {
|
|
|
3371
3505
|
const fixCapNm = deps.fixSanityCapNm(
|
|
3372
3506
|
config.fixes.maxDisplacementNm,
|
|
3373
3507
|
engine.underwaySinceOriginS,
|
|
3508
|
+
engine.originSetMs != null ? Date.now() - engine.originSetMs : 0,
|
|
3374
3509
|
);
|
|
3375
3510
|
if (resets && engine.origin) {
|
|
3376
3511
|
const displacementNm = deps.distanceNm(engine.origin, candidate);
|