@meri-imperiumi/signalk-dead-reckoning 0.6.0 → 0.7.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,170 @@ 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
+ ## [0.7.0] - 2026-08-29
9
+
10
+ ### Added
11
+ - **A single sight or bearing can now be resolved as a running fix
12
+ against the last confirmed fix.** The sun-run-sun economy — one sun
13
+ sight per day — previously stalled at every-other-day fixes: both
14
+ sights of a confirmed pair get attached to that fix and disappear
15
+ from the pending list, so the next day's lone sight had no partner to
16
+ resolve against. Selecting exactly one pending observation now
17
+ advances the last confirmed fix along the DR track (the shadow boat's
18
+ water-track integration, surviving restarts) to the observation time
19
+ and projects it onto the observation's constraint: cross-track comes
20
+ from the sight, along-track is trusted from the run. Works the same
21
+ for a celestial sight, a compass bearing when making landfall, or a
22
+ vertical-angle CPL (radial projection). In the pending list the
23
+ preview button reads "Running fix (1)" for a single selection;
24
+ selecting 2+ observations still resolves an ordinary fix. The
25
+ confirmed fix records its provenance in a new `derived_from_fix_id`
26
+ column (schema v2, migrated in place on open), the logbook entry
27
+ reads e.g. "Celestial fix from Sun LL sight (running fix, advanced
28
+ from fix #3)", and the map draws the previous-fix position, the DR
29
+ run vector, and the advanced point. Honest failures throughout: no
30
+ previous confirmed fix or a sight older than the latest fix → a 400
31
+ explaining what's missing; no DR-track coverage over the interval →
32
+ no fix, rather than silently projecting a stale fix position.
33
+
34
+ ### Fixed
35
+ - **Moon sights were unusable: the lunar ephemeris was off by up to 1.5°
36
+ in GHA** (~90 NM of LOP error) despite its stated "~0.1°" accuracy
37
+ — the two-term truncation was nowhere near that. Moon GHA/Dec,
38
+ semi-diameter and horizontal parallax are now computed by
39
+ astronomy-engine to arcsecond class, with the distance-driven SD/HP
40
+ varying correctly over the anomalistic month (the previously fixed
41
+ SD 0.2725°/HP 0.95° were up to ~2′ wrong near apogee). Pinned by
42
+ snapshot tests and sanity-checked against the Astronomical Almanac's
43
+ low-precision series.
44
+ - **DR no longer steers by raw magnetic heading when a variation source
45
+ is available.** The magnetic-heading fallback was used directly as
46
+ true heading, with no variation applied anywhere in the plugin — on
47
+ the Aitutaki–Niue route that would have steered the shadow boat
48
+ ~11° off (≈110 NM of lateral error over the passage) while
49
+ everything looked healthy. The plugin now subscribes to
50
+ `navigation.magneticVariation` (radians, east-positive) and applies it
51
+ to the fallback; `navigation.magneticVariation.source` (e.g.
52
+ "WMM 2025") is recorded and surfaced in `GET /status` as
53
+ `heading.mode` ("true" | "magnetic+variation" | "magnetic") plus
54
+ `heading.variationSource`, so the watchkeeper can verify what steers
55
+ DR instead of trusting it silently. A WMM-style source label past its
56
+ ~5-year model epoch ("WMM 2015") raises a plugin-status warning the
57
+ same way the star-almanac expiry does. Boats with no variation
58
+ source keep the old behavior (magnetic as-is) — visible as
59
+ `heading.mode: "magnetic"`.
60
+ - **The plugin no longer ingests its own published deltas.** `publish`
61
+ targets `vessels.self` — the same paths the plugin subscribes to — so
62
+ on a real server its output came straight back through the
63
+ subscription. Two consequences: the `headingTrue` it published
64
+ (which, on a magnetic-only boat, was the raw magnetic value) would
65
+ shadow the live heading and **freeze DR's steering at the first
66
+ tick's value** for the rest of the passage; and the same echo
67
+ pattern could close feedback loops on any other published path.
68
+ Self-sourced updates (matched on the publish source label) are now
69
+ skipped at ingestion, and the published `headingTrue` is the
70
+ computed true heading — bus value when present, magnetic + variation
71
+ otherwise — never raw magnetic. The fake app in tests never fed
72
+ handleMessage output back, which is why the loop was invisible
73
+ there; new tests simulate the echo explicitly.
74
+ - **The bundled star almanac's SHA values were wrong for several
75
+ stars, and mixed reference epochs.** Regulus was 12.5° off (~750 NM
76
+ of LOP error for anyone who sighted it), Hamal 2.9°, Polaris 3.1°,
77
+ Sirius/Schedar/Antares/Markab/Dubhe/Capella ~0.4° each; "Capella2"
78
+ was a duplicate of Capella; and the J2000 table was used with
79
+ of-date sidereal time, adding a systematic ~20′ (equinox precession)
80
+ to every star LOP by 2026. The table is now consistent J2000 mean
81
+ places — verified entry-by-entry against a Hipparcos-derived catalog
82
+ (d3-celestial's stars.6.json) to within ~0.5′ — and five southern-sky
83
+ navigational stars are bundled (Acrux, Hadar, Achernar, Miaplacidus,
84
+ Peacock): the South Pacific latitudes fix by the Southern Cross, not
85
+ Polaris. The of-date conversion is now the library's full
86
+ precession/nutation/aberration (see the ephemeris entry above).
87
+ - **The multi-fix resolver now actually converges on the least-squares
88
+ fix.** `leastSquaresFit()` — used for any fix combining three or more
89
+ lines/circles of position, and as the fallback when two don't
90
+ geometrically intersect — ran gradient descent with a fixed step size
91
+ that bounded its total travel to a few hundred metres from the DR
92
+ position, regardless of how far away the true answer was. With
93
+ realistic celestial intercepts (several to tens of nautical miles)
94
+ it stalled near the start point and reported it as the fix: a
95
+ symmetric three-star sight returned the same position for intercepts
96
+ from 0.1 to 50 nm, with only the residual growing in lockstep —
97
+ readable as a plausible cocked-hat spread. Lines-only problems are
98
+ now solved exactly via the normal equations (the same 2×2 solve the
99
+ two-LOP intersection uses, generalized to N lines), with a tiny
100
+ Tikhonov term so all-parallel lines return the midline point nearest
101
+ the DR instead of a degenerate solve; circles are refined by
102
+ damped Gauss-Newton (Levenberg-Marquardt) from the better of the
103
+ linear solution and the DR start, converging in a handful of
104
+ iterations and never returning a worse fit than it started with.
105
+ - **DR track interpolation crosses the antimeridian the short way.**
106
+ `GroundTrack.positionAt()` interpolated longitude linearly, so a
107
+ dateline crossing (179.9°E → 179.9°W) interpolated through
108
+ Greenwich instead of over ±180° — a running-fix advance taken
109
+ mid-crossing measured ~half the globe instead of the actual 0.2°
110
+ run. Longitude now interpolates the wrapped ±180° delta and the
111
+ result is normalized to [-180, 180).
112
+ - **Calibration matrix bins no longer blend the two tacks together in
113
+ light air.** The bin key folded AWA to |AWA| on the assumption that
114
+ the signed heel bin keeps the tacks apart — but at heel angles
115
+ below half the heel bin width both tacks quantize to `heel_bin 0`
116
+ (and a boat without a heel sensor lives there at every wind
117
+ strength), so opposite-tack observations with opposite-sign leeway
118
+ EMA-averaged toward zero in the same bin. `quantizeAwa()` now keeps
119
+ the sign: the AWA sign is the tack marker everywhere, and heel
120
+ remains the physics dimension. Bins written by earlier versions
121
+ under the folded key are simply retrained (fresh bins learn at full
122
+ rate); no migration is needed.
123
+
124
+ ### Changed
125
+ - **Celestial ephemerides are now computed by astronomy-engine** (MIT,
126
+ pure JS, zero sub-dependencies, fully offline — 1.8 MB installed):
127
+ Sun and Moon geocentric apparent places (nautical-almanac
128
+ convention: geocentric, coordinates of date, parallax applied as a
129
+ sight correction rather than baked into the GP), stars from the
130
+ bundled J2000 almanac converted to the date by the library's
131
+ precession/nutation/aberration. The hand-rolled formulas — verified
132
+ against an independent Meeus reduction when written, but
133
+ hand-maintained data and truncations forever — are retired in favor
134
+ of a maintained library; the trade is 1.8 MB of disk against a reef.
135
+ The unused suncalc runtime dependency is removed. The wiring is
136
+ pinned in tests/ephemeris.test.js by snapshots plus independent
137
+ anchors: a from-scratch Meeus reduction for the Sun (≤0.7′, the
138
+ anchor's own accuracy class), the AA low-precision series for the
139
+ Moon (≤20′), and paper-almanac star values.
140
+ - **The webapp's "Ghost Track" heading above the map is gone.** It
141
+ wasted vertical space the map could use — the map card now opens with
142
+ no chrome above it, so the chart starts higher on the page. The
143
+ "follow DR position" recenter button (◎) that lived on that heading
144
+ moved into the map view as a floating control in the bottom-left
145
+ corner (the last free corner: zoom is top-left, layers top-right, the
146
+ divergence chip bottom-right). It now reflects the follow state —
147
+ filled while auto-follow is on, outlined after a drag pans the map
148
+ freely, refilled on click — so a heading-less floating button still
149
+ reads its state at a glance.
150
+
151
+ ## [0.6.1] - 2026-08-28
152
+
153
+ ### Changed
154
+ - **Fix logbook entries now name the fix's sources instead of repeating
155
+ coordinates and the watchkeeper.** A confirmed fix was logged as
156
+ `Manual fix by bergie: 18°51'55.5" S 159°48'04.5" W, 0.1 nm from DR`
157
+ — but the coordinates are already in the structured `position` field
158
+ and the watchkeeper in `author`, so the text wasted both while
159
+ omitting the useful part: *what produced the fix*. A fix resolved
160
+ from compass bearings now reads `Manual fix from Aitutaki Atoll
161
+ bearing 123°T, Vessel Foo bearing 321°T, 0.1 NM at 045°T from DR`;
162
+ celestial fixes list the bodies sighted; CPL fixes list the object
163
+ and radius. Point fixes (GPS / manual / backfill) drop the
164
+ coordinates and the `by {user}` clause, keeping just the label and
165
+ the DR deviation. The deviation clause now carries the direction
166
+ (`0.5 NM at 090°T from DR`) and uses `NM` (Nautical Miles) rather
167
+ than `nm` (nanometers). `backfill` fixes get their own label and
168
+ `position.source` value instead of falling through to "Manual"/"DR".
169
+ Bearings are degrees-true (`°T`); magnetic (`°M`) would require a
170
+ magnetic-variation source, which isn't subscribed yet.
171
+
8
172
  ## [0.6.0] - 2026-08-28
9
173
 
10
174
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.6.0",
3
+ "version": "0.7.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,24 @@ 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}
57
- */
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
-
69
- /**
70
- * Reduces an ecliptic longitude/latitude to right ascension (degrees).
61
+ * Reduces an ecliptic longitude/latitude to right ascension (degrees),
62
+ * for a given obliquity (degrees).
71
63
  *
72
64
  * @param {number} lonDeg - ecliptic longitude, degrees
73
65
  * @param {number} latDeg - ecliptic latitude, degrees (0 for the Sun)
66
+ * @param {number} [epsDeg=OBLIQUITY_DEG] - obliquity of the ecliptic
74
67
  * @returns {number} RA, degrees
75
68
  */
76
- function raFromEcliptic(lonDeg, latDeg) {
77
- const e = OBLIQUITY_DEG * RAD;
69
+ function raFromEcliptic(lonDeg, latDeg, epsDeg = OBLIQUITY_DEG) {
70
+ const e = epsDeg * RAD;
78
71
  const l = lonDeg * RAD;
79
72
  const b = latDeg * RAD;
80
73
  const ra = Math.atan2(
@@ -85,14 +78,16 @@ function raFromEcliptic(lonDeg, latDeg) {
85
78
  }
86
79
 
87
80
  /**
88
- * Reduces an ecliptic longitude/latitude to declination (degrees).
81
+ * Reduces an ecliptic longitude/latitude to declination (degrees), for
82
+ * a given obliquity (degrees).
89
83
  *
90
84
  * @param {number} lonDeg
91
85
  * @param {number} latDeg
86
+ * @param {number} [epsDeg=OBLIQUITY_DEG] - obliquity of the ecliptic
92
87
  * @returns {number} declination, degrees [-90, 90]
93
88
  */
94
- function decFromEcliptic(lonDeg, latDeg) {
95
- const e = OBLIQUITY_DEG * RAD;
89
+ function decFromEcliptic(lonDeg, latDeg, epsDeg = OBLIQUITY_DEG) {
90
+ const e = epsDeg * RAD;
96
91
  const l = lonDeg * RAD;
97
92
  const b = latDeg * RAD;
98
93
  const dec = Math.asin(
@@ -102,71 +97,96 @@ function decFromEcliptic(lonDeg, latDeg) {
102
97
  }
103
98
 
104
99
  /**
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).
100
+ * Geocentric apparent GHA/Dec from true-ecliptic-of-date coordinates.
101
+ * The obliquity (true, of date) and Greenwich apparent sidereal time
102
+ * both come from the library, so nutation is included on both sides of
103
+ * GHA = GAST − RA — the nautical-almanac convention.
109
104
  *
110
105
  * @param {number} epochMs
106
+ * @param {number} lonDeg - ecliptic longitude of date, degrees
107
+ * @param {number} latDeg - ecliptic latitude of date, degrees
111
108
  * @returns {{gha_deg: number, declination_deg: number}}
112
109
  */
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 };
110
+ function ghaDecFromEcliptic(epochMs, lonDeg, latDeg) {
111
+ const date = new Date(epochMs);
112
+ const eps = Astronomy.e_tilt(Astronomy.MakeTime(date)).tobl;
113
+ const ra = raFromEcliptic(lonDeg, latDeg, eps);
114
+ const dec = decFromEcliptic(lonDeg, latDeg, eps);
115
+ const gastDeg = Astronomy.SiderealTime(date) * 15;
116
+ return { gha_deg: normalizeDeg360(gastDeg - ra), declination_deg: dec };
126
117
  }
127
118
 
128
119
  /**
129
- * Moon's geographic position at a timestamp: GHA and declination.
120
+ * Sun's geographic position at a timestamp: GHA, declination, and the
121
+ * apparent semi-diameter from the true Earth–Sun distance (varies
122
+ * 0.263–0.274° over the year — the fixed mean left up to ±0.3′).
130
123
  *
131
- * Low-precision lunar position (good to ~0.1°). Same source as suncalc.
124
+ * @param {number} epochMs
125
+ * @returns {{gha_deg: number, declination_deg: number, semi_diameter_deg: number}}
126
+ */
127
+ function sunGeographicPosition(epochMs) {
128
+ const ecl = Astronomy.SunPosition(new Date(epochMs));
129
+ const distKm = ecl.vec.Length() * Astronomy.KM_PER_AU;
130
+ return {
131
+ ...ghaDecFromEcliptic(epochMs, ecl.elon, ecl.elat),
132
+ semi_diameter_deg: Math.asin(SUN_RADIUS_KM / distKm) * DEG,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Moon's geographic position at a timestamp: GHA, declination, plus the
138
+ * semi-diameter and horizontal parallax from the true distance. The
139
+ * distance varies ±5.5% over the anomalistic month — the previously
140
+ * fixed SD 0.2725°/HP 0.95° were up to ~2′ wrong near apogee.
132
141
  *
133
142
  * @param {number} epochMs
134
- * @returns {{gha_deg: number, declination_deg: number}}
143
+ * @returns {{gha_deg: number, declination_deg: number, semi_diameter_deg: number, horizontal_parallax_deg: number}}
135
144
  */
136
145
  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 };
146
+ const ecl = Astronomy.EclipticGeoMoon(new Date(epochMs));
147
+ const distKm = ecl.dist * Astronomy.KM_PER_AU;
148
+ return {
149
+ ...ghaDecFromEcliptic(epochMs, ecl.lon, ecl.lat),
150
+ semi_diameter_deg: Math.asin(MOON_RADIUS_KM / distKm) * DEG,
151
+ horizontal_parallax_deg: Math.asin(EARTH_EQ_RADIUS_KM / distKm) * DEG,
152
+ };
157
153
  }
158
154
 
159
155
  /**
160
- * A star's geographic position, given GHA Aries (GMST) and the star's
161
- * SHA + declination from the bundled almanac.
156
+ * A star's geographic position at a timestamp: GHA and declination.
157
+ * The bundled almanac's J2000 mean place is registered with the library
158
+ * (`DefineStar`), which returns the apparent place of date — full
159
+ * precession, nutation and aberration.
160
+ *
161
+ * `DefineStar` mutates its slot globally; the single-threaded
162
+ * sight-reduction path re-registers per lookup, which is fine for
163
+ * per-sight (rare) events, not per-tick use.
162
164
  *
163
165
  * @param {number} epochMs
164
166
  * @param {{sha_deg: number, declination_deg: number}} star
165
167
  * @returns {{gha_deg: number, declination_deg: number}}
166
168
  */
167
169
  function starGeographicPosition(epochMs, star) {
168
- const gha = normalizeDeg360(gmstDeg(epochMs) + star.sha_deg);
169
- return { gha_deg: gha, declination_deg: star.declination_deg };
170
+ const date = new Date(epochMs);
171
+ const raHours = (360 - normalizeDeg360(star.sha_deg)) / 15;
172
+ Astronomy.DefineStar(
173
+ Astronomy.Body.Star1,
174
+ raHours,
175
+ star.declination_deg,
176
+ 100,
177
+ );
178
+ const eq = Astronomy.Equator(
179
+ Astronomy.Body.Star1,
180
+ date,
181
+ new Astronomy.Observer(0, 0, 0),
182
+ true,
183
+ true,
184
+ );
185
+ const gastDeg = Astronomy.SiderealTime(date) * 15;
186
+ return {
187
+ gha_deg: normalizeDeg360(gastDeg - eq.ra * 15),
188
+ declination_deg: eq.dec,
189
+ };
170
190
  }
171
191
 
172
192
  /**
@@ -312,20 +332,21 @@ function reduceSight(input) {
312
332
  const body = input.body;
313
333
  if (body === "Sun") {
314
334
  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;
335
+ // Apparent semi-diameter from the true Earth–Sun distance
336
+ // (0.263–0.274° over the year).
337
+ const sd = gp.semi_diameter_deg;
318
338
  semiDiameterDeg =
319
339
  input.limb === "upper" ? -sd : input.limb === "lower" ? sd : null;
320
340
  } else if (body === "Moon") {
321
341
  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;
342
+ // Semi-diameter and horizontal parallax from the true distance
343
+ // (varies ±5.5% over the anomalistic month). Parallax-in-altitude
344
+ // correction = HP·cos(Ha).
345
+ const sd = gp.semi_diameter_deg;
325
346
  semiDiameterDeg =
326
347
  input.limb === "upper" ? -sd : input.limb === "lower" ? sd : null;
327
348
  const ha = (input.hs_deg + (input.index_correction_deg ?? 0)) * RAD;
328
- parallaxDeg = 0.95 * Math.cos(ha); // horizontal parallax scaled by cos(Ha)
349
+ parallaxDeg = gp.horizontal_parallax_deg * Math.cos(ha);
329
350
  } else if (input.almanac) {
330
351
  const star = input.almanac.lookup(body);
331
352
  if (!star) throw new Error(`unknown body: ${body}`);
@@ -463,7 +484,6 @@ function reduceNoonSight(input) {
463
484
  }
464
485
 
465
486
  module.exports = {
466
- gmstDeg,
467
487
  raFromEcliptic,
468
488
  decFromEcliptic,
469
489
  sunGeographicPosition,
package/plugin/db.js CHANGED
@@ -17,8 +17,10 @@ const { DatabaseSync } = require("node:sqlite");
17
17
  const { dirname } = require("node:path");
18
18
  const { mkdirSync } = require("node:fs");
19
19
 
20
- /** Schema version, bumped when a migration is needed. Persisted in dr_state_store. */
21
- const SCHEMA_VERSION = 1;
20
+ /** Schema version, bumped when a migration is needed. Persisted in dr_state_store.
21
+ * v2: `fixes.derived_from_fix_id` — provenance for single-observation
22
+ * running fixes advanced from a previous confirmed fix. */
23
+ const SCHEMA_VERSION = 2;
22
24
 
23
25
  /**
24
26
  * DDL for every table in SPEC §4. Statements run in order; all are
@@ -71,6 +73,7 @@ const SCHEMA_DDL = [
71
73
  logged_to_logbook BOOLEAN NOT NULL DEFAULT 0,
72
74
  logbook_entry_ref TEXT,
73
75
  resets_dr_origin BOOLEAN NOT NULL DEFAULT 0,
76
+ derived_from_fix_id INTEGER,
74
77
  notes TEXT
75
78
  )`,
76
79
 
@@ -190,6 +193,15 @@ function openDatabase(dbPath) {
190
193
  for (const stmt of SCHEMA_DDL) {
191
194
  db.exec(stmt);
192
195
  }
196
+ // v1 → v2: running-fix provenance column on `fixes`. Fresh databases
197
+ // get the column from the DDL above; existing ones are altered in
198
+ // place. The stored version is read *after* the DDL (it creates
199
+ // dr_state_store on a fresh database, where there is nothing to
200
+ // migrate) and *before* the new version is recorded below.
201
+ const storedVersion = getState(db, "schema_version");
202
+ if (storedVersion != null && Number(storedVersion) < 2) {
203
+ db.exec("ALTER TABLE fixes ADD COLUMN derived_from_fix_id INTEGER");
204
+ }
193
205
  // Record schema version so future migrations can branch on it.
194
206
  setState(db, "schema_version", String(SCHEMA_VERSION));
195
207
  return db;
@@ -286,6 +298,8 @@ function recordCorrection(db, r) {
286
298
  * @param {number} [r.estimated_error_radius]
287
299
  * @param {string} [r.confirmed_by]
288
300
  * @param {boolean} [r.resets_dr_origin]
301
+ * @param {number} [r.derived_from_fix_id] - the fix this running fix was
302
+ * advanced from (single-observation running fix)
289
303
  * @param {string} [r.notes]
290
304
  * @returns {number} inserted fix_id
291
305
  */
@@ -293,8 +307,9 @@ function recordFix(db, r) {
293
307
  const stmt = db.prepare(
294
308
  `INSERT INTO fixes (
295
309
  timestamp, source_type, latitude, longitude,
296
- estimated_error_radius, confirmed_by, resets_dr_origin, notes
297
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
310
+ estimated_error_radius, confirmed_by, resets_dr_origin,
311
+ derived_from_fix_id, notes
312
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
298
313
  );
299
314
  const info = stmt.run(
300
315
  r.timestamp,
@@ -304,6 +319,7 @@ function recordFix(db, r) {
304
319
  r.estimated_error_radius ?? null,
305
320
  r.confirmed_by ?? null,
306
321
  r.resets_dr_origin ? 1 : 0,
322
+ r.derived_from_fix_id ?? null,
307
323
  r.notes ?? null,
308
324
  );
309
325
  return Number(info.lastInsertRowid);
@@ -518,7 +534,8 @@ function listFixes(db, q = {}) {
518
534
  return db
519
535
  .prepare(
520
536
  `SELECT fix_id, timestamp, source_type, latitude, longitude,
521
- estimated_error_radius, confirmed_by, resets_dr_origin
537
+ estimated_error_radius, confirmed_by, resets_dr_origin,
538
+ derived_from_fix_id
522
539
  FROM fixes ORDER BY fix_id DESC LIMIT ?`,
523
540
  )
524
541
  .all(q.limit ?? 100);
@@ -713,7 +730,7 @@ function getFix(db, id) {
713
730
  .prepare(
714
731
  `SELECT fix_id, timestamp, source_type, latitude, longitude,
715
732
  estimated_error_radius, confirmed_by, resets_dr_origin,
716
- notes, logged_to_logbook, logbook_entry_ref
733
+ notes, logged_to_logbook, logbook_entry_ref, derived_from_fix_id
717
734
  FROM fixes WHERE fix_id = ?`,
718
735
  )
719
736
  .get(id);