@meri-imperiumi/signalk-dead-reckoning 0.13.3 → 0.13.4
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 +6 -0
- package/README.md +9 -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-map-view.js +9 -0
- package/public/dr-sight-panel.js +389 -75
- package/public/dr-viewmodel.js +152 -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/plugin.test.js +92 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.4] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **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°").
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
10
16
|
## [0.13.3] - 2026-10-15
|
|
11
17
|
|
|
12
18
|
### Changed
|
package/README.md
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
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 ([reported by Yachting Monthly](https://www.yachtingmonthly.com/news/mca-tells-maritime-sector-to-plan-for-life-without-gps-106566), 1 Oct 2025) 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 exactly that contingency for Signal K vessels: the fallback is always running, and the trusted-GPS period is spent training it.
|
|
10
|
+
|
|
7
11
|
## Requirements
|
|
8
12
|
|
|
9
13
|
* This plugin installed and configured
|
|
@@ -19,7 +23,7 @@ An offline-first dead reckoning and sensor fusion engine for Signal K that maint
|
|
|
19
23
|
* Wind data — used by the calibration model and the polar speed fallback
|
|
20
24
|
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
25
|
4. Optional integrations, configured in the plugin settings:
|
|
22
|
-
* `signalk-logbook` —
|
|
26
|
+
* `signalk-logbook` (or any other `logentries` resource provider) — writes confirmed fixes and maneuvers to the vessel's logbook
|
|
23
27
|
* `signalk-polar-performance-plugin` — polar-derived speed when the paddlewheel is fouled or silent
|
|
24
28
|
* `@meri-imperiumi/signalk-autostate` — keeps the navigation state (anchored, sailing, motoring) accurate, which gates training
|
|
25
29
|
* Signal K Weather API — point-forecast current vectors for the DR solution
|
|
@@ -30,8 +34,10 @@ Open the webapp from your Signal K server's *Web apps* menu. It shows the vessel
|
|
|
30
34
|
|
|
31
35
|
**Entering fixes.** Open *⊕ Sight / LOP* and pick the method:
|
|
32
36
|
|
|
33
|
-
* **Bearing** —
|
|
37
|
+
* **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
38
|
* **Vert. Angle** — vertical angle to a known object of known height gives a circular position line
|
|
39
|
+
* **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
|
|
40
|
+
* **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
41
|
* **Celestial** — sextant sight of the Sun, Moon, or an almanac star
|
|
36
42
|
|
|
37
43
|
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-map-view.js
CHANGED
|
@@ -1088,6 +1088,15 @@ class DrMapView extends HTMLElement {
|
|
|
1088
1088
|
: [
|
|
1089
1089
|
{ label: ` Bearing to ${what}`, mode: "bearing" },
|
|
1090
1090
|
{ label: ` Distance CPL at ${what}`, mode: "vertical" },
|
|
1091
|
+
// Radar range (work doc #33): the GNSS-independent range
|
|
1092
|
+
// instrument — a radar range to a charted object, or to an
|
|
1093
|
+
// AIS target (whose position is itself GNSS-derived; the
|
|
1094
|
+
// radar form's note says so).
|
|
1095
|
+
{ label: ` Radar range at ${what}`, mode: "radar" },
|
|
1096
|
+
// Horizontal sextant angle (work doc #35): the compass-free
|
|
1097
|
+
// fix. Successive picks fill the form's object slots A → B
|
|
1098
|
+
// → C.
|
|
1099
|
+
{ label: ` Horizontal angle at ${what}`, mode: "hangle" },
|
|
1091
1100
|
{ label: " Measure from here…", mode: "_measure" },
|
|
1092
1101
|
{ label: " New note at…", mode: "_note" },
|
|
1093
1102
|
];
|