@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 +164 -0
- package/package.json +4 -4
- package/plugin/bins.js +14 -6
- package/plugin/celestial.js +104 -84
- package/plugin/db.js +23 -6
- package/plugin/fix-pipeline.js +68 -1
- package/plugin/fixes.js +266 -35
- package/plugin/ground-track.js +10 -3
- package/plugin/index.js +190 -17
- package/plugin/logbook.js +120 -26
- package/plugin/star-almanac.js +39 -27
- package/public/dr-app.js +0 -4
- package/public/dr-map-view.js +62 -4
- package/public/dr-pending-list.js +12 -5
- package/tests/bins.test.js +20 -5
- package/tests/celestial.test.js +79 -43
- package/tests/db.test.js +72 -0
- 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 +177 -1
- package/tests/fixes.test.js +182 -22
- package/tests/ground-track.test.js +30 -0
- package/tests/logbook.test.js +151 -8
- package/tests/matrix.test.js +45 -0
- package/tests/plugin.test.js +321 -1
- package/tests/star-almanac.test.js +30 -0
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.
|
|
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,
|
|
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,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
|
-
*
|
|
52
|
-
*
|
|
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 =
|
|
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 =
|
|
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
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
|
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 };
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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 };
|
|
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
|
|
161
|
-
*
|
|
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
|
|
169
|
-
|
|
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
|
-
//
|
|
316
|
-
//
|
|
317
|
-
const sd =
|
|
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
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
297
|
-
|
|
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);
|