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