@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 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, and vertical angles are entered through a unified pipeline and can snap dead reckoning back on track, with optional write-through to `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`).
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` — write confirmed fixes and maneuvers to the vessel's 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** — compass bearing to a known object (lighthouse, tower) gives a line of position
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 `signalk-logbook` (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.
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 signalk-logbook `logentries` resource, if written
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 → signalk-logbook Resources API (in-process, optional)
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 `signalk-logbook` 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.
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 `signalk-logbook` Field Mapping
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 the provider signalk-logbook registers (the contract is documented in signalk-logbook's `docs/logentries-resource.md`). 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.
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 `signalk-logbook` 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.13.3",
3
+ "version": "0.13.4",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
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 signalk-logbook, storing 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
  *
@@ -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") return true;
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 signalk-logbook",
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 the
2191
- * `logentries` resource provider (registered by signalk-logbook) with
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
- "signalk-logbook logentries provider not available — entries queued",
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: b.radius_uncertainty_nm ?? null,
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: "vertical",
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 `signalk-logbook` (SPEC §9.4, §9.5).
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, or a celestial
333
- * sight. Logged when the observation is recorded — independent of
334
- * whether it ever resolves into a fix — because taking the sight is
335
- * itself a navigational event. The vessel's position (DR at the time
336
- * of the sight) rides the `navigation.position` telemetry pathvalue —
337
- * the logbook UI renders it separately, so it stays out of `text`;
338
- * `text` carries only what was observed (object, bearing/radius/
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) or object label
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 CPLs)
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, with the `logentries`
414
- * provider registered by signalk-logbook. No REST, no tokens, no auth
415
- * of any kind: in-process plugin access passes no security middleware.
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
@@ -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
  ];