Skip to content

Commit 07d776e

Browse files
committed
fix(libastro): use apparent sidereal time in EquatorialToHorizontal; add accuracy tests
EquatorialToHorizontal called ln_get_hrz_from_equ which uses mean sidereal time internally, while HorizontalToEquatorial's ln_get_equ_from_hrz uses apparent sidereal time. The mismatch introduced a fixed ~17" RA offset (equation of the equinoxes) in every round-trip. Fix: call ln_get_hrz_from_equ_sidereal_time with ln_get_apparent_sidereal_time(JD) explicitly, matching the inverse path. Five tests document the accuracy of the libnova coordinate pipeline: Reciprocity: J2000toObserved -> ObservedToJ2000 closes to 0.004" for Deneb at two epochs (2020, 2026). HorizontalAccuracy_vs_IMCCE: measures accuracy against IMCCE Miriade (INPOP19) for Vega and Polaris at four sites (Greenwich, Mitaka, Mauna Kea, Siding Spring). Observed errors: Vega: az 5-12", alt 3-6" Polaris: az 18-31", alt 3-11" Polaris az errors are larger because polar distance is only 0.74°: a nutation- in-RA error is amplified ~1/sin(0.74°) ≈ 77x when mapped to azimuth. The underlying error is the IAU 1980 nutation accuracy floor of libnova, common to both stars. Per-case errors are logged; the assertion catches gross regressions which cause degree-level errors. ObserverLongitudeMatters: Vega altitude spans > 30° across sites covering 316° of longitude. ObserverLatitudeMatters: Polaris is above the horizon at Greenwich (51.5 N) and below it at Siding Spring (31.3 S). RoundTrip_HorizontalToEquatorial: after the sidereal time fix, the round-trip closes to sub-picosecond (was ~11" RA before the fix). Golden data generated by tools/generate_golden_files.py (IMCCE Miriade, INPOP19).
1 parent 653016c commit 07d776e

5 files changed

Lines changed: 514 additions & 4 deletions

File tree

libs/indicore/libastro.cpp

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
#include <libnova/aberration.h>
4040
#include <libnova/transform.h>
4141
#include <libnova/nutation.h>
42+
#include <libnova/sidereal_time.h>
4243

4344
namespace INDI
4445
{
@@ -145,7 +146,11 @@ void EquatorialToHorizontal(IEquatorialCoordinates *object, IGeographicCoordinat
145146
// RA Hours --> Degrees
146147
struct ln_equ_posn libnova_object = {object->rightascension * 15.0, object->declination};
147148
struct ln_hrz_posn horizontalPos;
148-
ln_get_hrz_from_equ(&libnova_object, &libnova_location, JD, &horizontalPos);
149+
// Use apparent sidereal time to match ln_get_equ_from_hrz (which already uses apparent).
150+
// ln_get_hrz_from_equ uses mean sidereal time — a libnova bug that introduces a ~17" RA
151+
// offset (equation of the equinoxes) in the round-trip.
152+
double apparentSidereal = ln_get_apparent_sidereal_time(JD);
153+
ln_get_hrz_from_equ_sidereal_time(&libnova_object, &libnova_location, apparentSidereal, &horizontalPos);
149154
position->azimuth = range360(180 + horizontalPos.az);
150155
position->altitude = horizontalPos.alt;
151156
}
@@ -161,6 +166,7 @@ void HorizontalToEquatorial(IHorizontalCoordinates *object, IGeographicCoordinat
161166
// Convert from INDI standard location to libnova standard location
162167
struct ln_hrz_posn libnova_object = {range360(object->azimuth + 180), object->altitude};
163168
struct ln_equ_posn equatorialPos;
169+
// ln_get_equ_from_hrz uses apparent sidereal time, matching EquatorialToHorizontal above.
164170
ln_get_equ_from_hrz(&libnova_object, &libnova_location, JD, &equatorialPos);
165171
// Degrees --> Hours
166172
position->rightascension = equatorialPos.ra / 15.0;

test/core/CMakeLists.txt

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
2-
31
SET (test_base64_SRCS
42
test_base64.cpp
53
)
@@ -34,7 +32,7 @@ SET (test_rotator_limits_SRCS
3432
ADD_EXECUTABLE(test_rotator_limits
3533
${test_rotator_limits_SRCS}
3634
)
37-
TARGET_LINK_LIBRARIES(test_rotator_limits
35+
TARGET_LINK_LIBRARIES(test_rotator_limits
3836
${GTEST_BOTH_LIBRARIES}
3937
${GMOCK_LIBRARIES}
4038
${CMAKE_THREAD_LIBS_INIT}
@@ -52,3 +50,18 @@ TARGET_LINK_LIBRARIES(test_libnova_nutation
5250
${CMAKE_THREAD_LIBS_INIT}
5351
)
5452
ADD_TEST(test_libnova_nutation test_libnova_nutation)
53+
54+
SET (test_libastro_SRCS
55+
test_libastro.cpp
56+
)
57+
ADD_EXECUTABLE(test_libastro ${test_libastro_SRCS})
58+
TARGET_LINK_LIBRARIES(test_libastro
59+
indidriver
60+
${GTEST_BOTH_LIBRARIES}
61+
${GMOCK_LIBRARIES}
62+
${CMAKE_THREAD_LIBS_INIT}
63+
)
64+
TARGET_COMPILE_DEFINITIONS(test_libastro PRIVATE
65+
TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/test/data"
66+
)
67+
ADD_TEST(test_libastro test_libastro)

test/core/test_libastro.cpp

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/*******************************************************************************
2+
* Tests for libastro coordinate functions (libnova backend)
3+
*
4+
* Tests for the libastro coordinate pipeline against external truth (IMCCE Miriade)
5+
* and internal round-trip consistency.
6+
*
7+
* Golden data: test/data/horizontal_golden.json
8+
* 8 cases: Vega and Polaris at Greenwich, Mitaka, Mauna Kea, Siding Spring.
9+
* Source: IMCCE Miriade (INPOP19), -tcoor=5 (horizontal coordinates).
10+
* Regenerate with: python3 tools/generate_golden_files.py
11+
*******************************************************************************/
12+
13+
#include <gtest/gtest.h>
14+
#include <libastro.h>
15+
#include <nlohmann/json.hpp>
16+
#include <fstream>
17+
#include <string>
18+
#include <vector>
19+
#include <cmath>
20+
21+
// ---------------------------------------------------------------------------
22+
// Golden data loader
23+
// ---------------------------------------------------------------------------
24+
25+
struct HorizCase {
26+
std::string label;
27+
std::string object;
28+
double ra_j2000_h;
29+
double dec_j2000_deg;
30+
double jd;
31+
std::string site;
32+
double lon_deg;
33+
double lat_deg;
34+
double elev_m;
35+
double az_truth;
36+
double alt_truth;
37+
};
38+
39+
static std::vector<HorizCase> load_golden()
40+
{
41+
std::ifstream f(TEST_DATA_DIR "/horizontal_golden.json");
42+
if (!f.is_open()) return {};
43+
nlohmann::json j = nlohmann::json::parse(f);
44+
std::vector<HorizCase> cases;
45+
for (auto &e : j) {
46+
if (!e.contains("az_deg")) continue;
47+
HorizCase c;
48+
c.label = e["label"];
49+
c.object = e["object"];
50+
c.ra_j2000_h = e["ra_j2000_h"];
51+
c.dec_j2000_deg = e["dec_j2000_deg"];
52+
c.jd = e["jd"];
53+
c.site = e["site"];
54+
c.lon_deg = e["lon_deg"];
55+
c.lat_deg = e["lat_deg"];
56+
c.elev_m = e["elev_m"];
57+
c.az_truth = e["az_deg"];
58+
c.alt_truth = e["alt_deg"];
59+
cases.push_back(c);
60+
}
61+
return cases;
62+
}
63+
64+
static double az_diff_arcsec(double a, double b) {
65+
double d = a - b;
66+
while (d > 180.0) d -= 360.0;
67+
while (d < -180.0) d += 360.0;
68+
return std::abs(d) * 3600.0;
69+
}
70+
71+
// ---------------------------------------------------------------------------
72+
// J2000toObserved -> ObservedToJ2000 round-trip
73+
// The libnova forward and inverse paths must close to < 1 arcsecond.
74+
// A large error here indicates a broken nutation or precession inversion.
75+
// ---------------------------------------------------------------------------
76+
TEST(Libastro, Reciprocity)
77+
{
78+
// Use two different epochs to exercise different precession amounts.
79+
struct { double jd; const char *label; } epochs[] = {
80+
{ 2459019.833333, "2020-06-19" },
81+
{ 2461112.5, "2026-01-01" },
82+
};
83+
INDI::IEquatorialCoordinates j2000_in = { 20.69053168, 45.28033881 }; // Deneb
84+
85+
for (auto &ep : epochs) {
86+
INDI::IEquatorialCoordinates jnow, j2000_out;
87+
INDI::J2000toObserved(&j2000_in, ep.jd, &jnow);
88+
INDI::ObservedToJ2000(&jnow, ep.jd, &j2000_out);
89+
90+
double cos_dec = std::cos(j2000_in.declination * M_PI / 180.0);
91+
double ra_err = std::abs(j2000_in.rightascension - j2000_out.rightascension) * 15.0 * 3600.0 * cos_dec;
92+
double dec_err = std::abs(j2000_in.declination - j2000_out.declination) * 3600.0;
93+
94+
GTEST_LOG_(INFO) << ep.label << " round-trip: RA=" << ra_err << "\" Dec=" << dec_err << "\"";
95+
EXPECT_LT(ra_err, 1.0) << "Round-trip RA error at " << ep.label;
96+
EXPECT_LT(dec_err, 1.0) << "Round-trip Dec error at " << ep.label;
97+
}
98+
}
99+
100+
// ---------------------------------------------------------------------------
101+
// EquatorialToHorizontal accuracy vs IMCCE Miriade (INPOP19) truth.
102+
// Per-case errors are logged; the assertion catches gross regressions
103+
// (wrong observer, wrong sidereal time, etc.) which cause degree-level errors.
104+
// ---------------------------------------------------------------------------
105+
TEST(Libastro, HorizontalAccuracy_vs_IMCCE)
106+
{
107+
static constexpr double TOLERANCE_ARCSEC = 60.0;
108+
109+
auto cases = load_golden();
110+
ASSERT_GT(cases.size(), 0u) << "Could not load horizontal_golden.json";
111+
112+
double max_az_err = 0, max_alt_err = 0;
113+
int n = 0;
114+
for (auto &c : cases) {
115+
INDI::IEquatorialCoordinates j2000 = { c.ra_j2000_h, c.dec_j2000_deg };
116+
INDI::IEquatorialCoordinates jnow;
117+
INDI::J2000toObserved(&j2000, c.jd, &jnow);
118+
119+
INDI::IGeographicCoordinates obs = { c.lon_deg, c.lat_deg, c.elev_m };
120+
INDI::IHorizontalCoordinates hrz;
121+
INDI::EquatorialToHorizontal(&jnow, &obs, c.jd, &hrz);
122+
123+
double az_err = az_diff_arcsec(hrz.azimuth, c.az_truth);
124+
double alt_err = std::abs(hrz.altitude - c.alt_truth) * 3600.0;
125+
max_az_err = std::max(max_az_err, az_err);
126+
max_alt_err = std::max(max_alt_err, alt_err);
127+
128+
GTEST_LOG_(INFO) << c.object << " at " << c.site
129+
<< " az_err=" << az_err << "\" alt_err=" << alt_err << "\"";
130+
EXPECT_LT(az_err, TOLERANCE_ARCSEC)
131+
<< "Az error for " << c.object << " at " << c.site
132+
<< ": " << az_err << "\" (truth=" << c.az_truth << " got=" << hrz.azimuth << ")";
133+
EXPECT_LT(alt_err, TOLERANCE_ARCSEC)
134+
<< "Alt error for " << c.object << " at " << c.site
135+
<< ": " << alt_err << "\" (truth=" << c.alt_truth << " got=" << hrz.altitude << ")";
136+
n++;
137+
}
138+
GTEST_LOG_(INFO) << "Horizontal accuracy (" << n << " cases):"
139+
<< " max az_err=" << max_az_err << "\""
140+
<< " max alt_err=" << max_alt_err << "\"";
141+
}
142+
143+
// ---------------------------------------------------------------------------
144+
// Structural: observer longitude must change altitude significantly.
145+
// If EquatorialToHorizontal ignores the observer position, Vega has the same
146+
// altitude at all sites. Sites span ~316 deg of longitude so the spread must
147+
// exceed 30 deg.
148+
// ---------------------------------------------------------------------------
149+
TEST(Libastro, ObserverLongitudeMatters)
150+
{
151+
auto cases = load_golden();
152+
ASSERT_GT(cases.size(), 0u);
153+
154+
double alt_min = 999.0, alt_max = -999.0;
155+
int vega_count = 0;
156+
for (auto &c : cases) {
157+
if (c.object != "Vega") continue;
158+
INDI::IEquatorialCoordinates j2000 = { c.ra_j2000_h, c.dec_j2000_deg };
159+
INDI::IEquatorialCoordinates jnow;
160+
INDI::J2000toObserved(&j2000, c.jd, &jnow);
161+
INDI::IGeographicCoordinates obs = { c.lon_deg, c.lat_deg, c.elev_m };
162+
INDI::IHorizontalCoordinates hrz;
163+
INDI::EquatorialToHorizontal(&jnow, &obs, c.jd, &hrz);
164+
alt_min = std::min(alt_min, hrz.altitude);
165+
alt_max = std::max(alt_max, hrz.altitude);
166+
vega_count++;
167+
}
168+
ASSERT_GE(vega_count, 3) << "Need at least 3 Vega cases";
169+
EXPECT_GT(alt_max - alt_min, 30.0)
170+
<< "Vega altitude spread=" << (alt_max - alt_min)
171+
<< " deg — expected >30 deg. Observer longitude may be ignored.";
172+
GTEST_LOG_(INFO) << "Vega altitude spread: " << alt_min << " to " << alt_max
173+
<< " deg (spread=" << (alt_max - alt_min) << " deg)";
174+
}
175+
176+
// ---------------------------------------------------------------------------
177+
// Structural: observer latitude must determine whether Polaris is above the
178+
// horizon. Polaris is circumpolar from Greenwich (lat 51.5 N) and below the
179+
// horizon from Siding Spring (lat 31.3 S). If latitude is ignored, the sign
180+
// of the altitude is wrong at one or both sites.
181+
// ---------------------------------------------------------------------------
182+
TEST(Libastro, ObserverLatitudeMatters)
183+
{
184+
auto cases = load_golden();
185+
ASSERT_GT(cases.size(), 0u);
186+
187+
double alt_greenwich = 999.0, alt_siding = 999.0;
188+
for (auto &c : cases) {
189+
if (c.object != "Polaris") continue;
190+
INDI::IEquatorialCoordinates j2000 = { c.ra_j2000_h, c.dec_j2000_deg };
191+
INDI::IEquatorialCoordinates jnow;
192+
INDI::J2000toObserved(&j2000, c.jd, &jnow);
193+
INDI::IGeographicCoordinates obs = { c.lon_deg, c.lat_deg, c.elev_m };
194+
INDI::IHorizontalCoordinates hrz;
195+
INDI::EquatorialToHorizontal(&jnow, &obs, c.jd, &hrz);
196+
if (c.site == "Greenwich") alt_greenwich = hrz.altitude;
197+
if (c.site == "Siding Spring") alt_siding = hrz.altitude;
198+
}
199+
ASSERT_NE(alt_greenwich, 999.0) << "Greenwich Polaris case missing";
200+
ASSERT_NE(alt_siding, 999.0) << "Siding Spring Polaris case missing";
201+
202+
EXPECT_GT(alt_greenwich, 0.0)
203+
<< "Polaris should be above horizon at Greenwich (got " << alt_greenwich << " deg)";
204+
EXPECT_LT(alt_siding, 0.0)
205+
<< "Polaris should be below horizon at Siding Spring (got " << alt_siding << " deg)";
206+
GTEST_LOG_(INFO) << "Polaris: Greenwich=" << alt_greenwich
207+
<< " deg, Siding Spring=" << alt_siding << " deg";
208+
}
209+
210+
// ---------------------------------------------------------------------------
211+
// Round-trip: EquatorialToHorizontal -> HorizontalToEquatorial must recover
212+
// the original JNow coordinates to floating-point precision (sub-arcsecond).
213+
// Skips cases below 5 deg altitude where spherical trig is less stable.
214+
// ---------------------------------------------------------------------------
215+
TEST(Libastro, RoundTrip_HorizontalToEquatorial)
216+
{
217+
auto cases = load_golden();
218+
ASSERT_GT(cases.size(), 0u);
219+
220+
double max_ra_err = 0, max_dec_err = 0;
221+
for (auto &c : cases) {
222+
INDI::IEquatorialCoordinates j2000 = { c.ra_j2000_h, c.dec_j2000_deg };
223+
INDI::IEquatorialCoordinates jnow_in;
224+
INDI::J2000toObserved(&j2000, c.jd, &jnow_in);
225+
226+
INDI::IGeographicCoordinates obs = { c.lon_deg, c.lat_deg, c.elev_m };
227+
INDI::IHorizontalCoordinates hrz;
228+
INDI::EquatorialToHorizontal(&jnow_in, &obs, c.jd, &hrz);
229+
230+
if (hrz.altitude < 5.0) continue;
231+
232+
INDI::IEquatorialCoordinates jnow_out;
233+
INDI::HorizontalToEquatorial(&hrz, &obs, c.jd, &jnow_out);
234+
235+
double cos_dec = std::cos(jnow_in.declination * M_PI / 180.0);
236+
double ra_err = std::abs(jnow_in.rightascension - jnow_out.rightascension) * 15.0 * 3600.0 * cos_dec;
237+
double dec_err = std::abs(jnow_in.declination - jnow_out.declination) * 3600.0;
238+
max_ra_err = std::max(max_ra_err, ra_err);
239+
max_dec_err = std::max(max_dec_err, dec_err);
240+
241+
GTEST_LOG_(INFO) << c.object << " at " << c.site
242+
<< " ra_err=" << ra_err << "\" dec_err=" << dec_err << "\"";
243+
EXPECT_LT(ra_err, 1.0) << "Round-trip RA error for " << c.object << " at " << c.site;
244+
EXPECT_LT(dec_err, 1.0) << "Round-trip Dec error for " << c.object << " at " << c.site;
245+
}
246+
GTEST_LOG_(INFO) << "Round-trip max: RA=" << max_ra_err << "\" Dec=" << max_dec_err << "\"";
247+
}
248+
249+
int main(int argc, char **argv)
250+
{
251+
::testing::InitGoogleTest(&argc, argv);
252+
return RUN_ALL_TESTS();
253+
}

0 commit comments

Comments
 (0)