@meri-imperiumi/signalk-dead-reckoning 0.13.3 → 0.14.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 +17 -0
- package/README.md +10 -3
- package/SPEC.md +7 -7
- package/package.json +1 -1
- package/plugin/db.js +2 -1
- package/plugin/fix-pipeline.js +29 -0
- package/plugin/fixes.js +6 -2
- package/plugin/index.js +51 -6
- package/plugin/logbook.js +30 -15
- package/public/dr-app.js +11 -0
- package/public/dr-ext-host-core.js +454 -6
- package/public/dr-ext-host-overlays.js +996 -0
- package/public/dr-ext-host.js +161 -14
- package/public/dr-map-view.js +9 -0
- package/public/dr-sight-panel.js +389 -75
- package/public/dr-viewmodel.js +152 -2
- package/public/index.html +2 -0
- package/public/vendor/plotterext-bus/README.md +10 -6
- package/public/vendor/plotterext-bus/{chunk-RED55KML.js → chunk-UZTRWLMT.js} +29 -2
- package/public/vendor/plotterext-bus/{chunk-4W6N34SD.js → chunk-WV66EF3N.js} +74 -17
- package/public/vendor/plotterext-bus/{chunk-7XRFPDQL.js → chunk-WWNNVR3K.js} +141 -6
- package/public/vendor/plotterext-bus/extension.js +8 -2
- package/public/vendor/plotterext-bus/host.js +11 -3
- package/tests/dr-ext-host-core.test.js +6 -1
- package/tests/dr-ext-host-overlays.test.js +704 -0
- package/tests/dr-ext-host.test.js +23 -2
- package/tests/dr-form-submit.test.js +131 -0
- package/tests/dr-stopwatch.test.js +3 -3
- package/tests/dr-viewmodel.test.js +272 -0
- package/tests/fix-pipeline.test.js +25 -0
- package/tests/fixes.test.js +16 -0
- package/tests/logbook.test.js +30 -0
- package/tests/plotterext.test.js +2 -2
- package/tests/plugin.test.js +92 -0
- package/tests/replay-passage.test.js +8 -1
- package/tools/replay-passage.js +58 -17
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.0] - 2026-10-18
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **The DR webapp hosts chart layers: plotter-extension map overlays (work doc #27, chart-layer phase).** The webapp's plotter-extension host — so far widgets and panels only — gains the surface a map layer needs, with [signalk-livedepth-plugin](https://github.com/night199uk/signalk-livedepth-plugin) as the first consumer: soundings recorded by the LiveDepth plugin draw as a shaded, isobath-contoured depth surface on the DR chart, live-extended while sailing. New host capabilities: `background.iframe` (headless 1×1 sandboxed iframes running extension runtimes off-viewport, torn down with the extension), `overlays.image` (the `overlay.request` → `overlay.frame` cycle — the host sends the viewport's EPSG:3857 extent, bounds, pixel ratio clamped to the overlay's `preferredPixelRatio`, resolution, zoom and `liveInterval`; the runtime answers with a transferred `ImageBitmap` the host composites into a pane above the charts and below the chartwork/vessel, refusing `overlays.superseded` frames and pacing live ones at `overlays.throttled`; frames stay georeferenced through panning via their own extent, and world copies on either side of the antimeridian are covered), `map` (`map.getView`/`map.center`/`map.fitBounds` plus settled `map.view` events), `units` (`units.get` with the preferred depth unit, default `m`), and `buttons` (`mapToolbar` entries rendered into a stack above the bottom-left controls, toggling panels). Layer affordances: a ▤ **Plugin layers** control opens a dialog with per-layer show/hide, opacity, and the layer's settings panel (`configPanel`) — layer state persists in `dr.plotterext.overlays.v1` like the chart layers' own prefs, extensions' `state.get/set`/`state.changed` storage shared with their settings panel, and `events.publish` routing (scope `all`/`extension`). The vendored `signalk-plotterext-bus` moves 0.11.0 → 0.21.0 for the `overlay.*` and `map.view` wire surface (0.12–0.18 lack it). Nothing is needed server-side: LiveDepth registers its own `plotterExtensions` provider, and the host discovers it through the same collection polling; the same `overlays.image` surface is what a future radar-overlay provider would plug into for DR fixes.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **The GPS/override bar no longer sits in the middle of a phone screen** (`public/dr-ext-host.js`): on the ≤600 px phone layout the top band stacks vertically, and every `<dr-ext-widget-area>` reserves its full 2×2 widget footprint even while empty — so the empty top-left area inflated the tools column to ~323 px and pushed the GPS status + Engage OVERRIDE panel down to y≈337 of an 844 px viewport, mid-screen. The reservation exists only for the chart context-menu placement hit-test, so it now collapses inside the area's own phone media query; a placed grid still sizes itself through its rows, and placement simply isn't offered on an empty phone area. Phone top band drops from ~656 px to ~260 px with the GPS bar back at the top. `doc/dr-mobile.png` regenerated with the fixed layout.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- **Passage replay gains a blind variant — the GPS-loss backtest the tier-2 circularity demanded** (`tools/replay-passage.js`): the derived tier-2 current is fitted to the very GPS track a replay scores against, so "derived beats everything" over a fully-GPS-fed passage is a tautology, not a result. The new `--blind-from ISO` variant simulates the actual use case — GPS dies mid-passage: fixes stop feeding the derived-current state at the cutoff while snapshots keep being taken, so the shadow boat navigates on the carried EWMA current decaying at its 24 h time constant and lapsing to tier 5 after the TTL, with divergence still scored against the hidden GPS track. First blind run on the Niue→Vava'u passage (2026-09-11→14, 253 nm, GPS lost at hour 24 of 60): DR stayed within ~10 nm of truth through the entire 24 h carry window, then degraded at ~1 nm/h once the current support vanished — 22.5 nm final vs 10.0 nm with GPS live and 38.2 nm cold. Also fixed on the way: the derived-current provider returned no `tier` field, so the §6.2 training gate read every tick as unresolved and the learning variant silently never trained (0 matrix bins, learn ≡ derived); it now stamps `tier: 2`, the leeway-synthetic test's threshold is recalibrated to the post-gate semantics (the old bound was met by the current, not by learning), and the `scudLearn` variant is vestigial by design — the gate deliberately blocks training on model currents.
|
|
20
|
+
|
|
21
|
+
## [0.13.4] - 2026-10-16
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- **Horizontal sextant angle fixes: the compass-free three-point fix (work doc #35).** The sight panel gains an **H. Angle** tab: measure the horizontal angle between two charted objects with the sextant, and the inscribed-angle circle of position through both objects is derived against the current DR position (the mirror-image circle ambiguity resolves by which circle actually passes near the boat); a second angle to a third object resolves the classic three-point fix directly. One angle alone combines with any bearing, range, or celestial LOP through the pending list as before. The chart pick menu gains "Horizontal angle at…" — successive picks fill the form's object slots A → B → C. Degenerate inputs (angles beyond 0.5–179.5°, coincident objects, missing DR) refuse with navigator-facing messages instead of producing planet-sized circles. The measured angle is stored raw (`raw_angle_deg`) for backtesting, and observations log to the vessel's logbook through the `logentries` resource provider ("Lighthouse / Tower horizontal angle 45.5°").
|
|
25
|
+
- **Radar fixes: radar range and radar-bearing observations (work doc #33).** Radar is the one ranging instrument on a typical boat that does not depend on GNSS, and the MCA's GNSS-resilience guidance (Note 719) names radar fixing among the practices to maintain. The sight panel gains a **Radar** tab — object name, measured range in NM, the shared sight-time/stopwatch entry, and a chart-pick-pre-filled object position — posting a `radar-range` circular position line; the chart pick menu gains "Radar range at…" alongside the bearing and distance-CPL entries (works on AIS targets too, with a note that a target's position is itself GNSS-derived — charted fixed objects are the spoof-resistant choice). The bearing form gains an **instrument** selector (hand compass / radar): a radar-derived bearing is stored as a `radar-bearing` LOP — identical geometry (a ray to the object, enforced by the resolver's observer-ray guard) with instrument provenance. Radar ranges carry a range-proportional error model: the default uncertainty is the larger of `radarRangeErrorPct`% of the measured range (default 1.5, schema-configurable) and an absolute floor (default 20 m), applied server-side when the client doesn't supply its own estimate; range + bearing to the same object at the same timestamp resolve through the pending list as the classic two-instrument radar fix. Radar ranges log to the vessel's logbook through the `logentries` resource provider as observations ("Cape Rocks radar range 4.2 NM"), and radar entries edit in the radar tab like the other observation types.
|
|
26
|
+
|
|
10
27
|
## [0.13.3] - 2026-10-15
|
|
11
28
|
|
|
12
29
|
### Changed
|
package/README.md
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
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,
|
|
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`).
|
|
4
4
|
|
|
5
5
|
**Note:** This is just a toy. Make your own navigation calculations and decisions.
|
|
6
6
|
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Planning for life without GPS is no longer hypothetical: the UK Maritime and Coastguard Agency's [Guidance Note 719](https://www.gov.uk/government/publications/mgn-719-mf-operating-in-gnss-denied-and-degraded-environments/mgn-719-mf-operating-in-gnss-denied-and-degraded-environments) ([reported by Yachting Monthly](https://www.yachtingmonthly.com/news/mca-tells-maritime-sector-to-plan-for-life-without-gps-106566), 1 Oct 2026) reports a marked increase in GNSS jamming and spoofing worldwide and asks mariners to review contingency arrangements, maintain resilient navigation practices, and practice navigating without satellite positioning — naming dead reckoning, visual bearings, and celestial fixing as valid methods. This plugin implements a contingency for Signal K vessels.
|
|
10
|
+
|
|
7
11
|
## Requirements
|
|
8
12
|
|
|
9
13
|
* This plugin installed and configured
|
|
10
14
|
* Some chart source available to Signal K
|
|
11
15
|
* Hand bearing compass and/or sextant
|
|
16
|
+
* A heading source independent of GNSS — a fluxgate or inertial compass feeding `navigation.headingMagnetic`, not a satellite (GNSS) compass. A satellite compass shares the jamming and spoofing failure mode of the position fix, so a vessel whose only heading source is satellite-derived cannot dead-reckon through GNSS denial. Be careful checking this: on N2K networks a single physical device often provides both compass and position data, and display list entries do not necessarily reflect that (a device may publish both PGN 127257 heading and PGN 129029 position under one source)
|
|
12
17
|
|
|
13
18
|
## Getting started
|
|
14
19
|
|
|
@@ -19,7 +24,7 @@ An offline-first dead reckoning and sensor fusion engine for Signal K that maint
|
|
|
19
24
|
* Wind data — used by the calibration model and the polar speed fallback
|
|
20
25
|
3. Just sail. While GPS is reliable and you're under sail (propulsion stopped), the engine continuously learns vessel-specific leeway and speed-loss corrections against GPS ground truth. There is nothing to activate: the "shadow boat" runs at all times, and the uncertainty polygon tightens over a season as the calibration bins fill up. Early on, expect conservative uncertainty estimates.
|
|
21
26
|
4. Optional integrations, configured in the plugin settings:
|
|
22
|
-
* `signalk-logbook` —
|
|
27
|
+
* `signalk-logbook` (or any other `logentries` resource provider) — writes confirmed fixes and maneuvers to the vessel's logbook
|
|
23
28
|
* `signalk-polar-performance-plugin` — polar-derived speed when the paddlewheel is fouled or silent
|
|
24
29
|
* `@meri-imperiumi/signalk-autostate` — keeps the navigation state (anchored, sailing, motoring) accurate, which gates training
|
|
25
30
|
* Signal K Weather API — point-forecast current vectors for the DR solution
|
|
@@ -30,8 +35,10 @@ Open the webapp from your Signal K server's *Web apps* menu. It shows the vessel
|
|
|
30
35
|
|
|
31
36
|
**Entering fixes.** Open *⊕ Sight / LOP* and pick the method:
|
|
32
37
|
|
|
33
|
-
* **Bearing** —
|
|
38
|
+
* **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
|
|
34
39
|
* **Vert. Angle** — vertical angle to a known object of known height gives a circular position line
|
|
40
|
+
* **Radar** — range to a known object gives a circular position line — the one ranging instrument that does not depend on GNSS. Range + bearing to the same object is the classic two-instrument radar fix. A range to an AIS target rests on the target's own GNSS-derived position, so under suspected interference prefer charted fixed objects
|
|
41
|
+
* **H. Angle** — horizontal sextant angle between two charted objects gives a circle of position with no compass involved; a second angle to a third object gives the classic three-point fix
|
|
35
42
|
* **Celestial** — sextant sight of the Sun, Moon, or an almanac star
|
|
36
43
|
|
|
37
44
|
You can right-click an object on the map to pre-fill its coordinates. Sights can be entered with a stopwatch delay ("minutes ago") instead of clock time. Observations accumulate in the pending list; select two or more, *Preview selected* to resolve the candidate fix, then *Confirm fix* to snap dead reckoning to it. A single line of position can also be advanced to a later sight (running fix).
|
package/SPEC.md
CHANGED
|
@@ -23,7 +23,7 @@ The three motivations are causally linked: (1) generates the fix data and crew e
|
|
|
23
23
|
- Learns vessel-specific leeway, speed-loss, and heading-deviation corrections via a binned EMA matrix, trained against GPS ground truth during normal sailing.
|
|
24
24
|
- Detects GPS anomalies (sudden jumps, implausible-at-anchor/moored displacement, corroborating third-party AIS/ADS-B inconsistency) and alerts the watchkeeper — it does **not** auto-switch navigational authority; a human always decides when to trust DR over GPS.
|
|
25
25
|
- Supports a unified concept of "fix" that spans GPS, celestial sights, compass bearings, vertical-angle-to-known-object, and future methods (Starlink-constellation positioning, radio direction finding), each contributing either a point, a line-of-position, or a circular position line to a common fix-resolution model.
|
|
26
|
-
- Integrates with
|
|
26
|
+
- Integrates with the vessel's logbook through the `logentries` Signal K v2 Resources API (in-process) as an optional, gracefully-degrading write-through layer, so fixes and DR events become part of the vessel's actual logbook record. Provider-agnostic: any plugin registering a `logentries` resource provider works — `signalk-logbook` is the known-good one, not a requirement.
|
|
27
27
|
- Supports offline historical backfill and backtesting against 4–5 years of Signal K History API data, optionally enriched with reanalysis weather/current data and logbook records when broadband is available.
|
|
28
28
|
|
|
29
29
|
\* "Zero-dependency" here means no external services required for core operation; the SQLite persistence layer should target Node's built-in `node:sqlite` (or an equivalent no-native-build option) rather than a compiled native binding, given the ARM/Raspberry-Pi-class hardware most Signal K installs run on.
|
|
@@ -135,7 +135,7 @@ CREATE TABLE IF NOT EXISTS fixes (
|
|
|
135
135
|
estimated_error_radius REAL,
|
|
136
136
|
confirmed_by TEXT, -- crew name; simple free-text picker, no auth model assumed
|
|
137
137
|
logged_to_logbook BOOLEAN NOT NULL DEFAULT 0,
|
|
138
|
-
logbook_entry_ref TEXT, -- UUID of the corresponding
|
|
138
|
+
logbook_entry_ref TEXT, -- UUID of the corresponding `logentries` resource, if written
|
|
139
139
|
resets_dr_origin BOOLEAN NOT NULL DEFAULT 0,
|
|
140
140
|
notes TEXT
|
|
141
141
|
);
|
|
@@ -247,7 +247,7 @@ CREATE TABLE IF NOT EXISTS gps_anomalies (
|
|
|
247
247
|
|
|
248
248
|
```
|
|
249
249
|
Main Thread (Signal K Event Loop)
|
|
250
|
-
N2K/N0183 Ingest → Delta Router →
|
|
250
|
+
N2K/N0183 Ingest → Delta Router → logentries Resources API (in-process, optional)
|
|
251
251
|
→ GRIB Provider Service
|
|
252
252
|
→ AIS/ADS-B target tracking
|
|
253
253
|
|
|
@@ -394,15 +394,15 @@ On every confirmed fix that resets the DR origin (symmetric across NORMAL and OV
|
|
|
394
394
|
|
|
395
395
|
### 9.4 Automatic Tack/Gybe Logbook Entries
|
|
396
396
|
|
|
397
|
-
The rate-of-turn detection already built for matrix-training suppression (§6.4) is a free source of a routine, easily-forgotten logbook event: reuse the same tack/gybe detection to auto-generate a `
|
|
397
|
+
The rate-of-turn detection already built for matrix-training suppression (§6.4) is a free source of a routine, easily-forgotten logbook event: reuse the same tack/gybe detection to auto-generate a `logentries` entry (`category: navigation`, `origin: auto` — pure automation, no human in the loop; see §9.5) whenever a completed tack or gybe is detected — direction (tack→tack or gybe), new heading, and timestamp are all already available from the same signal.
|
|
398
398
|
|
|
399
399
|
- No watchkeeper confirmation step is needed for this entry type — unlike fixes, there's no ambiguity or human judgment involved in "a tack happened," so it can write directly rather than going through the confirm-first pipeline used for fixes.
|
|
400
400
|
- Should respect the same "avoid flooding the logbook" concern raised for routine GPS fixes (§9.1) — a beat to windward with frequent short tacks could generate a lot of entries; consider a minimum-interval debounce (e.g. don't log a second tack within some short window, treating rapid back-to-back direction changes as one transient event) rather than logging every single rate-of-turn crossing.
|
|
401
401
|
- This is incidental to the core DR/matrix purpose but effectively free, since the detection work is already required for §6.4.
|
|
402
402
|
|
|
403
|
-
### 9.5 `
|
|
403
|
+
### 9.5 Logbook (`logentries`) Field Mapping
|
|
404
404
|
|
|
405
|
-
Logbook entries are written through the Signal K v2 Resources API — in-process `app.resourcesApi.setResource("logentries", <uuid>, entry)` against
|
|
405
|
+
Logbook entries are written through the Signal K v2 Resources API — in-process `app.resourcesApi.setResource("logentries", <uuid>, entry)` against whatever plugin registers a `logentries` resource provider. The contract is provider-agnostic: signalk-logbook is the reference implementation (its `docs/logentries-resource.md` documents the schema), but any provider answering the type works, and the plugin probes for the type — not for a plugin name. No REST, no tokens: the server gates in-process plugin access at nothing, which dissolves the access-request/token apparatus the deprecated v1 plugin routes forced. The `logentries` schema has no dedicated fix-type or celestial-specific fields — a required free-text `text`, logbook metadata fields, and an optional `telemetry` array of delta-shaped pathvalues in Signal K SI units. `fixes` remains the local source of truth; the logbook write is a formatted export, not the canonical record.
|
|
406
406
|
|
|
407
407
|
| `fixes` / DR field | `logentries` field | Notes |
|
|
408
408
|
|---|---|---|
|
|
@@ -429,7 +429,7 @@ Two distinct jobs, both explicit, user-triggered, broadband-gated (Starlink) ope
|
|
|
429
429
|
Replays History API data (4–5 years) through the same bin/EMA logic as live training mode, to seed `dr_matrix_bins` before significant live sailing accumulates.
|
|
430
430
|
|
|
431
431
|
- **Weather/current enrichment:** when run with broadband available, fetches historical reanalysis current/wind data (e.g. Copernicus Marine Service / ERA5-class products — license/attribution terms to be confirmed before committing to a specific provider) for the dates/positions actually visited, rather than relying solely on climatological pilot-chart averages. Query set is deduplicated to distinct (date, grid-cell) tuples before fetching, batched and rate-limited/backed-off per the provider's documented limits, with a persisted progress checkpoint (`dr_state_store` high-water-mark) so an interrupted run can resume. Bins trained with reanalysis current get a higher `historical_confidence_tier` weight than climatology-only bins (§4.1).
|
|
432
|
-
- **Logbook enrichment:** where `
|
|
432
|
+
- **Logbook enrichment:** where `logentries` entries exist for the historical window (likely not the full 4–5 years), read them tokenlessly in-process via `app.resourcesApi.listResources("logentries", {from, to})` and join by nearest-timestamp (~±30min) to recover `sail_state`/sea state for spans that would otherwise fall to `'unknown'` (entry telemetry carries SI pathvalues — `environment.water.seaState` is Beaufort, convert back to the Douglas code DR stores). Historical `navigation`-category entries carrying a `navigation.position` pathvalue are additionally ingested into `fixes` (`source_type = 'backfill'`), so passage review isn't blank for the pre-plugin era.
|
|
433
433
|
- **Resolution/provenance caveats, explicitly documented rather than silently absorbed:** ~10s History API resolution under-represents instantaneous heel/AWA response (gust/roll dynamics) versus live 1Hz IMU data; paddlewheel fouling status is essentially unknown for historical data and must be inferred via the STW≈0-while-moving heuristic (§6.3) applied retroactively; `navigation.log`/`navigation.trip.log`/logbook `navigation.log` telemetry are GPS-track-derived, not water-track, and must never be used as an STW proxy or ground truth during backfill.
|
|
434
434
|
- **Outlier rejection:** a max-plausible-speed/position-jump filter, plus the fouling heuristic, applied before samples enter training — historical GPS glitches are otherwise uncorrected and would poison bins.
|
|
435
435
|
- Also used to build `anchor_swing_stats` and `moored_position_stats` (§7.1, §7.2), each with their own drag-exclusion / settle-window preprocessing. Recomputed periodically (e.g. quarterly) rather than live-updated, since these have no need for 1Hz reactivity.
|
package/package.json
CHANGED
package/plugin/db.js
CHANGED
|
@@ -638,7 +638,8 @@ function getDeviationRateStats(db, q = {}) {
|
|
|
638
638
|
}
|
|
639
639
|
|
|
640
640
|
/**
|
|
641
|
-
* Marks a `fixes` row as written to the
|
|
641
|
+
* Marks a `fixes` row as written to the logbook (`logentries` resource),
|
|
642
|
+
* storing the
|
|
642
643
|
* entry reference (its datetime key). A failed write leaves the row
|
|
643
644
|
* unmarked — visible in `fixes`, never blocking the fix flow.
|
|
644
645
|
*
|
package/plugin/fix-pipeline.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
const { resolveFix, advanceObservation } = require("./fixes.js");
|
|
34
|
+
const { METRES_PER_NM } = require("./geo.js");
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* The helpers the orchestrator needs, injected by the caller. In
|
|
@@ -506,6 +507,33 @@ function defaultOriginErrorNm(sourceType) {
|
|
|
506
507
|
return sourceType === "gps" ? 0.05 : 5;
|
|
507
508
|
}
|
|
508
509
|
|
|
510
|
+
/**
|
|
511
|
+
* Radar-range error model (work doc #33): a consumer radar's range
|
|
512
|
+
* accuracy is spec'd around 1% of range (beamwidth + pulse timing),
|
|
513
|
+
* so the uncertainty grows with the measured distance — plus an
|
|
514
|
+
* absolute floor for the short-range case where the percentage term
|
|
515
|
+
* collapses below what marker/plot resolution can support (20 m is
|
|
516
|
+
* already inside a 2 m cell's display). Configurable: `errorPct`
|
|
517
|
+
* (percent of range) and `floorM` (metres).
|
|
518
|
+
*
|
|
519
|
+
* Pure — unit-testable without Signal K; the POST /fix/cpl route
|
|
520
|
+
* applies it as the default `radius_uncertainty_nm` for
|
|
521
|
+
* `cpl_type: 'radar-range'` when the client doesn't supply one.
|
|
522
|
+
*
|
|
523
|
+
* @param {number} rangeNm - measured range, nm
|
|
524
|
+
* @param {object} [opts]
|
|
525
|
+
* @param {number} [opts.errorPct=1.5] - error as percent of range
|
|
526
|
+
* @param {number} [opts.floorM=20] - absolute floor, metres
|
|
527
|
+
* @returns {number} uncertainty radius, nm
|
|
528
|
+
*/
|
|
529
|
+
function radarRangeUncertaintyNm(rangeNm, opts = {}) {
|
|
530
|
+
const errorPct = Number.isFinite(opts.errorPct) ? opts.errorPct : 1.5;
|
|
531
|
+
const floorM = Number.isFinite(opts.floorM) ? opts.floorM : 20;
|
|
532
|
+
const range = Number.isFinite(rangeNm) && rangeNm >= 0 ? rangeNm : 0;
|
|
533
|
+
const pctNm = (range * errorPct) / 100;
|
|
534
|
+
return Math.max(pctNm, floorM / METRES_PER_NM);
|
|
535
|
+
}
|
|
536
|
+
|
|
509
537
|
/**
|
|
510
538
|
* Maximum implied vessel speed (kn) for an observation to be physically
|
|
511
539
|
* plausible: how fast the boat would have had to travel from the last
|
|
@@ -565,6 +593,7 @@ module.exports = {
|
|
|
565
593
|
loadObservationsById,
|
|
566
594
|
advanceToLatest,
|
|
567
595
|
defaultOriginErrorNm,
|
|
596
|
+
radarRangeUncertaintyNm,
|
|
568
597
|
evaluateObservationPlausibility,
|
|
569
598
|
fixSanityCapNm,
|
|
570
599
|
MAX_IMPLIED_SPEED_KN,
|
package/plugin/fixes.js
CHANGED
|
@@ -562,7 +562,10 @@ const RAY_TOLERANCE_M = 100;
|
|
|
562
562
|
* the line is equally valid.
|
|
563
563
|
*
|
|
564
564
|
* @param {object} obs - resolver-shaped LOP observation (needs
|
|
565
|
-
* `lop_type`, `assumed_lat/lon`, `azimuth_true`)
|
|
565
|
+
* `lop_type`, `assumed_lat/lon`, `azimuth_true`). Bearings to known
|
|
566
|
+
* objects are ray-constrained — `bearing` (hand-bearing compass) and
|
|
567
|
+
* `radar-bearing` (radar heading-marked bearing, work doc #33) alike:
|
|
568
|
+
* same geometry, different instrument.
|
|
566
569
|
* @param {{latitude:number, longitude:number}} projectionCenter
|
|
567
570
|
* @param {{x:number, y:number}} p - candidate point, local plane
|
|
568
571
|
* @param {number} [toleranceM=RAY_TOLERANCE_M] along-ray slack
|
|
@@ -570,7 +573,8 @@ const RAY_TOLERANCE_M = 100;
|
|
|
570
573
|
* the observation is not a bearing — no constraint)
|
|
571
574
|
*/
|
|
572
575
|
function onObserverRay(obs, projectionCenter, p, toleranceM = RAY_TOLERANCE_M) {
|
|
573
|
-
if (obs.lop_type !== "bearing"
|
|
576
|
+
if (obs.lop_type !== "bearing" && obs.lop_type !== "radar-bearing")
|
|
577
|
+
return true;
|
|
574
578
|
const anchor = projectToLocal(projectionCenter, {
|
|
575
579
|
latitude: obs.assumed_lat,
|
|
576
580
|
longitude: obs.assumed_lon,
|
package/plugin/index.js
CHANGED
|
@@ -88,6 +88,7 @@ const {
|
|
|
88
88
|
confirmFix,
|
|
89
89
|
evaluateObservationPlausibility,
|
|
90
90
|
fixSanityCapNm,
|
|
91
|
+
radarRangeUncertaintyNm,
|
|
91
92
|
} = require("./fix-pipeline.js");
|
|
92
93
|
const { reduceSight, reduceNoonSight } = require("./celestial.js");
|
|
93
94
|
const starAlmanac = require("./star-almanac.js");
|
|
@@ -377,6 +378,15 @@ const DEFAULT_CONFIG = {
|
|
|
377
378
|
*/
|
|
378
379
|
fixes: {
|
|
379
380
|
maxDisplacementNm: 100,
|
|
381
|
+
/**
|
|
382
|
+
* Radar-range error model (work doc #33): consumer radar range
|
|
383
|
+
* accuracy is ~1% of range plus display/marker resolution; the
|
|
384
|
+
* default uncertainty for a `radar-range` CPL is the larger of
|
|
385
|
+
* `radarRangeErrorPct`% of the range and `radarRangeErrorFloorM`
|
|
386
|
+
* metres.
|
|
387
|
+
*/
|
|
388
|
+
radarRangeErrorPct: 1.5,
|
|
389
|
+
radarRangeErrorFloorM: 20,
|
|
380
390
|
},
|
|
381
391
|
weatherCurrent: {
|
|
382
392
|
enabled: true,
|
|
@@ -414,6 +424,7 @@ const deps = {
|
|
|
414
424
|
confirmFix,
|
|
415
425
|
evaluateObservationPlausibility,
|
|
416
426
|
fixSanityCapNm,
|
|
427
|
+
radarRangeUncertaintyNm,
|
|
417
428
|
computeRadius,
|
|
418
429
|
createDivergenceState,
|
|
419
430
|
divergenceTick,
|
|
@@ -781,7 +792,7 @@ module.exports = (app) => {
|
|
|
781
792
|
},
|
|
782
793
|
"logbook.enabled": {
|
|
783
794
|
type: "boolean",
|
|
784
|
-
title: "Write fixes and maneuvers to
|
|
795
|
+
title: "Write fixes and maneuvers to the vessel's logbook",
|
|
785
796
|
default: DEFAULT_CONFIG.logbook.enabled,
|
|
786
797
|
},
|
|
787
798
|
"logbook.retryBackoffMs": {
|
|
@@ -832,6 +843,24 @@ module.exports = (app) => {
|
|
|
832
843
|
title: "Shadow vessel label",
|
|
833
844
|
default: DEFAULT_CONFIG.shadowVessel.name,
|
|
834
845
|
},
|
|
846
|
+
"fixes.radarRangeErrorPct": {
|
|
847
|
+
type: "number",
|
|
848
|
+
title: "Radar range error (% of range)",
|
|
849
|
+
description:
|
|
850
|
+
"Default uncertainty radius for a radar-range observation: this percentage of the measured range, or the absolute floor below — whichever is larger. Consumer radar range accuracy is typically ~1%.",
|
|
851
|
+
minimum: 0,
|
|
852
|
+
maximum: 20,
|
|
853
|
+
default: DEFAULT_CONFIG.fixes.radarRangeErrorPct,
|
|
854
|
+
},
|
|
855
|
+
"fixes.radarRangeErrorFloorM": {
|
|
856
|
+
type: "integer",
|
|
857
|
+
title: "Radar range error floor (m)",
|
|
858
|
+
description:
|
|
859
|
+
"Absolute floor for the radar-range uncertainty: at short ranges the percentage term collapses below what marker resolution on the display supports.",
|
|
860
|
+
minimum: 0,
|
|
861
|
+
maximum: 500,
|
|
862
|
+
default: DEFAULT_CONFIG.fixes.radarRangeErrorFloorM,
|
|
863
|
+
},
|
|
835
864
|
},
|
|
836
865
|
},
|
|
837
866
|
|
|
@@ -2187,8 +2216,8 @@ module.exports = (app) => {
|
|
|
2187
2216
|
}
|
|
2188
2217
|
|
|
2189
2218
|
/**
|
|
2190
|
-
* Initializes the logbook write path when enabled: probes
|
|
2191
|
-
* `logentries` resource provider (
|
|
2219
|
+
* Initializes the logbook write path when enabled: probes for any
|
|
2220
|
+
* registered `logentries` resource provider (e.g. signalk-logbook) with
|
|
2192
2221
|
* a minimal listing — a cheap, complete-window query under the
|
|
2193
2222
|
* contract's "no silent windows" rule. Plugin start order isn't
|
|
2194
2223
|
* guaranteed, so a missing provider isn't fatal: writes queue (SQLite)
|
|
@@ -2218,7 +2247,7 @@ module.exports = (app) => {
|
|
|
2218
2247
|
logbookProbing = false;
|
|
2219
2248
|
logbook = null;
|
|
2220
2249
|
setStatus(
|
|
2221
|
-
"
|
|
2250
|
+
"No logentries resource provider available — logbook entries queued",
|
|
2222
2251
|
);
|
|
2223
2252
|
scheduleLogbookRetry();
|
|
2224
2253
|
});
|
|
@@ -2919,7 +2948,17 @@ module.exports = (app) => {
|
|
|
2919
2948
|
center_lat: b.center_lat,
|
|
2920
2949
|
center_lon: b.center_lon,
|
|
2921
2950
|
radius_nm: b.radius_nm,
|
|
2922
|
-
radius_uncertainty_nm:
|
|
2951
|
+
radius_uncertainty_nm:
|
|
2952
|
+
b.radius_uncertainty_nm ??
|
|
2953
|
+
// Radar ranges carry a range-proportional error model (work
|
|
2954
|
+
// doc #33) — applied as the default when the client doesn't
|
|
2955
|
+
// send its own estimate.
|
|
2956
|
+
(b.cpl_type === "radar-range"
|
|
2957
|
+
? deps.radarRangeUncertaintyNm(b.radius_nm, {
|
|
2958
|
+
errorPct: config.fixes.radarRangeErrorPct,
|
|
2959
|
+
floorM: config.fixes.radarRangeErrorFloorM,
|
|
2960
|
+
})
|
|
2961
|
+
: null),
|
|
2923
2962
|
source_object: b.source_object ?? null,
|
|
2924
2963
|
confirmed_by: b.confirmed_by ?? null,
|
|
2925
2964
|
// Raw inputs (schema v2) for backtesting.
|
|
@@ -2929,7 +2968,12 @@ module.exports = (app) => {
|
|
|
2929
2968
|
const obsBy = b.confirmed_by || usernameFromCookies(req.cookies) || null;
|
|
2930
2969
|
writeLogbookEntry(
|
|
2931
2970
|
deps.composeObservationEntry({
|
|
2932
|
-
kind:
|
|
2971
|
+
kind:
|
|
2972
|
+
b.cpl_type === "radar-range"
|
|
2973
|
+
? "radar"
|
|
2974
|
+
: b.cpl_type === "horizontal-angle"
|
|
2975
|
+
? "hangle"
|
|
2976
|
+
: "vertical",
|
|
2933
2977
|
datetime: b.timestamp ?? new Date().toISOString(),
|
|
2934
2978
|
body_or_object: b.source_object ?? null,
|
|
2935
2979
|
confirmed_by: obsBy,
|
|
@@ -2937,6 +2981,7 @@ module.exports = (app) => {
|
|
|
2937
2981
|
latitude: engine.origin?.latitude,
|
|
2938
2982
|
longitude: engine.origin?.longitude,
|
|
2939
2983
|
radius_nm: b.radius_nm,
|
|
2984
|
+
angle_deg: b.angle_deg ?? null,
|
|
2940
2985
|
sea_state:
|
|
2941
2986
|
resolveSeaState() !== "unknown" ? Number(resolveSeaState()) : null,
|
|
2942
2987
|
}),
|
package/plugin/logbook.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Logbook integration: write-through to `
|
|
2
|
+
* Logbook integration: write-through to the `logentries` resource provider
|
|
3
|
+
* (e.g. `signalk-logbook`; SPEC §9.4, §9.5).
|
|
3
4
|
*
|
|
4
5
|
* `fixes` remains the canonical record; logbook entries are formatted
|
|
5
6
|
* exports. Entries are written through the Signal K v2 Resources API
|
|
@@ -329,18 +330,19 @@ function composeTackEntry(t) {
|
|
|
329
330
|
|
|
330
331
|
/**
|
|
331
332
|
* Composes a `NewEntry`-shaped body for a standalone observation
|
|
332
|
-
* (SPEC §9.5): a bearing LOP, a vertical-angle CPL,
|
|
333
|
-
* sight. Logged when the observation
|
|
334
|
-
* whether it ever resolves into a fix —
|
|
335
|
-
* itself a navigational event. The
|
|
336
|
-
* of the sight) rides the
|
|
337
|
-
* the logbook UI renders
|
|
338
|
-
* `text` carries only what
|
|
339
|
-
* reduction).
|
|
333
|
+
* (SPEC §9.5): a bearing LOP, a vertical-angle CPL, a radar range
|
|
334
|
+
* (work doc #33), or a celestial sight. Logged when the observation
|
|
335
|
+
* is recorded — independent of whether it ever resolves into a fix —
|
|
336
|
+
* because taking the sight is itself a navigational event. The
|
|
337
|
+
* vessel's position (DR at the time of the sight) rides the
|
|
338
|
+
* `navigation.position` telemetry pathvalue — the logbook UI renders
|
|
339
|
+
* it separately, so it stays out of `text`; `text` carries only what
|
|
340
|
+
* was observed (object, bearing/radius/reduction).
|
|
340
341
|
*
|
|
341
342
|
* @param {object} o
|
|
342
|
-
* @param {"bearing"|"vertical"|"celestial"} o.kind
|
|
343
|
-
* @param {string} [o.body_or_object] - body name (celestial)
|
|
343
|
+
* @param {"bearing"|"vertical"|"radar"|"hangle"|"celestial"} o.kind
|
|
344
|
+
* @param {string} [o.body_or_object] - body name (celestial), object label,
|
|
345
|
+
* or "A / B" (horizontal-angle CPLs)
|
|
344
346
|
* @param {string} o.datetime - ISO timestamp
|
|
345
347
|
* @param {string|null} [o.confirmed_by] - watchkeeper (author)
|
|
346
348
|
* @param {number} [o.latitude] - vessel position to anchor the entry
|
|
@@ -348,7 +350,10 @@ function composeTackEntry(t) {
|
|
|
348
350
|
* @param {object} [o.reduction] - celestial reduction (Hc/Ho/Zn/intercept)
|
|
349
351
|
* @param {number|null} [o.azimuth_true] - true bearing to the object, deg
|
|
350
352
|
* (bearing LOPs — the observed angle is the event, it belongs in text)
|
|
351
|
-
* @param {number|null} [o.radius_nm] - CPL radius, nm (vertical-angle
|
|
353
|
+
* @param {number|null} [o.radius_nm] - CPL radius, nm (vertical-angle / radar
|
|
354
|
+
* range CPLs)
|
|
355
|
+
* @param {number|null} [o.angle_deg] - measured horizontal angle, deg
|
|
356
|
+
* (horizontal-angle CPLs, work doc #35)
|
|
352
357
|
* @param {number|null} [o.sea_state] - Douglas sea state code 0-9
|
|
353
358
|
* @returns {object} setResource body
|
|
354
359
|
*/
|
|
@@ -372,6 +377,14 @@ function composeObservationEntry(o) {
|
|
|
372
377
|
? ` ${o.radius_nm.toFixed(1)} NM`
|
|
373
378
|
: "";
|
|
374
379
|
text = `${o.body_or_object ?? "object"} CPL${r}`;
|
|
380
|
+
} else if (o.kind === "radar") {
|
|
381
|
+
const r = Number.isFinite(o.radius_nm)
|
|
382
|
+
? ` ${o.radius_nm.toFixed(1)} NM`
|
|
383
|
+
: "";
|
|
384
|
+
text = `${o.body_or_object ?? "object"} radar range${r}`;
|
|
385
|
+
} else if (o.kind === "hangle") {
|
|
386
|
+
const a = Number.isFinite(o.angle_deg) ? ` ${o.angle_deg.toFixed(1)}°` : "";
|
|
387
|
+
text = `${o.body_or_object ?? "objects"} horizontal angle${a}`;
|
|
375
388
|
} else {
|
|
376
389
|
// The observed angle is the event — a bearing entry without it
|
|
377
390
|
// isn't navigable after the fact. Zero-padded like the tack text;
|
|
@@ -410,9 +423,11 @@ function composeObservationEntry(o) {
|
|
|
410
423
|
|
|
411
424
|
/**
|
|
412
425
|
* Creates a logbook write client over the Signal K v2 Resources API —
|
|
413
|
-
* the in-process `app.resourcesApi` interface,
|
|
414
|
-
*
|
|
415
|
-
*
|
|
426
|
+
* the in-process `app.resourcesApi` interface, against whatever plugin
|
|
427
|
+
* registers a `logentries` resource provider (signalk-logbook is the
|
|
428
|
+
* reference implementation, not a requirement). No REST, no tokens, no
|
|
429
|
+
* auth of any kind: in-process plugin access passes no security
|
|
430
|
+
* middleware.
|
|
416
431
|
*
|
|
417
432
|
* @param {object} opts
|
|
418
433
|
* @param {object} opts.resourcesApi - the server's resources API
|
package/public/dr-app.js
CHANGED
|
@@ -903,7 +903,18 @@ class DrApp extends HTMLElement {
|
|
|
903
903
|
this.extHost = new PlotterExtHost({
|
|
904
904
|
stream: window.drSignalkStream,
|
|
905
905
|
mount: root,
|
|
906
|
+
// The chart, once it exists — map overlays composite onto it and
|
|
907
|
+
// the map capability reads/drives its viewport.
|
|
908
|
+
getMap: () => this.map?.map ?? null,
|
|
906
909
|
});
|
|
910
|
+
// The host hooks the Leaflet events itself, but discovery can land
|
|
911
|
+
// before the chart exists — forward the element's own view-change
|
|
912
|
+
// event and kick once so the first overlay frames are requested
|
|
913
|
+
// even without a user pan.
|
|
914
|
+
this.map?.addEventListener("dr-view-changed", () =>
|
|
915
|
+
this.extHost.viewChanged(),
|
|
916
|
+
);
|
|
917
|
+
setTimeout(() => this.extHost.viewChanged(), 250);
|
|
907
918
|
for (const el of root.querySelectorAll("dr-ext-widget-area")) {
|
|
908
919
|
this.extHost.attachArea(el);
|
|
909
920
|
}
|