@meri-imperiumi/signalk-dead-reckoning 0.6.1 → 0.8.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
@@ -5,6 +5,244 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.8.0] - 2026-09-09
11
+
12
+ ### Fixed
13
+ - **Celestial sights are no longer silently forced through the noon
14
+ (meridian-altitude) reducer** (sea trial 2026-08-31, Aitutaki→Niue:
15
+ both Sun sights reduced to −69.3°/−31.9°). The sight panel's
16
+ `readForm()` read checkboxes via `el.value` — always the string
17
+ `"on"` regardless of checked state — so every sight was POSTed with
18
+ `noon: true`. Checkboxes are now read via `el.checked` (both panels).
19
+ - **The webapp no longer overwrites its own-vessel GPS position with
20
+ the DR shadow boat's** (sea trial: the map boat flipped ~50 NM
21
+ between GPS and the ghost, and fix #9 recorded the ghost position as
22
+ a "GNSS" fix 87.5 km from the boat). Deltas whose context is the
23
+ shadow vessel now route to neither the AIS target store nor the
24
+ own-vessel view-model.
25
+
26
+ ### Changed
27
+ - **The uncertainty cone now grows with current-knowledge**, not just
28
+ distance run (sea trial: 88 km of DR-vs-GPS divergence while the
29
+ polygon "expected" ~2 NM). Radius combines the distance-run error
30
+ and a per-tier current residual (manual 0.25 kn, weather/pilot
31
+ 0.3 kn, zero-vector 1.0 kn) root-sum-square; the fallback margin
32
+ rose from 1° to 4° per NM run (measured open-loop rates were
33
+ 7–12× the old value); the empirical rate is now a median with a
34
+ 2 kn per-row cap (the spec's original intent — a garbage 3058 NM
35
+ correction previously poisoned an EWMA at "145 kn"); the floor rose
36
+ to 0.05 NM and the origin's own error radius seeds it (a celestial
37
+ fix is realistically ~5 nm — the cone never claims GPS confidence
38
+ below that).
39
+ - **The divergence advisory no longer flaps after every fix or print
40
+ "0.27 nm exceeds expected 0.27 nm"**: the exceedance deadband is
41
+ instrument-scale (0.005 NM) instead of float epsilon, and the alert
42
+ message states the exceedance margin.
43
+ - **"Tack/gybe in progress" no longer latches open for the whole
44
+ passage** in ss3–4 seaway: rate-of-turn is measured over a 6 s
45
+ rolling window (a single second of wave yaw can't open it), the
46
+ re-stabilization tolerances scale with sea state, and a window still
47
+ open after 5 minutes force-closes without classifying a maneuver.
48
+ The pre-maneuver AWA for tack/gybe classification is now taken from
49
+ the base of the ROT window (the previous tick's AWA has already
50
+ flipped with the bow by the time the window opens).
51
+
52
+ ### Added
53
+ - **Passage replay backtest tool** (`tools/replay-passage.js`, minimal
54
+ SPEC §10.2 scope): replays a historical passage from the Signal K
55
+ History API through the real DR engine and reports how the shadow
56
+ boat diverged from GPS. Fetches a range in chunks at native 10s
57
+ resolution, forward-fills sparse sensors (unwrapping the signed AWA
58
+ across ±π through gybes), queries `navigation.attitude.roll`
59
+ (radians) directly — the attitude object itself is not recorded, only
60
+ its component paths — so heel reaches the matrix bins, seeds the DR
61
+ origin at the first GPS fix and never re-anchors it. Four variants run side by side: cold (no
62
+ training, tier-5 zero current), learning (Training Mode on, mirroring
63
+ a first live passage), plus the same two with a SCUD satellite-derived
64
+ current source — daily 0.25° fields from the PacIOOS ERDDAP,
65
+ nearest-cell in space, time-interpolated over source holes (and held
66
+ at the span ends), resolved at the shadow boat's own position. Also
67
+ derives the implied current (ground vector minus water-track vector)
68
+ as a diagnostic of what DR is missing. Outputs an hourly divergence
69
+ table, an implied-vs-SCUD current comparison, a GeoJSON track file,
70
+ full-resolution samples, and a self-contained SVG HTML report. A
71
+ `--stw-scale` option multiplies STW end-to-end (engine, training,
72
+ matrix lookup) to test paddlewheel-calibration hypotheses against a
73
+ passage.
74
+ Smoketests cover URL building, row parsing/filling, the current-grid
75
+ interpolation, and three synthetic passages (no-drift, known-current,
76
+ leeway absorbed by training).
77
+ - **Observation submission is speed-plausibility-gated**: a sight,
78
+ bearing or vertical-angle observation whose reduction implies the
79
+ vessel traveled faster than 50 kn since the last fix (e.g. the
80
+ trial's 3058 NM Antarctica sight ≈ 140 kn) is rejected with an
81
+ explanatory message in the form, so the user can fix the entry.
82
+ Displacements within realistic observation quality (~5 NM) always
83
+ pass.
84
+ - **Noon sights carry meridian and sanity guards**: a sight whose Sun
85
+ is more than 20° from the meridian (LHA), or whose computed latitude
86
+ lands more than 12° from the assumed/DR position, is refused rather
87
+ than reduced into garbage.
88
+ - **Fix confirmation carries a gross-displacement guard**: confirming
89
+ a fix more than 100 NM (configurable, `fixes.maxDisplacementNm`)
90
+ from the current DR origin is rejected with 422 unless the request
91
+ carries `force: true`; `/fix/resolve` previews the candidate's
92
+ displacement and flags gross candidates.
93
+ - **Raw sight inputs are persisted** (`raw_hs_deg`, index correction,
94
+ eye height, limb, computed Ho/Hc on lines_of_position; angle/height
95
+ on circular_position_lines) so reductions can be re-run and
96
+ backtested — reconstructing the trial's sights from the stored
97
+ noon results required algebraic archaeology.
98
+ - **`elapsedSinceOriginS` and the origin error radius survive plugin
99
+ restarts** (persisted alongside `last_known_good_fix`), so "since
100
+ last fix" and the cone no longer reset mid-excursion on a restart.
101
+
102
+ ## [0.7.0] - 2026-08-29
103
+
104
+ ### Added
105
+ - **A single sight or bearing can now be resolved as a running fix
106
+ against the last confirmed fix.** The sun-run-sun economy — one sun
107
+ sight per day — previously stalled at every-other-day fixes: both
108
+ sights of a confirmed pair get attached to that fix and disappear
109
+ from the pending list, so the next day's lone sight had no partner to
110
+ resolve against. Selecting exactly one pending observation now
111
+ advances the last confirmed fix along the DR track (the shadow boat's
112
+ water-track integration, surviving restarts) to the observation time
113
+ and projects it onto the observation's constraint: cross-track comes
114
+ from the sight, along-track is trusted from the run. Works the same
115
+ for a celestial sight, a compass bearing when making landfall, or a
116
+ vertical-angle CPL (radial projection). In the pending list the
117
+ preview button reads "Running fix (1)" for a single selection;
118
+ selecting 2+ observations still resolves an ordinary fix. The
119
+ confirmed fix records its provenance in a new `derived_from_fix_id`
120
+ column (schema v2, migrated in place on open), the logbook entry
121
+ reads e.g. "Celestial fix from Sun LL sight (running fix, advanced
122
+ from fix #3)", and the map draws the previous-fix position, the DR
123
+ run vector, and the advanced point. Honest failures throughout: no
124
+ previous confirmed fix or a sight older than the latest fix → a 400
125
+ explaining what's missing; no DR-track coverage over the interval →
126
+ no fix, rather than silently projecting a stale fix position.
127
+
128
+ ### Fixed
129
+ - **Moon sights were unusable: the lunar ephemeris was off by up to 1.5°
130
+ in GHA** (~90 NM of LOP error) despite its stated "~0.1°" accuracy
131
+ — the two-term truncation was nowhere near that. Moon GHA/Dec,
132
+ semi-diameter and horizontal parallax are now computed by
133
+ astronomy-engine to arcsecond class, with the distance-driven SD/HP
134
+ varying correctly over the anomalistic month (the previously fixed
135
+ SD 0.2725°/HP 0.95° were up to ~2′ wrong near apogee). Pinned by
136
+ snapshot tests and sanity-checked against the Astronomical Almanac's
137
+ low-precision series.
138
+ - **DR no longer steers by raw magnetic heading when a variation source
139
+ is available.** The magnetic-heading fallback was used directly as
140
+ true heading, with no variation applied anywhere in the plugin — on
141
+ the Aitutaki–Niue route that would have steered the shadow boat
142
+ ~11° off (≈110 NM of lateral error over the passage) while
143
+ everything looked healthy. The plugin now subscribes to
144
+ `navigation.magneticVariation` (radians, east-positive) and applies it
145
+ to the fallback; `navigation.magneticVariation.source` (e.g.
146
+ "WMM 2025") is recorded and surfaced in `GET /status` as
147
+ `heading.mode` ("true" | "magnetic+variation" | "magnetic") plus
148
+ `heading.variationSource`, so the watchkeeper can verify what steers
149
+ DR instead of trusting it silently. A WMM-style source label past its
150
+ ~5-year model epoch ("WMM 2015") raises a plugin-status warning the
151
+ same way the star-almanac expiry does. Boats with no variation
152
+ source keep the old behavior (magnetic as-is) — visible as
153
+ `heading.mode: "magnetic"`.
154
+ - **The plugin no longer ingests its own published deltas.** `publish`
155
+ targets `vessels.self` — the same paths the plugin subscribes to — so
156
+ on a real server its output came straight back through the
157
+ subscription. Two consequences: the `headingTrue` it published
158
+ (which, on a magnetic-only boat, was the raw magnetic value) would
159
+ shadow the live heading and **freeze DR's steering at the first
160
+ tick's value** for the rest of the passage; and the same echo
161
+ pattern could close feedback loops on any other published path.
162
+ Self-sourced updates (matched on the publish source label) are now
163
+ skipped at ingestion, and the published `headingTrue` is the
164
+ computed true heading — bus value when present, magnetic + variation
165
+ otherwise — never raw magnetic. The fake app in tests never fed
166
+ handleMessage output back, which is why the loop was invisible
167
+ there; new tests simulate the echo explicitly.
168
+ - **The bundled star almanac's SHA values were wrong for several
169
+ stars, and mixed reference epochs.** Regulus was 12.5° off (~750 NM
170
+ of LOP error for anyone who sighted it), Hamal 2.9°, Polaris 3.1°,
171
+ Sirius/Schedar/Antares/Markab/Dubhe/Capella ~0.4° each; "Capella2"
172
+ was a duplicate of Capella; and the J2000 table was used with
173
+ of-date sidereal time, adding a systematic ~20′ (equinox precession)
174
+ to every star LOP by 2026. The table is now consistent J2000 mean
175
+ places — verified entry-by-entry against a Hipparcos-derived catalog
176
+ (d3-celestial's stars.6.json) to within ~0.5′ — and five southern-sky
177
+ navigational stars are bundled (Acrux, Hadar, Achernar, Miaplacidus,
178
+ Peacock): the South Pacific latitudes fix by the Southern Cross, not
179
+ Polaris. The of-date conversion is now the library's full
180
+ precession/nutation/aberration (see the ephemeris entry above).
181
+ - **The multi-fix resolver now actually converges on the least-squares
182
+ fix.** `leastSquaresFit()` — used for any fix combining three or more
183
+ lines/circles of position, and as the fallback when two don't
184
+ geometrically intersect — ran gradient descent with a fixed step size
185
+ that bounded its total travel to a few hundred metres from the DR
186
+ position, regardless of how far away the true answer was. With
187
+ realistic celestial intercepts (several to tens of nautical miles)
188
+ it stalled near the start point and reported it as the fix: a
189
+ symmetric three-star sight returned the same position for intercepts
190
+ from 0.1 to 50 nm, with only the residual growing in lockstep —
191
+ readable as a plausible cocked-hat spread. Lines-only problems are
192
+ now solved exactly via the normal equations (the same 2×2 solve the
193
+ two-LOP intersection uses, generalized to N lines), with a tiny
194
+ Tikhonov term so all-parallel lines return the midline point nearest
195
+ the DR instead of a degenerate solve; circles are refined by
196
+ damped Gauss-Newton (Levenberg-Marquardt) from the better of the
197
+ linear solution and the DR start, converging in a handful of
198
+ iterations and never returning a worse fit than it started with.
199
+ - **DR track interpolation crosses the antimeridian the short way.**
200
+ `GroundTrack.positionAt()` interpolated longitude linearly, so a
201
+ dateline crossing (179.9°E → 179.9°W) interpolated through
202
+ Greenwich instead of over ±180° — a running-fix advance taken
203
+ mid-crossing measured ~half the globe instead of the actual 0.2°
204
+ run. Longitude now interpolates the wrapped ±180° delta and the
205
+ result is normalized to [-180, 180).
206
+ - **Calibration matrix bins no longer blend the two tacks together in
207
+ light air.** The bin key folded AWA to |AWA| on the assumption that
208
+ the signed heel bin keeps the tacks apart — but at heel angles
209
+ below half the heel bin width both tacks quantize to `heel_bin 0`
210
+ (and a boat without a heel sensor lives there at every wind
211
+ strength), so opposite-tack observations with opposite-sign leeway
212
+ EMA-averaged toward zero in the same bin. `quantizeAwa()` now keeps
213
+ the sign: the AWA sign is the tack marker everywhere, and heel
214
+ remains the physics dimension. Bins written by earlier versions
215
+ under the folded key are simply retrained (fresh bins learn at full
216
+ rate); no migration is needed.
217
+
218
+ ### Changed
219
+ - **Celestial ephemerides are now computed by astronomy-engine** (MIT,
220
+ pure JS, zero sub-dependencies, fully offline — 1.8 MB installed):
221
+ Sun and Moon geocentric apparent places (nautical-almanac
222
+ convention: geocentric, coordinates of date, parallax applied as a
223
+ sight correction rather than baked into the GP), stars from the
224
+ bundled J2000 almanac converted to the date by the library's
225
+ precession/nutation/aberration. The hand-rolled formulas — verified
226
+ against an independent Meeus reduction when written, but
227
+ hand-maintained data and truncations forever — are retired in favor
228
+ of a maintained library; the trade is 1.8 MB of disk against a reef.
229
+ The unused suncalc runtime dependency is removed. The wiring is
230
+ pinned in tests/ephemeris.test.js by snapshots plus independent
231
+ anchors: a from-scratch Meeus reduction for the Sun (≤0.7′, the
232
+ anchor's own accuracy class), the AA low-precision series for the
233
+ Moon (≤20′), and paper-almanac star values.
234
+
235
+ - **The webapp's "Ghost Track" heading above the map is gone.** It
236
+ wasted vertical space the map could use — the map card now opens with
237
+ no chrome above it, so the chart starts higher on the page. The
238
+ "follow DR position" recenter button (◎) that lived on that heading
239
+ moved into the map view as a floating control in the bottom-left
240
+ corner (the last free corner: zoom is top-left, layers top-right, the
241
+ divergence chip bottom-right). It now reflects the follow state —
242
+ filled while auto-follow is on, outlined after a drag pans the map
243
+ freely, refilled on click — so a heading-less floating button still
244
+ reads its state at a glance.
245
+
8
246
  ## [0.6.1] - 2026-08-28
9
247
 
10
248
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -45,10 +45,10 @@
45
45
  "engines": {
46
46
  "node": ">=22.5.0"
47
47
  },
48
- "dependencies": {
49
- "suncalc": "^1.9.0"
50
- },
51
48
  "devDependencies": {
52
49
  "@signalk/server-api": "^2.30.0"
50
+ },
51
+ "dependencies": {
52
+ "astronomy-engine": "^2.1.19"
53
53
  }
54
54
  }
package/plugin/bins.js CHANGED
@@ -10,6 +10,8 @@
10
10
  * @file bins.js
11
11
  */
12
12
 
13
+ const { normalizeDeg180 } = require("./geo.js");
14
+
13
15
  /** STW bin width in knots (SPEC §4.1 example: nearest 0.5kt). */
14
16
  const STW_BIN_WIDTH = 0.5;
15
17
 
@@ -46,18 +48,24 @@ function quantizeStw(stwKn) {
46
48
  }
47
49
 
48
50
  /**
49
- * Quantizes apparent wind angle (degrees, absolute) to the matrix bin.
51
+ * Quantizes apparent wind angle (degrees, signed) to the matrix bin.
50
52
  *
51
- * AWA is taken as an absolute angle (port/starboard symmetry holds for the
52
- * leeway/speed-loss physics the matrix learns), so we fold to [0, 180]
53
- * before quantizing.
53
+ * The sign is kept — it is the tack marker. The learned leeway angle
54
+ * is signed (leeward-positive), so port-tack and starboard-tack
55
+ * observations must never EMA-average into the same bin: their leeway
56
+ * signs are opposite and would blend toward zero. Heel sign separates
57
+ * the tacks while the boat heels, but at light-air heel (< half the
58
+ * heel bin width) both tacks quantize to heel_bin 0 — and a boat with
59
+ * no heel sensor lives there at every wind strength — so folding AWA
60
+ * to |AWA| would collapse the tacks exactly there. The input is
61
+ * normalized to [-180, 180) first so 0–360-style angles bin
62
+ * identically to their signed forms.
54
63
  *
55
64
  * @param {number} awaDeg - apparent wind angle in degrees (signed ok)
56
65
  * @returns {number}
57
66
  */
58
67
  function quantizeAwa(awaDeg) {
59
- const folded = Math.min(Math.abs(awaDeg), 180);
60
- return quantize(folded, AWA_BIN_WIDTH);
68
+ return quantize(normalizeDeg180(awaDeg), AWA_BIN_WIDTH);
61
69
  }
62
70
 
63
71
  /**
@@ -22,13 +22,16 @@
22
22
  * offset by the intercept — exactly the form `recordLineOfPosition`
23
23
  * and the fix resolver expect.
24
24
  *
25
- * Ephemeris (SPEC §13):
26
- * - Sun and Moon: computed locally from the timestamp via standard
27
- * formulas (the same `aa.quae.nl` derivation suncalc uses, but we
28
- * need GHA/declination, which suncalc's public API doesn't expose).
29
- * - Stars: from a bundled static almanac (plugin/star-almanac.js),
30
- * GHA = GHA Aries + SHA, declination from the almanac. Proper motion
31
- * is negligible over the almanac's stated valid epoch (§13).
25
+ * Ephemeris (SPEC §13): computed by astronomy-engine (MIT, pure JS,
26
+ * zero sub-dependencies, fully offline) — Sun and Moon geocentric
27
+ * apparent places, stars from the bundled J2000 almanac (verified
28
+ * against a Hipparcos-derived catalog) converted to the date by the
29
+ * library's precession/nutation/aberration. The nautical-almanac
30
+ * convention throughout is GEOCENTRIC, coordinates of date; parallax
31
+ * is applied as a separate sight correction (step 3), never baked into
32
+ * the GP. Library accuracy is arcsecond-class — far below sextant
33
+ * precision — and is pinned by paper-almanac anchors in
34
+ * tests/ephemeris.test.js.
32
35
  *
33
36
  * The module is pure: no DB, no I/O, no `new Date()` (the sight time is
34
37
  * passed in explicitly so reduction is deterministic and testable).
@@ -38,6 +41,7 @@
38
41
  * @file celestial.js
39
42
  */
40
43
 
44
+ const Astronomy = require("astronomy-engine");
41
45
  const { normalizeDeg360 } = require("./geo.js");
42
46
 
43
47
  /** Radians per degree. */
@@ -46,35 +50,40 @@ const RAD = Math.PI / 180;
46
50
  const DEG = 180 / Math.PI;
47
51
  /** Mean obliquity of the ecliptic (J2000), degrees. */
48
52
  const OBLIQUITY_DEG = 23.4397;
53
+ /** Sun radius, km (IAU nominal). */
54
+ const SUN_RADIUS_KM = 695700;
55
+ /** Moon radius, km. */
56
+ const MOON_RADIUS_KM = 1737.4;
57
+ /** Earth equatorial radius, km. */
58
+ const EARTH_EQ_RADIUS_KM = 6378.14;
49
59
 
50
60
  /**
51
- * Greenwich Mean Sidereal Time in degrees [0, 360), for a given UTC
52
- * timestamp. Standard USNO formula; used to turn SHA → GHA for stars and
53
- * RA → GHA for Sun/Moon.
54
- *
55
- * @param {number} epochMs - Date.getTime() value (UTC ms)
56
- * @returns {number}
61
+ /**
62
+ * Maximum distance of the Sun's LHA from the meridian (deg) for a
63
+ * sight to be accepted as a noon sight (sea trial 2026-08-31 guard).
64
+ * ~20° ≈ 80 minutes from transit — generous versus the ~30 min a
65
+ * navigator would actually shoot around LAN, but far below the ~100°
66
+ * the trial's 07:05-local sights carried.
57
67
  */
58
- function gmstDeg(epochMs) {
59
- const jd = epochMs / 86400000 + 2440587.5;
60
- const T = (jd - 2451545.0) / 36525;
61
- const g =
62
- 280.46061837 +
63
- 360.98564736629 * (jd - 2451545.0) +
64
- 0.000387933 * T * T -
65
- (T * T * T) / 38710000;
66
- return normalizeDeg360(g);
67
- }
68
+ const NOON_MAX_LHA_DEG = 20;
69
+
70
+ /**
71
+ * Maximum computed-vs-assumed latitude discrepancy (deg) accepted from
72
+ * a noon sight before it is refused as implausible (~720 NM).
73
+ */
74
+ const NOON_MAX_LAT_DELTA_DEG = 12;
68
75
 
69
76
  /**
70
- * Reduces an ecliptic longitude/latitude to right ascension (degrees).
77
+ * Reduces an ecliptic longitude/latitude to right ascension (degrees),
78
+ * for a given obliquity (degrees).
71
79
  *
72
80
  * @param {number} lonDeg - ecliptic longitude, degrees
73
81
  * @param {number} latDeg - ecliptic latitude, degrees (0 for the Sun)
82
+ * @param {number} [epsDeg=OBLIQUITY_DEG] - obliquity of the ecliptic
74
83
  * @returns {number} RA, degrees
75
84
  */
76
- function raFromEcliptic(lonDeg, latDeg) {
77
- const e = OBLIQUITY_DEG * RAD;
85
+ function raFromEcliptic(lonDeg, latDeg, epsDeg = OBLIQUITY_DEG) {
86
+ const e = epsDeg * RAD;
78
87
  const l = lonDeg * RAD;
79
88
  const b = latDeg * RAD;
80
89
  const ra = Math.atan2(
@@ -85,14 +94,16 @@ function raFromEcliptic(lonDeg, latDeg) {
85
94
  }
86
95
 
87
96
  /**
88
- * Reduces an ecliptic longitude/latitude to declination (degrees).
97
+ * Reduces an ecliptic longitude/latitude to declination (degrees), for
98
+ * a given obliquity (degrees).
89
99
  *
90
100
  * @param {number} lonDeg
91
101
  * @param {number} latDeg
102
+ * @param {number} [epsDeg=OBLIQUITY_DEG] - obliquity of the ecliptic
92
103
  * @returns {number} declination, degrees [-90, 90]
93
104
  */
94
- function decFromEcliptic(lonDeg, latDeg) {
95
- const e = OBLIQUITY_DEG * RAD;
105
+ function decFromEcliptic(lonDeg, latDeg, epsDeg = OBLIQUITY_DEG) {
106
+ const e = epsDeg * RAD;
96
107
  const l = lonDeg * RAD;
97
108
  const b = latDeg * RAD;
98
109
  const dec = Math.asin(
@@ -102,71 +113,96 @@ function decFromEcliptic(lonDeg, latDeg) {
102
113
  }
103
114
 
104
115
  /**
105
- * Sun's geographic position at a timestamp: GHA and declination.
106
- *
107
- * Derived from the low-precision solar position formulas (good to ~0.01°,
108
- * ample for sextant work). Same source as suncalc (aa.quae.nl).
116
+ * Geocentric apparent GHA/Dec from true-ecliptic-of-date coordinates.
117
+ * The obliquity (true, of date) and Greenwich apparent sidereal time
118
+ * both come from the library, so nutation is included on both sides of
119
+ * GHA = GAST − RA — the nautical-almanac convention.
109
120
  *
110
121
  * @param {number} epochMs
122
+ * @param {number} lonDeg - ecliptic longitude of date, degrees
123
+ * @param {number} latDeg - ecliptic latitude of date, degrees
111
124
  * @returns {{gha_deg: number, declination_deg: number}}
112
125
  */
113
- function sunGeographicPosition(epochMs) {
114
- const d = epochMs / 86400000 - 10957.5; // days since J2000
115
- const M = normalizeDeg360(357.5291 + 0.98560028 * d); // mean anomaly
116
- const C =
117
- 1.9148 * Math.sin(M * RAD) +
118
- 0.02 * Math.sin(2 * M * RAD) +
119
- 0.0003 * Math.sin(3 * M * RAD); // equation of center
120
- const lon = normalizeDeg360(280.4665 + 0.9856474 * d + C); // true ecliptic longitude
121
- const ra = raFromEcliptic(lon, 0);
122
- const dec = decFromEcliptic(lon, 0);
123
- // GHA = GMST − RA (Greenwich hour angle of the body).
124
- const gha = normalizeDeg360(gmstDeg(epochMs) - ra);
125
- return { gha_deg: gha, declination_deg: dec };
126
+ function ghaDecFromEcliptic(epochMs, lonDeg, latDeg) {
127
+ const date = new Date(epochMs);
128
+ const eps = Astronomy.e_tilt(Astronomy.MakeTime(date)).tobl;
129
+ const ra = raFromEcliptic(lonDeg, latDeg, eps);
130
+ const dec = decFromEcliptic(lonDeg, latDeg, eps);
131
+ const gastDeg = Astronomy.SiderealTime(date) * 15;
132
+ return { gha_deg: normalizeDeg360(gastDeg - ra), declination_deg: dec };
126
133
  }
127
134
 
128
135
  /**
129
- * Moon's geographic position at a timestamp: GHA and declination.
136
+ * Sun's geographic position at a timestamp: GHA, declination, and the
137
+ * apparent semi-diameter from the true Earth–Sun distance (varies
138
+ * 0.263–0.274° over the year — the fixed mean left up to ±0.3′).
130
139
  *
131
- * Low-precision lunar position (good to ~0.1°). Same source as suncalc.
140
+ * @param {number} epochMs
141
+ * @returns {{gha_deg: number, declination_deg: number, semi_diameter_deg: number}}
142
+ */
143
+ function sunGeographicPosition(epochMs) {
144
+ const ecl = Astronomy.SunPosition(new Date(epochMs));
145
+ const distKm = ecl.vec.Length() * Astronomy.KM_PER_AU;
146
+ return {
147
+ ...ghaDecFromEcliptic(epochMs, ecl.elon, ecl.elat),
148
+ semi_diameter_deg: Math.asin(SUN_RADIUS_KM / distKm) * DEG,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Moon's geographic position at a timestamp: GHA, declination, plus the
154
+ * semi-diameter and horizontal parallax from the true distance. The
155
+ * distance varies ±5.5% over the anomalistic month — the previously
156
+ * fixed SD 0.2725°/HP 0.95° were up to ~2′ wrong near apogee.
132
157
  *
133
158
  * @param {number} epochMs
134
- * @returns {{gha_deg: number, declination_deg: number}}
159
+ * @returns {{gha_deg: number, declination_deg: number, semi_diameter_deg: number, horizontal_parallax_deg: number}}
135
160
  */
136
161
  function moonGeographicPosition(epochMs) {
137
- const d = epochMs / 86400000 - 10957.5; // days since J2000
138
- const L = normalizeDeg360(218.316 + 13.176396 * d); // mean longitude
139
- const M = normalizeDeg360(134.963 + 13.064993 * d); // mean anomaly
140
- const F = normalizeDeg360(93.272 + 13.22935 * d); // argument of latitude
141
- // Ecliptic longitude with the two largest correction terms.
142
- const lon =
143
- L +
144
- 6.289 * Math.sin(M * RAD) -
145
- 1.274 * Math.sin((2 * L - 2 * M) * RAD) +
146
- 0.658 * Math.sin(2 * F * RAD) -
147
- 0.186 * Math.sin((2 * L - 2 * M + 2 * F) * RAD);
148
- // Ecliptic latitude (small, from F).
149
- const lat =
150
- 5.128 * Math.sin(F * RAD) +
151
- 0.28 * Math.sin((M + F) * RAD) -
152
- 0.28 * Math.sin((M - F) * RAD);
153
- const ra = raFromEcliptic(lon, lat);
154
- const dec = decFromEcliptic(lon, lat);
155
- const gha = normalizeDeg360(gmstDeg(epochMs) - ra);
156
- return { gha_deg: gha, declination_deg: dec };
162
+ const ecl = Astronomy.EclipticGeoMoon(new Date(epochMs));
163
+ const distKm = ecl.dist * Astronomy.KM_PER_AU;
164
+ return {
165
+ ...ghaDecFromEcliptic(epochMs, ecl.lon, ecl.lat),
166
+ semi_diameter_deg: Math.asin(MOON_RADIUS_KM / distKm) * DEG,
167
+ horizontal_parallax_deg: Math.asin(EARTH_EQ_RADIUS_KM / distKm) * DEG,
168
+ };
157
169
  }
158
170
 
159
171
  /**
160
- * A star's geographic position, given GHA Aries (GMST) and the star's
161
- * SHA + declination from the bundled almanac.
172
+ * A star's geographic position at a timestamp: GHA and declination.
173
+ * The bundled almanac's J2000 mean place is registered with the library
174
+ * (`DefineStar`), which returns the apparent place of date — full
175
+ * precession, nutation and aberration.
176
+ *
177
+ * `DefineStar` mutates its slot globally; the single-threaded
178
+ * sight-reduction path re-registers per lookup, which is fine for
179
+ * per-sight (rare) events, not per-tick use.
162
180
  *
163
181
  * @param {number} epochMs
164
182
  * @param {{sha_deg: number, declination_deg: number}} star
165
183
  * @returns {{gha_deg: number, declination_deg: number}}
166
184
  */
167
185
  function starGeographicPosition(epochMs, star) {
168
- const gha = normalizeDeg360(gmstDeg(epochMs) + star.sha_deg);
169
- return { gha_deg: gha, declination_deg: star.declination_deg };
186
+ const date = new Date(epochMs);
187
+ const raHours = (360 - normalizeDeg360(star.sha_deg)) / 15;
188
+ Astronomy.DefineStar(
189
+ Astronomy.Body.Star1,
190
+ raHours,
191
+ star.declination_deg,
192
+ 100,
193
+ );
194
+ const eq = Astronomy.Equator(
195
+ Astronomy.Body.Star1,
196
+ date,
197
+ new Astronomy.Observer(0, 0, 0),
198
+ true,
199
+ true,
200
+ );
201
+ const gastDeg = Astronomy.SiderealTime(date) * 15;
202
+ return {
203
+ gha_deg: normalizeDeg360(gastDeg - eq.ra * 15),
204
+ declination_deg: eq.dec,
205
+ };
170
206
  }
171
207
 
172
208
  /**
@@ -312,20 +348,21 @@ function reduceSight(input) {
312
348
  const body = input.body;
313
349
  if (body === "Sun") {
314
350
  gp = sunGeographicPosition(epochMs);
315
- // Sun semi-diameter ≈ 0.2666° (varies ~0.263–0.274). Use the mean;
316
- // the variation is below sextant-reading precision for v1.
317
- const sd = 0.2666;
351
+ // Apparent semi-diameter from the true Earth–Sun distance
352
+ // (0.263–0.274° over the year).
353
+ const sd = gp.semi_diameter_deg;
318
354
  semiDiameterDeg =
319
355
  input.limb === "upper" ? -sd : input.limb === "lower" ? sd : null;
320
356
  } else if (body === "Moon") {
321
357
  gp = moonGeographicPosition(epochMs);
322
- // Moon semi-diameter ≈ 0.2725° (mean; HP ≈ 0.95°). Parallax-in-altitude
323
- // correction ≈ HP * cos(Ha); included as a rough mean for v1.
324
- const sd = 0.2725;
358
+ // Semi-diameter and horizontal parallax from the true distance
359
+ // (varies ±5.5% over the anomalistic month). Parallax-in-altitude
360
+ // correction = HP·cos(Ha).
361
+ const sd = gp.semi_diameter_deg;
325
362
  semiDiameterDeg =
326
363
  input.limb === "upper" ? -sd : input.limb === "lower" ? sd : null;
327
364
  const ha = (input.hs_deg + (input.index_correction_deg ?? 0)) * RAD;
328
- parallaxDeg = 0.95 * Math.cos(ha); // horizontal parallax scaled by cos(Ha)
365
+ parallaxDeg = gp.horizontal_parallax_deg * Math.cos(ha);
329
366
  } else if (input.almanac) {
330
367
  const star = input.almanac.lookup(body);
331
368
  if (!star) throw new Error(`unknown body: ${body}`);
@@ -416,6 +453,18 @@ function reduceNoonSight(input) {
416
453
  const gp = sunGeographicPosition(epochMs);
417
454
  const dec = gp.declination_deg;
418
455
 
456
+ // 1b. Meridian-transit guard (sea trial 2026-08-31): a sight taken
457
+ // hours from local noon reduces to garbage latitudes (both trial
458
+ // sights were morning sights that landed at −69°/−32°). LHA is 0
459
+ // (or 360) exactly on the meridian; refuse sights far from transit.
460
+ const lhaDeg = normalizeDeg360(gp.gha_deg + assumed.longitude);
461
+ const fromMeridianDeg = Math.min(lhaDeg, 360 - lhaDeg);
462
+ if (fromMeridianDeg > NOON_MAX_LHA_DEG) {
463
+ throw new Error(
464
+ `Sun is ${fromMeridianDeg.toFixed(0)}° from the meridian (LHA ${lhaDeg.toFixed(1)}°) at the sight time — not a noon sight. Uncheck "Noon sight" for an intercept-method reduction, or verify the sight time`,
465
+ );
466
+ }
467
+
419
468
  // 2. Ho from Hs (same corrections as a normal sight; limb sights apply).
420
469
  const sd = 0.2666;
421
470
  const semiDiameterDeg =
@@ -441,6 +490,17 @@ function reduceNoonSight(input) {
441
490
  const sunSouth = dec < assumed.latitude;
442
491
  const latitude = sunSouth ? dec + z : dec - z;
443
492
 
493
+ // 3b. Computed-vs-assumed sanity (sea trial 2026-08-31): even a sight
494
+ // near transit can be mis-entered (wrong limb, IC, time). A computed
495
+ // latitude a whole ocean away from the assumed/DR position is far
496
+ // more likely a bad sight than a 700 NM DR error — refuse it.
497
+ const latDeltaDeg = Math.abs(latitude - assumed.latitude);
498
+ if (latDeltaDeg > NOON_MAX_LAT_DELTA_DEG) {
499
+ throw new Error(
500
+ `Noon sight latitude ${latitude.toFixed(1)}° is ${latDeltaDeg.toFixed(1)}° from the assumed ${assumed.latitude.toFixed(1)}° — check the sight altitude, limb and index correction`,
501
+ );
502
+ }
503
+
444
504
  // Azimuth: 180° (due south) when the Sun is south of the observer,
445
505
  // 0° (due north) when it's north. Either yields an east-west LOP.
446
506
  const azimuth = sunSouth ? 180 : 0;
@@ -463,7 +523,8 @@ function reduceNoonSight(input) {
463
523
  }
464
524
 
465
525
  module.exports = {
466
- gmstDeg,
526
+ NOON_MAX_LHA_DEG,
527
+ NOON_MAX_LAT_DELTA_DEG,
467
528
  raFromEcliptic,
468
529
  decFromEcliptic,
469
530
  sunGeographicPosition,