|
| 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