@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 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. A status tile is also available for Freeboard-SK ≥ 3.0.
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
- **Entering fixes.** Open *⊕ Sight / LOP* and pick the method:
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.14.2",
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;
@@ -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(maxDisplacementNm, underwaySinceOriginS) {
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
- return Math.max(maxDisplacementNm, 1.5 * hours);
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: (t0, t1) => groundTrack?.displacementBetween(t0, t1) ?? null,
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({ message: "observations not resolvable" });
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: (t0, t1) =>
3328
- groundTrack?.displacementBetween(t0, t1) ?? null,
3461
+ advance: advanceDisplacement,
3329
3462
  });
3330
3463
  } else {
3331
- const lopIds = Array.isArray(b.lop_ids) ? b.lop_ids : [];
3332
- const cplIds = Array.isArray(b.cpl_ids) ? b.cpl_ids : [];
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: (t0, t1) =>
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({ message: "observations not resolvable" });
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);