@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 +238 -0
- package/package.json +4 -4
- package/plugin/bins.js +14 -6
- package/plugin/celestial.js +142 -81
- package/plugin/db.js +115 -10
- package/plugin/divergence.js +7 -5
- package/plugin/engine.js +11 -2
- package/plugin/fix-pipeline.js +144 -5
- package/plugin/fixes.js +266 -35
- package/plugin/ground-track.js +10 -3
- package/plugin/index.js +319 -21
- package/plugin/logbook.js +9 -3
- package/plugin/star-almanac.js +39 -27
- package/plugin/training.js +112 -21
- package/plugin/uncertainty.js +128 -78
- package/public/dr-app.js +9 -7
- package/public/dr-fix-panel.js +5 -2
- package/public/dr-map-view.js +62 -4
- package/public/dr-pending-list.js +12 -5
- package/public/dr-sight-panel.js +9 -1
- package/tests/bins.test.js +20 -5
- package/tests/celestial.test.js +146 -43
- package/tests/db.test.js +72 -0
- package/tests/divergence.test.js +15 -5
- package/tests/dr-recenter.test.js +65 -0
- package/tests/dr-viewmodel.test.js +35 -0
- package/tests/ephemeris.test.js +266 -0
- package/tests/fix-pipeline.test.js +230 -1
- package/tests/fixes.test.js +182 -22
- package/tests/ground-track.test.js +30 -0
- package/tests/logbook.test.js +33 -0
- package/tests/matrix.test.js +45 -0
- package/tests/plugin.test.js +354 -20
- package/tests/replay-passage.test.js +318 -0
- package/tests/star-almanac.test.js +30 -0
- package/tests/training.test.js +332 -176
- package/tests/uncertainty.test.js +154 -85
- package/tools/replay-passage.js +1090 -0
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.
|
|
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,
|
|
51
|
+
* Quantizes apparent wind angle (degrees, signed) to the matrix bin.
|
|
50
52
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
|
|
60
|
-
return quantize(folded, AWA_BIN_WIDTH);
|
|
68
|
+
return quantize(normalizeDeg180(awaDeg), AWA_BIN_WIDTH);
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
/**
|
package/plugin/celestial.js
CHANGED
|
@@ -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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
161
|
-
*
|
|
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
|
|
169
|
-
|
|
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
|
-
//
|
|
316
|
-
//
|
|
317
|
-
const sd =
|
|
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
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
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 =
|
|
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
|
-
|
|
526
|
+
NOON_MAX_LHA_DEG,
|
|
527
|
+
NOON_MAX_LAT_DELTA_DEG,
|
|
467
528
|
raFromEcliptic,
|
|
468
529
|
decFromEcliptic,
|
|
469
530
|
sunGeographicPosition,
|