@meri-imperiumi/signalk-dead-reckoning 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-10-05
11
+
12
+ ### Fixed
13
+ - **Antimeridian crossings no longer corrupt the derived-current sampler and the celestial fix projection.** Two raw longitude deltas measured the long way around ±180°: the derived-current ground-velocity decomposition (`derived-current.js`) read a dateline crossing between two GPS fixes as a ~360° west jump — the SOG gate then rejected every crossing sample as "gps-glitch", so tier-2 current learning went silent exactly when sailing Fiji→Samoa or the Bering Strait — and the fix pipeline's equirectangular projection (`fixes.js` `projectToLocal`) exploded for assumed positions across the line from the projection center, scattering celestial LOPs halfway around the planet. Both deltas now wrap to ±180° (the short way), matching what `ground-track.js` interpolation already did. A new `tests/antimeridian.test.js` suite locks the whole longitude-touching chain down: geo primitives, DR track interpolation, fix projection, derived-current sampling, and engine tick integration.
14
+
15
+ ### Added
16
+ - **Notes show provenance: who published, when.** The note detail surface renders a "Published on 10-03 12:00Z by FIJI METEOROLOGICAL SERVICE" line, read from the note's `properties` — the stamp signalk-passage-briefing now puts on every metarea note it serves (issuer parsed from the bulletin header, bulletin issue time). Provider-shaped notes without a stamp fall back to the bare resource timestamp. Notes created in this webapp are stamped the same way: the Signal K session user (via `/skServer/loginStatus`, "anonymous" without a login) and the save time — a create stamps the publication, an edit preserves it and records `updatedBy`/`updatedAt` ("· edited on 10-04 08:15Z by crew2").
17
+
18
+ ### Changed
19
+ - **AIS target glyphs are visible again.** The bare 16px arrows had a hairline 0.6px stroke and no casing, so they vanished against the chart at rest — the sea-trial symptom read backwards, targets appearing only while a zoom animation scaled the pane up and disappearing the moment it snapped back. The glyphs now get the own-boat cartographic treatment: dark casing under the arrow, near-solid fill (0.9), a real 1.1px stroke, 20px instead of 16px — a deliberate notch below the own-boat hull (26px, heavier casing/stroke) so traffic reads as context and own ship as the subject. Expiring-target dimming is unchanged.
20
+ - **Note picks no longer offer the observation actions.** "Bearing to" and "Distance CPL at" made no sense on a note — an annotation's position marks a warning area or remark, not a sightable object, and the menu already shows bearing & distance from both own-ship references. Note picks now keep only "Measure from here…" and "New note at…", and the menu headline drops the misleading "Add observation at …" framing (the note's title heads the surface instead). Picks on AIS targets, chart symbols and bare water are unchanged.
21
+ - **Logbook write-through now goes through the `logentries` Resources API** (in-process `app.resourcesApi`, the provider signalk-logbook registers) instead of POSTing to the logbook plugin's deprecated v1 REST routes. Entries are composed to the `logentries` schema — structured data as SI telemetry pathvalues (`navigation.position`, `navigation.log` in meters, headings in radians, speeds in m/s, sea state as Beaufort force on `environment.water.seaState`), free text unchanged. Confirmed fixes and watchkeeper observations are now stamped `origin: 'agent'` (machine writing on behalf of a human, per SPEC §9.5's original intent); tack/gybe entries stay 'auto'. Entry refs in `fixes` are now the resource UUID.
22
+ - **All logbook auth machinery removed.** In-process Resources access needs no tokens, so the Access Requests flow, approval polling, 401 provenance handling, and the `logbook.url` / `logbook.token` settings are gone — only `logbook.enabled` (plus the retry backoff and tack debounce) remains. Queued-entry delivery survives restarts as before; the pending queue now covers the provider-unavailable window (logbook absent or not yet started) instead of the approval-window.
23
+
24
+ ## [0.12.3] - 2026-10-03
25
+
26
+ ### Fixed
27
+ - **Tier-3 weather current now parses the fields providers actually
28
+ serve.** `parseWeatherCurrent` read the REST docs' shape
29
+ (`current.set` rad / `current.drift` m/s), but the Weather API server
30
+ transforms nothing — both the JS API (`app.weatherApi.getForecasts`)
31
+ and REST return the registered provider's data as-is — and the
32
+ providers on this install never emit that shape: the default GRIB
33
+ provider carries no currents at all, and signalk-weather-router-plus
34
+ answers `water.surfaceCurrentDirection` (rad, the set, towards) /
35
+ `water.surfaceCurrentSpeed` (m/s), the shape of `@signalk/server-api`'s
36
+ TS `WeatherData` interface (which contradicts the docs' TypeBox
37
+ `WeatherDataModel`). Every poll therefore died with "forecast carries
38
+ no current data" and DR silently ran on derived/zero current. The
39
+ parser now accepts both shapes (water.* preferred), verified against
40
+ the live server; wind-only entries without current coverage are
41
+ skipped so interpolation brackets between the entries that carry
42
+ current.
43
+
10
44
  ## [0.12.2] - 2026-09-28
11
45
 
12
46
  ### Fixed
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` (REST API) 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 `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.
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, -- datetime key of the corresponding signalk-logbook entry, if written
138
+ logbook_entry_ref TEXT, -- UUID of the corresponding signalk-logbook `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 API (REST, optional)
250
+ N2K/N0183 Ingest → Delta Router → signalk-logbook Resources API (in-process, optional)
251
251
  → GRIB Provider Service
252
252
  → AIS/ADS-B target tracking
253
253
 
@@ -262,7 +262,7 @@ Worker Thread (DR Physics)
262
262
  ```
263
263
 
264
264
  - Vector math and matrix lookups run isolated in a Worker Thread to avoid blocking the Signal K main event loop under high-frequency sensor input.
265
- - REST calls to `signalk-logbook` happen on the **main thread**, never the physics worker — network I/O has no place on the 1Hz hot path.
265
+ - Logbook writes go through `app.resourcesApi` (`logentries` provider) on the **main thread**, never the physics worker — provider I/O has no place on the 1Hz hot path.
266
266
  - The DR engine computes continuously regardless of mode (see §3.1, `navigation.deadReckoning.active`), so switching to OVERRIDE is a warm, instant authority handoff rather than a cold start.
267
267
  - A separate, explicitly user-triggered backfill job (§10) runs as a one-shot batch process, not on the live path.
268
268
 
@@ -394,7 +394,7 @@ 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: agent`, per the field mapping in §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 `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.
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.
@@ -402,22 +402,21 @@ The rate-of-turn detection already built for matrix-training suppression (§6.4)
402
402
 
403
403
  ### 9.5 `signalk-logbook` Field Mapping
404
404
 
405
- The logbook's `NewEntry`/`Entry` schema (OpenAPI, confirmed against the actual plugin) has no dedicated fix-type or celestial-specific fields — it's a general entry with a required free-text `text` plus optional structured fields. `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 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.
406
406
 
407
- | `fixes` / DR field | Logbook field | Notes |
407
+ | `fixes` / DR field | `logentries` field | Notes |
408
408
  |---|---|---|
409
- | `timestamp` | `datetime` | Use explicit `datetime`, not `ago` — confirmations can lag a DR-origin reset by more than the 15-minute cap `ago` allows. |
410
- | `latitude`/`longitude` | `position.latitude`/`position.longitude` | |
411
- | — | `position.source` | Free string: `"GPS"` / `"Celestial"` / `"Bearing"` / `"DR"`. |
409
+ | `timestamp` | `datetime` | Explicit RFC 3339 UTC, always — never rely on the provider's default-to-now (confirmations can lag a DR-origin reset by more than the enrichment buffer window). |
410
+ | `latitude`/`longitude` | `telemetry[]` pathvalue `navigation.position` | Value shape `{latitude, longitude, source}` — `source` is a logbook extra naming the fix origin, a free string: `"GPS"` / `"Celestial"` / `"Bearing"` / `"DR"`. |
412
411
  | celestial specifics (body, Hs, Ho, Hc, intercept, azimuth, index error) | `text` | No structured field exists for these — compose into the free-text summary, templated per `source_type`. |
413
412
  | `confirmed_by` | `author` | |
414
413
  | — | `category` | `"navigation"`. |
415
- | — | `origin` | `"agent"` for plugin-generated entries from a human-confirmed fix (not `"manual"` — no free text was typed by a human; not `"auto"` — a human did confirm the underlying observation). Convention adopted for this plugin; verify against how `origin: agent` is used elsewhere before finalizing. |
416
- | DR-integrated distance since last fix | `log` | Sourced from `navigation.deadReckoning.log`, not GPS-derived distance (§10.3), especially important during OVERRIDE. |
417
- | STW/SOG, heading/COG if available | `speed.stw`/`speed.sog`, `heading`/`course` | |
418
- | `environment.seaState`, if available | `observations.seaState` | Schema is `additionalProperties: false` — only populate defined fields, don't attempt to pass extra data through. |
414
+ | — | `origin` | `"agent"` for entries written on behalf of a human (confirmed fixes, watchkeeper-entered observations); `"auto"` for pure automation with no human in the loop (tack/gybe detection, §9.4). |
415
+ | DR-integrated distance since last fix | `telemetry[]` pathvalue `navigation.log` | Meters (SI; NM × 1852). Sourced from `navigation.deadReckoning.log`, not GPS-derived distance (§10.3), especially important during OVERRIDE. |
416
+ | STW/SOG, heading/COG if available | `telemetry[]` pathvalues `navigation.speedThroughWater`/`navigation.speedOverGround` (m/s; kn × 463/900), `navigation.headingTrue`/`navigation.courseOverGroundTrue` (rad true; deg × π/180) | |
417
+ | `environment.seaState`, if available | `telemetry[]` pathvalue `environment.water.seaState` | The resource path carries Beaufort force 0–12; the DR-stored value is the Douglas code 0–9 the logbook watch flow publishes — convert via the WMO correspondence at the boundary (mirroring the logbook's own mapping). |
419
418
 
420
- Writes are POSTed from the main thread (never the physics worker), gated on `signalk-logbook` being detected as installed at startup (optional peer, degrade gracefully if absent — `fixes` remains complete regardless), and always use explicit `datetime` rather than relying on retry/idempotency assumptions not yet confirmed from the plugin's actual behavior under connectivity loss.
419
+ Writes happen on the main thread (never the physics worker), gated on a registered `logentries` provider — probed with a minimal `listResources("logentries", {limit: 1})`, the one cheap complete-window query the contract's "no silent windows" rule allows; optional peer, degrade gracefully if absent — `fixes` remains complete regardless. Each write carries an explicit `datetime`, and `logbook_entry_ref` stores the resource UUID the provider assigned. Provider failures (absent or rejecting) queue the entry in the plugin DB and retry with backoff — nothing is lost while the provider is unavailable.
421
420
 
422
421
  ---
423
422
 
@@ -430,8 +429,8 @@ Two distinct jobs, both explicit, user-triggered, broadband-gated (Starlink) ope
430
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.
431
430
 
432
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).
433
- - **Logbook enrichment:** where `signalk-logbook` entries exist for the historical window (likely not the full 4–5 years), join by nearest-timestamp (~±30min) to recover `sail_state`/`observations.seaState` for spans that would otherwise fall to `'unknown'`. Historical `navigation`-category logbook entries with a `position` are additionally ingested into `fixes` (`source_type = 'backfill'`), so passage review isn't blank for the pre-plugin era.
434
- - **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 `log` fields are GPS-track-derived, not water-track, and must never be used as an STW proxy or ground truth during backfill.
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.
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.
435
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.
436
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.
437
436
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
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/current.js CHANGED
@@ -13,11 +13,17 @@
13
13
  * observation outranks model products (sea trial 2026-08-30→09-05:
14
14
  * ~11 nm DR error over 622 nm vs 47 nm for tier 3).
15
15
  * 3. **Signal K Weather API** (`/signalk/v2/api/weather/forecasts/point`)
16
- * — a provider (e.g. a GRIB another process already downloaded)
17
- * serves point forecasts carrying `current: {set (rad), drift (m/s)}`.
18
- * The client polls it at the vessel position on a slow interval and
19
- * caches the time-interpolated vector; the 1 Hz tick never touches
20
- * the network.
16
+ * — a weather provider carrying currents (e.g. signalk-weather-router-
17
+ * plus: Copernicus SMOC) answers point forecasts; the client polls it
18
+ * at the vessel position on a slow interval and caches the
19
+ * time-interpolated vector; the 1 Hz tick never touches the network.
20
+ * Current fields follow the provider: the REST docs' TypeBox model
21
+ * defines top-level `current.set` (rad, towards) / `current.drift`
22
+ * (m/s), while `@signalk/server-api`'s TS WeatherData and the real
23
+ * providers use `water.surfaceCurrentDirection` /
24
+ * `water.surfaceCurrentSpeed`. The server transforms nothing — the
25
+ * JS API and REST both return provider data as-is — so the parser
26
+ * accepts both shapes.
21
27
  * 4. Offline pilot charts (`offline_pilot_currents`) — table exists in
22
28
  * the schema; the lookup hook is reserved.
23
29
  * 5. **Zero vector** — pure inertial water track.
@@ -44,8 +50,11 @@ function normalizeDeg360(deg) {
44
50
 
45
51
  /**
46
52
  * Parses a Weather API forecast series into the current vector valid at
47
- * `nowMs`. Entries carrying `current.set` (rad, direction the current
48
- * flows *toward*) and `current.drift` (m/s) are interpolated as u/v
53
+ * `nowMs`. Entries carrying current data — `water.surfaceCurrentDirection`
54
+ * (rad, the direction the current flows *toward*) and
55
+ * `water.surfaceCurrentSpeed` (m/s), or the REST docs' top-level
56
+ * `current.set` / `current.drift` (same units, same semantics) — are
57
+ * interpolated as u/v
49
58
  * components between the entries bracketing `nowMs`; outside the series
50
59
  * the nearest endpoint is used (the client's TTL bounds staleness).
51
60
  *
@@ -59,8 +68,13 @@ function parseWeatherCurrent(points, nowMs) {
59
68
  const entries = [];
60
69
  for (const p of points) {
61
70
  const t = Date.parse(p?.date ?? "");
62
- const set = p?.current?.set;
63
- const drift = p?.current?.drift;
71
+ // Two shapes exist in the wild: the REST docs' TypeBox model (top-
72
+ // level current.set/drift) and the server-api TS WeatherData interface
73
+ // plus real providers (water.surfaceCurrent*). The server serves
74
+ // provider data untransformed on both the JS API and REST, so accept
75
+ // either; prefer the water.* fields.
76
+ const set = p?.water?.surfaceCurrentDirection ?? p?.current?.set;
77
+ const drift = p?.water?.surfaceCurrentSpeed ?? p?.current?.drift;
64
78
  if (!Number.isFinite(t)) continue;
65
79
  if (!Number.isFinite(set) || !Number.isFinite(drift)) continue;
66
80
  entries.push({ t, set, drift });
@@ -184,8 +198,14 @@ function resolveCurrent(input = {}) {
184
198
  * `/signalk/...` path. On servers without the Weather API the client
185
199
  * stays idle and the §6.2 resolver never sees tier 3.
186
200
  *
187
- * The response is a WeatherDataModel array; `current.set` is radians
188
- * and `current.drift` m/s (converted here to deg/kn).
201
+ * The response is a WeatherData array (the Signal K Weather API model).
202
+ * Currents appear either as `water.surfaceCurrentDirection` (rad, the
203
+ * set, towards) / `water.surfaceCurrentSpeed` (m/s) — what real
204
+ * providers like signalk-weather-router-plus serve — or as the REST
205
+ * docs' `current.set` / `current.drift` (same units); both are
206
+ * converted here to deg/kn. Entries without current coverage (e.g.
207
+ * wind-only steps, or points outside the provider's current sources)
208
+ * simply lack the fields and are skipped.
189
209
  */
190
210
  class WeatherCurrentClient {
191
211
  /**
package/plugin/db.js CHANGED
@@ -160,12 +160,13 @@ const SCHEMA_DDL = [
160
160
  severity TEXT NOT NULL
161
161
  )`,
162
162
 
163
- // §9.5: entries awaiting a logbook token (access-request approval
164
- // window, or token expiry mid-write). Persisted so a plugin restart
165
- // doesn't lose the approval-window entries; bounded by MAX_PENDING.
166
- // `fix_id` links a queued fix entry back to its `fixes` row so the
167
- // delayed flush can mark it logged (the confirm route's own .then
168
- // only fires when the write is immediate, not queued).
163
+ // §9.5: entries written while the logbook's `logentries` provider is
164
+ // unavailable (logbook absent, or not yet started — plugin start order
165
+ // isn't guaranteed). Persisted so a plugin restart doesn't lose them;
166
+ // bounded by MAX_PENDING. `fix_id` links a queued fix entry back to
167
+ // its `fixes` row so the delayed flush can mark it logged (the
168
+ // confirm route's own .then only fires when the write is immediate,
169
+ // not queued).
169
170
  `CREATE TABLE IF NOT EXISTS logbook_pending (
170
171
  pending_id INTEGER PRIMARY KEY AUTOINCREMENT,
171
172
  created_at TEXT NOT NULL,
@@ -604,7 +605,7 @@ function markFixLogged(db, fixId, logbookRef) {
604
605
  ).run(logbookRef ?? null, fixId);
605
606
  }
606
607
 
607
- /** Max queued logbook entries (approval window + retry storms). */
608
+ /** Max queued logbook entries (provider-unavailable window + retry storms). */
608
609
  const MAX_PENDING = 200;
609
610
 
610
611
  /**
@@ -27,6 +27,8 @@
27
27
  * @file derived-current.js
28
28
  */
29
29
 
30
+ const { normalizeDeg180 } = require("./geo.js");
31
+
30
32
  /** Minimum seconds between GPS fixes used for ground-vector sampling. */
31
33
  const MIN_GPS_INTERVAL_S = 5;
32
34
 
@@ -157,7 +159,10 @@ function updateDerivedCurrent(st, s) {
157
159
  const rad = Math.PI / 180;
158
160
  const latAvg = ((prev.latitude + s.gps.latitude) / 2) * rad;
159
161
  const meanLatNm = (s.gps.latitude - prev.latitude) * 60;
160
- const eastNm = (s.gps.longitude - prev.longitude) * 60 * Math.cos(latAvg);
162
+ // Longitude delta wrapped to ±180° so an antimeridian crossing
163
+ // (179.9995° → -179.9995°) measures 0.001° east, not ~360° west.
164
+ const eastNm =
165
+ normalizeDeg180(s.gps.longitude - prev.longitude) * 60 * Math.cos(latAvg);
161
166
  const distNm = Math.hypot(meanLatNm, eastNm);
162
167
  const sogKn = distNm / (dtS / 3600);
163
168
  if (sogKn > MAX_SOG_KN) {
package/plugin/fixes.js CHANGED
@@ -52,8 +52,12 @@ const {
52
52
  */
53
53
  function projectToLocal(center, p) {
54
54
  const lat0 = degToRad(center.latitude);
55
+ // Wrap the longitude delta to ±180° so points across the antimeridian
56
+ // from the center project the short way (0.2° apart, not 359.8°).
55
57
  const x =
56
- degToRad(p.longitude - center.longitude) * EARTH_RADIUS_M * Math.cos(lat0);
58
+ degToRad(normalizeDeg180(p.longitude - center.longitude)) *
59
+ EARTH_RADIUS_M *
60
+ Math.cos(lat0);
57
61
  const y = degToRad(p.latitude - center.latitude) * EARTH_RADIUS_M;
58
62
  return { x, y };
59
63
  }