Instrument Neutral Distributed Interface INDI 2.2.5
test_alignment_plugins.cpp
Go to the documentation of this file.
1/*******************************************************************************
2 * unit test for Alignment Math Plugins
3 *******************************************************************************/
4
5#include <gtest/gtest.h>
6#include <algorithm>
7#include <cmath>
8#include <cstdlib>
9#include <cstring>
10#include <random>
11#include <stdio.h>
12#include <vector>
13
14#include <indilogger.h>
15#include <indicom.h>
16#include <libastro.h>
17
25#include "../../drivers/telescope/scopesim_helper.h"
26
27#include <libnova/julian_day.h>
28#include <libnova/sidereal_time.h>
29
30using namespace INDI::AlignmentSubsystem;
31
32// ---------------------------------------------------------------------------
33// MountErrors -- all six Wallace terms, in degrees
34// ---------------------------------------------------------------------------
35
37{
38 double ih = 0;
39 double id = 0;
40 double ch = 0;
41 double np = 0;
42 double ma = 0;
43 double me = 0;
44};
45
46// ---------------------------------------------------------------------------
47// ErrorStats -- statistical summary of residuals
48// ---------------------------------------------------------------------------
49
51{
52 double max = 0;
53 double rms = 0;
54 double mean = 0;
55 double p95 = 0;
56
57 static ErrorStats compute(std::vector<double> residuals)
58 {
59 ErrorStats s;
60 if (residuals.empty()) return s;
61
62 double sumSq = 0;
63 double sum = 0;
64 for (double r : residuals)
65 {
66 sumSq += r * r;
67 sum += r;
68 if (r > s.max) s.max = r;
69 }
70 s.rms = std::sqrt(sumSq / residuals.size());
71 s.mean = sum / residuals.size();
72
73 std::sort(residuals.begin(), residuals.end());
74 size_t idx95 = static_cast<size_t>(0.95 * (residuals.size() - 1));
75 s.p95 = residuals[idx95];
76
77 return s;
78 }
79};
80
81// -----------------------------------------------------------------------
82// Grid sampling helpers
83// -----------------------------------------------------------------------
84
85static void getSkyPoint(double rawRA, double rawDec, double minDec, double maxDec, double &ra, double &dec)
86{
87 ra = rawRA * 24.0;
88 double sinMin = std::sin(DEG_TO_RAD(minDec));
89 double sinMax = std::sin(DEG_TO_RAD(maxDec));
90 dec = RAD_TO_DEG(std::asin(sinMin + rawDec * (sinMax - sinMin)));
91}
92
93// Perturb a unit vector by Gaussian noise of sigmaDeg per axis.
94// Two orthogonal perturbation axes are chosen automatically.
95static void addNoiseToDirection(TelescopeDirectionVector &tdv, double sigmaDeg, std::mt19937 &rng)
96{
97 if (sigmaDeg <= 0.0) return;
98
99 std::normal_distribution<double> dist(0.0, DEG_TO_RAD(sigmaDeg));
100
101 // Pick a basis vector not nearly parallel to tdv
102 double ax = std::abs(tdv.x), ay = std::abs(tdv.y), az = std::abs(tdv.z);
104 if (ax <= ay && ax <= az)
105 e1 = {0, -tdv.z, tdv.y};
106 else if (ay <= az)
107 e1 = {tdv.z, 0, -tdv.x};
108 else
109 e1 = {-tdv.y, tdv.x, 0};
110
111 double len1 = std::sqrt(e1.x * e1.x + e1.y * e1.y + e1.z * e1.z);
112 e1.x /= len1; e1.y /= len1; e1.z /= len1;
113
114 // e2 = tdv × e1
115 e2.x = tdv.y * e1.z - tdv.z * e1.y;
116 e2.y = tdv.z * e1.x - tdv.x * e1.z;
117 e2.z = tdv.x * e1.y - tdv.y * e1.x;
118
119 double d1 = dist(rng), d2 = dist(rng);
120 tdv.x += d1 * e1.x + d2 * e2.x;
121 tdv.y += d1 * e1.y + d2 * e2.y;
122 tdv.z += d1 * e1.z + d2 * e2.z;
123
124 double len = std::sqrt(tdv.x * tdv.x + tdv.y * tdv.y + tdv.z * tdv.z);
125 tdv.x /= len; tdv.y /= len; tdv.z /= len;
126}
127
128static const MountErrors kMixed = {
129 .ih = ARCMIN_TO_DEG(5), .id = ARCMIN_TO_DEG(3), .ch = ARCMIN_TO_DEG(8),
130 .np = ARCMIN_TO_DEG(0.5), .ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(6),
131};
132
133static const MountErrors kHugeIndex = {
134 .ih = 35.0, .id = -20.0, .ch = 1.5,
135 .np = 0.5, .ma = 2.0, .me = 2.0,
136};
137
138// ---------------------------------------------------------------------------
139// Observatory sites
140// ---------------------------------------------------------------------------
141
142static const INDI::IGeographicCoordinates kLosAngeles = { .longitude = -118.2, .latitude = 34.1, .elevation = 0 };
143static const INDI::IGeographicCoordinates kSydney = { .longitude = 151.2, .latitude = -33.9, .elevation = 0 };
144static const INDI::IGeographicCoordinates kTokyo = { .longitude = 139.7, .latitude = 35.7, .elevation = 0 };
145static const INDI::IGeographicCoordinates kNairobi = { .longitude = 36.8, .latitude = 0.0, .elevation = 0 };
146static const INDI::IGeographicCoordinates kArctic = { .longitude = 15.6, .latitude = 78.2, .elevation = 0 };
147static const INDI::IGeographicCoordinates kAntarctic = { .longitude = 166.7, .latitude = -77.8, .elevation = 0 };
148
149// ---------------------------------------------------------------------------
150// Sky windows used for Halton point generation
151// ---------------------------------------------------------------------------
152
153static constexpr double kEQ_MinDec = -60.0; // equatorial: Dec lower bound (deg)
154static constexpr double kEQ_MaxDec = 80.0; // equatorial: Dec upper bound (deg)
155static constexpr double kAZ_MinEl = 20.0; // AltAz: elevation lower bound (deg); below ~15 deg refraction is nonlinear
156static constexpr double kAZ_MaxEl = 85.0; // AltAz: elevation upper bound (deg); avoids azimuth-wrap singularity near zenith
157
158// ---------------------------------------------------------------------------
159// Tolerance constants
160// ---------------------------------------------------------------------------
161
162static constexpr double kRoundTripTolDeg = 0.05; // RunPluginAllInAlignment Dec tolerance (~3')
163static constexpr double kRoundTripTolHours = kRoundTripTolDeg / 15.0; // equivalent RA tolerance in hours
164
165// ---------------------------------------------------------------------------
166// AlignmentPluginTest fixture
167// ---------------------------------------------------------------------------
168
169class AlignmentPluginTest : public ::testing::Test
170{
171protected:
172 // Fixed JD for fully deterministic transforms (2026-03-14)
173 static constexpr double fixedJD = 2461113.81667;
174
180
181 // -----------------------------------------------------------------------
182 // Helpers
183 // -----------------------------------------------------------------------
184
186 static ::Alignment makeGenerator(const MountErrors &e, INDI::IGeographicCoordinates site,
188 {
189 ::Alignment gen;
190 gen.mountType = mountType;
191 gen.latitude = Angle(site.latitude);
192 gen.longitude = Angle(site.longitude);
193 gen.setCorrections(e.ih, e.id, e.ch, e.np, e.ma, e.me);
194 return gen;
195 }
196
197 static void buildEntry(::Alignment &gen,
199 double ra, double dec, double lst_hrs, double jd,
200 ::Alignment::MOUNT_TYPE mountType,
202 {
203 Angle ha(get_local_hour_angle(lst_hrs, ra), Angle::HOURS), adec(dec);
204
205 entry.RightAscension = ra;
206 entry.Declination = dec;
207 entry.ObservationJulianDate = jd;
208
209 if (mountType == ::Alignment::ALTAZ)
210 {
211 Angle primary, secondary;
212 gen.apparentHaDecToMount(ha, adec, &primary, &secondary);
213 INDI::IHorizontalCoordinates horCoords = { primary.Degrees(), secondary.Degrees() };
215 }
216 else
217 {
218 Angle mha, mdec;
219 gen.observedToInstrument(ha, adec, &mha, &mdec);
220 double instRA = range24(lst_hrs - mha.Hours());
221 INDI::IEquatorialCoordinates raCoords = { instRA, mdec.Degrees() };
223 }
224 }
225
226 // -----------------------------------------------------------------------
227 // RunPluginAllInAlignment -- all points in alignment
228 // -----------------------------------------------------------------------
229
230 void RunPluginAllInAlignment(MathPlugin &plugin, const MountErrors &errors,
231 int numPoints,
232 INDI::IGeographicCoordinates site = kLosAngeles,
234 double minDecOverride = NAN, double maxDecOverride = NAN)
235 {
236 double minDec = std::isnan(minDecOverride) ? ((mountType == ::Alignment::ALTAZ) ? kAZ_MinEl : kEQ_MinDec) : minDecOverride;
237 double maxDec = std::isnan(maxDecOverride) ? ((mountType == ::Alignment::ALTAZ) ? kAZ_MaxEl : kEQ_MaxDec) : maxDecOverride;
238
241
242 MountAlignment_t alignment = (mountType == ::Alignment::ALTAZ) ? ZENITH :
243 site.latitude >= 0 ? NORTH_CELESTIAL_POLE
245 plugin.SetApproximateMountAlignment(alignment);
246
247 ::Alignment generator = makeGenerator(errors, site, mountType);
248
249 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
250 ASSERT_NE(pSupport, nullptr);
251
252 double gmst = ln_get_apparent_sidereal_time(fixedJD);
253 double lst = range24(gmst + DEG_TO_HOURS(site.longitude));
254
255 HaltonSequence seq(2, 3);
256 for (int i = 1; i <= numPoints; ++i)
257 {
258 double ra, dec;
259 auto raw = seq.getRaw(i);
260 getSkyPoint(raw.first, raw.second, minDec, maxDec, ra, dec);
261
263 buildEntry(generator, pSupport, ra, dec, lst, fixedJD, mountType, entry);
264 db.GetAlignmentDatabase().push_back(entry);
265 }
266
267 ASSERT_TRUE(plugin.Initialise(&db));
268
269 double testRA = 12.0;
270 double testDec = (mountType == ::Alignment::ALTAZ) ? 60.0 : 45.0;
271
273 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(testRA, testDec, fixedJD, resultV));
274
275 double outRA, outDec;
276 plugin.TransformTelescopeToCelestialJD(resultV, outRA, outDec, fixedJD);
277
278 ASSERT_NEAR(testRA, outRA, kRoundTripTolHours);
279 ASSERT_NEAR(testDec, outDec, kRoundTripTolDeg);
280 }
281
282 // -----------------------------------------------------------------------
283 // RunPluginAlignValidate -- Dual-sequence align/validate split
284 //
285 // targetRMSArcsec: fixed RMS precision limit (e.g. 20" for high quality)
286 // -----------------------------------------------------------------------
287
288 void RunPluginAlignValidate(MathPlugin &plugin, const MountErrors &errors,
289 int numAlign, int numValidate,
290 double targetRMSArcsec,
291 INDI::IGeographicCoordinates site = kLosAngeles,
293 double minDecOverride = NAN, double maxDecOverride = NAN,
294 double *maxResidualOut = nullptr)
295 {
296 double minDec = std::isnan(minDecOverride) ? ((mountType == ::Alignment::ALTAZ) ? kAZ_MinEl : kEQ_MinDec) : minDecOverride;
297 double maxDec = std::isnan(maxDecOverride) ? ((mountType == ::Alignment::ALTAZ) ? kAZ_MaxEl : kEQ_MaxDec) : maxDecOverride;
298
299 InMemoryDatabase alignDb;
300 alignDb.SetDatabaseReferencePosition(site);
301
302 MountAlignment_t alignment = (mountType == ::Alignment::ALTAZ) ? ZENITH :
303 site.latitude >= 0 ? NORTH_CELESTIAL_POLE
305 plugin.SetApproximateMountAlignment(alignment);
306
307 ::Alignment generator = makeGenerator(errors, site, mountType);
308
309 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
310 ASSERT_NE(pSupport, nullptr);
311
312 double gmst = ln_get_apparent_sidereal_time(fixedJD);
313 double lst = range24(gmst + DEG_TO_HOURS(site.longitude));
314
315 // Alignment layer: Halton(2, 3)
316 HaltonSequence alignSeq(2, 3);
317 for (int i = 1; i <= numAlign; ++i)
318 {
319 double ra, dec;
320 auto raw = alignSeq.getRaw(i);
321 getSkyPoint(raw.first, raw.second, minDec, maxDec, ra, dec);
322
324 buildEntry(generator, pSupport, ra, dec, lst, fixedJD, mountType, entry);
325 alignDb.GetAlignmentDatabase().push_back(entry);
326 }
327
328 ASSERT_TRUE(plugin.Initialise(&alignDb));
329
330 // Validation layer: Halton(5, 7)
331 HaltonSequence validateSeq(5, 7);
332 std::vector<double> residuals;
333
334 for (int i = 1; i <= numValidate; ++i)
335 {
336 double ra, dec;
337 auto raw = validateSeq.getRaw(i);
338 getSkyPoint(raw.first, raw.second, minDec, maxDec, ra, dec);
339
341 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(ra, dec, fixedJD, resultV));
342
343 double outRA, outDec;
344 plugin.TransformTelescopeToCelestialJD(resultV, outRA, outDec, fixedJD);
345
346 double dRA = DEG_TO_ARCSEC(rangeHA(outRA - ra) * 15.0) * std::cos(DEG_TO_RAD(dec));
347 double dDec = DEG_TO_ARCSEC(outDec - dec);
348 double residual = std::sqrt(dRA * dRA + dDec * dDec);
349
350 residuals.push_back(residual);
351
352 if (i <= 5) // Only log first few points to avoid spam
353 {
354 GTEST_LOG_(INFO) << " validate[" << i << "] ra=" << ra
355 << " dec=" << dec
356 << " dRA=" << dRA << "\" dDec=" << dDec
357 << "\" residual=" << residual << "\"";
358 }
359 }
360
361 ErrorStats stats = ErrorStats::compute(residuals);
362 GTEST_LOG_(INFO) << "[ STATS ] RMS: " << stats.rms << "\" Target: " << targetRMSArcsec
363 << "\" P95: " << stats.p95 << "\" Peak: " << stats.max << "\"";
364
365 if (maxResidualOut) *maxResidualOut = stats.max;
366
367 EXPECT_LT(stats.rms, targetRMSArcsec)
368 << "Plugin failed to meet RMS quality target " << targetRMSArcsec << "\"";
369
370 // P95 safe bound is generally target * 1.5 to allow for artifacts
371 EXPECT_LT(stats.p95, targetRMSArcsec * 1.5)
372 << "Plugin failed to meet P95 quality target " << targetRMSArcsec * 1.5 << "\"";
373 }
374
375 // -----------------------------------------------------------------------
376 // RunPluginNoiseValidate -- measures pointing accuracy under centroiding
377 // noise injected into alignment entries.
378 //
379 // noisyPlugin is trained on entries with noiseSigmaDeg per-axis Gaussian
380 // noise. refPlugin is trained on the same sky positions but without noise.
381 // Validation measures the angular error between the two plugins' predicted
382 // telescope directions at held-out sky positions, using the same JulianOffset
383 // for both. This directly measures generalization accuracy under noise without
384 // any time-convention dependency.
385 // -----------------------------------------------------------------------
386
387 void RunPluginNoiseValidate(MathPlugin &noisyPlugin, MathPlugin &refPlugin,
388 const MountErrors &errors,
389 int numAlign, int numValidate,
390 double noiseSigmaDeg, double targetRMSArcsec,
391 INDI::IGeographicCoordinates site = kLosAngeles,
393 {
394 double minDec = (mountType == ::Alignment::ALTAZ) ? kAZ_MinEl : kEQ_MinDec;
395 double maxDec = (mountType == ::Alignment::ALTAZ) ? kAZ_MaxEl : kEQ_MaxDec;
396
397 MountAlignment_t alignment = (mountType == ::Alignment::ALTAZ) ? ZENITH :
398 site.latitude >= 0 ? NORTH_CELESTIAL_POLE : SOUTH_CELESTIAL_POLE;
399 noisyPlugin.SetApproximateMountAlignment(alignment);
400 refPlugin.SetApproximateMountAlignment(alignment);
401
402 ::Alignment generator = makeGenerator(errors, site, mountType);
403 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&noisyPlugin);
404 ASSERT_NE(pSupport, nullptr);
405
406 double gmst = ln_get_apparent_sidereal_time(fixedJD);
407 double lst = range24(gmst + DEG_TO_HOURS(site.longitude));
408
409 std::mt19937 rng(42);
410
411 InMemoryDatabase noisyDb, cleanDb;
412 noisyDb.SetDatabaseReferencePosition(site);
413 cleanDb.SetDatabaseReferencePosition(site);
414
415 HaltonSequence alignSeq(2, 3);
416 for (int i = 1; i <= numAlign; ++i)
417 {
418 double ra, dec;
419 auto raw = alignSeq.getRaw(i);
420 getSkyPoint(raw.first, raw.second, minDec, maxDec, ra, dec);
421
423 buildEntry(generator, pSupport, ra, dec, lst, fixedJD, mountType, entry);
424 cleanDb.GetAlignmentDatabase().push_back(entry);
425
426 addNoiseToDirection(entry.TelescopeDirection, noiseSigmaDeg, rng);
427 noisyDb.GetAlignmentDatabase().push_back(entry);
428 }
429
430 ASSERT_TRUE(refPlugin.Initialise(&cleanDb));
431 ASSERT_TRUE(noisyPlugin.Initialise(&noisyDb));
432
433 HaltonSequence validateSeq(5, 7);
434 std::vector<double> residuals;
435
436 for (int i = 1; i <= numValidate; ++i)
437 {
438 double ra, dec;
439 auto raw = validateSeq.getRaw(i);
440 getSkyPoint(raw.first, raw.second, minDec, maxDec, ra, dec);
441
442 // Noisy plugin's predicted direction
444 ASSERT_TRUE(noisyPlugin.TransformCelestialToTelescopeJD(ra, dec, fixedJD, noisyV));
445
446 // Reference (noiseless) plugin's predicted direction - same JD
448 ASSERT_TRUE(refPlugin.TransformCelestialToTelescopeJD(ra, dec, fixedJD, refV));
449
450 // Angular separation in arcseconds
451 double dot = noisyV.x * refV.x + noisyV.y * refV.y + noisyV.z * refV.z;
452 dot = std::max(-1.0, std::min(1.0, dot));
453 double residual = DEG_TO_ARCSEC(RAD_TO_DEG(std::acos(dot)));
454
455 residuals.push_back(residual);
456
457 if (i <= 5)
458 {
459 GTEST_LOG_(INFO) << " noise_validate[" << i << "] ra=" << ra
460 << " dec=" << dec
461 << " residual=" << residual << "\"";
462 }
463 }
464
465 ErrorStats stats = ErrorStats::compute(residuals);
466 GTEST_LOG_(INFO) << "[ STATS ] RMS: " << stats.rms << "\" Target: " << targetRMSArcsec
467 << "\" P95: " << stats.p95 << "\" Peak: " << stats.max << "\"";
468
469 EXPECT_LT(stats.rms, targetRMSArcsec)
470 << "Plugin failed noise-stress RMS target " << targetRMSArcsec << "\"";
471 EXPECT_LT(stats.p95, targetRMSArcsec * 1.5)
472 << "Plugin failed noise-stress P95 target " << targetRMSArcsec * 1.5 << "\"";
473 }
474
475 void RunTimeShiftValidate(MathPlugin &plugin, const MountErrors &errors,
476 int numAlign, int numValidate,
477 double shiftHours, double targetRMSArcsec,
478 INDI::IGeographicCoordinates site = kLosAngeles,
480 {
481 double minDec = (mountType == ::Alignment::ALTAZ) ? kAZ_MinEl : kEQ_MinDec;
482 double maxDec = (mountType == ::Alignment::ALTAZ) ? kAZ_MaxEl : kEQ_MaxDec;
483
484 InMemoryDatabase alignDb;
485 alignDb.SetDatabaseReferencePosition(site);
486
487 MountAlignment_t alignment = (mountType == ::Alignment::ALTAZ) ? ZENITH :
488 site.latitude >= 0 ? NORTH_CELESTIAL_POLE
490 plugin.SetApproximateMountAlignment(alignment);
491
492 ::Alignment generator = makeGenerator(errors, site, mountType);
493 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
494 ASSERT_NE(pSupport, nullptr);
495
496 // Training Phase: JD = fixedJD
497 double gmst = ln_get_apparent_sidereal_time(fixedJD);
498 double lst = range24(gmst + DEG_TO_HOURS(site.longitude));
499
500 HaltonSequence alignSeq(2, 3);
501 for (int i = 1; i <= numAlign; ++i)
502 {
503 double ra, dec;
504 auto raw = alignSeq.getRaw(i);
505 getSkyPoint(raw.first, raw.second, minDec, maxDec, ra, dec);
506
508 buildEntry(generator, pSupport, ra, dec, lst, fixedJD, mountType, entry);
509 alignDb.GetAlignmentDatabase().push_back(entry);
510 }
511
512 ASSERT_TRUE(plugin.Initialise(&alignDb));
513
514 // Validation Phase: JD = fixedJD + shiftHours/24
515 double valJD = fixedJD + shiftHours / 24.0;
516
517 HaltonSequence validateSeq(5, 7);
518 std::vector<double> residuals;
519
520 for (int i = 1; i <= numValidate; ++i)
521 {
522 double ra, dec;
523 auto raw = validateSeq.getRaw(i);
524 getSkyPoint(raw.first, raw.second, minDec, maxDec, ra, dec);
525
527 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(ra, dec, valJD, resultV));
528
529 double outRA, outDec;
530 plugin.TransformTelescopeToCelestialJD(resultV, outRA, outDec, valJD);
531
532 double dRA = DEG_TO_ARCSEC(rangeHA(outRA - ra) * 15.0) * std::cos(DEG_TO_RAD(dec));
533 double dDec = DEG_TO_ARCSEC(outDec - dec);
534 double residual = std::sqrt(dRA * dRA + dDec * dDec);
535
536 residuals.push_back(residual);
537 }
538
539 ErrorStats stats = ErrorStats::compute(residuals);
540 GTEST_LOG_(INFO) << "[ TIME SHIFT " << shiftHours << "h ] RMS: " << stats.rms << "\" Target: " << targetRMSArcsec
541 << "\" P95: " << stats.p95 << "\" Peak: " << stats.max << "\"";
542
543 EXPECT_LT(stats.rms, targetRMSArcsec)
544 << "Plugin failed time-shift RMS quality target " << targetRMSArcsec << "\"";
545 }
546
547 // -----------------------------------------------------------------------
548 // RunGotoConventionRegression -- AltAz C->T convention check.
549 //
550 // Trains the plugin on numAlign points, then calls C->T on a held-out
551 // sky position and decodes the returned TDV directly to Az/Alt without
552 // doing T->C. Compares against the scopesim ground-truth encoder.
553 //
554 // This breaks the round-trip symmetry that allows a 180deg Az convention
555 // bug to pass AlignValidate tests undetected. azTolDeg should be much
556 // less than 180 but generous enough for the plugin's interpolation error.
557 // -----------------------------------------------------------------------
559 const MountErrors &errors,
560 int numAlign,
561 double valRA, double valDec,
562 double azTolDeg,
563 INDI::IGeographicCoordinates site = kLosAngeles)
564 {
566
569
570 ::Alignment gen = makeGenerator(errors, site, ::Alignment::ALTAZ);
571 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
572 ASSERT_NE(pSupport, nullptr);
573
574 double gmst = ln_get_apparent_sidereal_time(fixedJD);
575 double lst = range24(gmst + DEG_TO_HOURS(site.longitude));
576
577 HaltonSequence seq(2, 3);
578 for (int i = 1; i <= numAlign; ++i)
579 {
580 double ra, dec;
581 auto raw = seq.getRaw(i);
582 getSkyPoint(raw.first, raw.second, kAZ_MinEl, kAZ_MaxEl, ra, dec);
584 buildEntry(gen, pSupport, ra, dec, lst, fixedJD, ::Alignment::ALTAZ, entry);
585 db.GetAlignmentDatabase().push_back(entry);
586 }
587 ASSERT_TRUE(plugin.Initialise(&db));
588
589 // Ground truth encoder position
590 Angle ha(get_local_hour_angle(lst, valRA), Angle::HOURS);
591 Angle adec(valDec);
592 Angle encAz, encAlt;
593 gen.apparentHaDecToMount(ha, adec, &encAz, &encAlt);
594
595 // Plugin C->T prediction, decoded to Az/Alt without T->C
597 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(valRA, valDec, fixedJD, tdv));
599 pSupport->AltitudeAzimuthFromTelescopeDirectionVector(tdv, hor);
600
601 double azDiff = std::fmod(hor.azimuth - encAz.Degrees() + 540.0, 360.0) - 180.0;
602 azDiff = std::abs(azDiff);
603 double altDiff = std::abs(hor.altitude - encAlt.Degrees());
604
605 GTEST_LOG_(INFO) << " encAz=" << encAz.Degrees() << " pluginAz=" << hor.azimuth
606 << " azDiff=" << azDiff * 3600.0 << "\"";
607 GTEST_LOG_(INFO) << " encAlt=" << encAlt.Degrees() << " pluginAlt=" << hor.altitude
608 << " altDiff=" << altDiff * 3600.0 << "\"";
609
610 EXPECT_LT(azDiff, azTolDeg) << "Az differs by " << azDiff << "deg — likely Az convention bug";
611 EXPECT_LT(altDiff, azTolDeg) << "Alt differs by " << altDiff << "deg";
612 }
613
615 const MountErrors &errors,
616 int numAlign,
617 double valRA, double valDec,
618 double tolArcsec,
619 INDI::IGeographicCoordinates site = kLosAngeles)
620 {
622
625
626 ::Alignment gen = makeGenerator(errors, site, ::Alignment::EQ_GEM);
627 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
628 ASSERT_NE(pSupport, nullptr);
629
630 double gmst = ln_get_apparent_sidereal_time(fixedJD);
631 double lst = range24(gmst + DEG_TO_HOURS(site.longitude));
632
633 HaltonSequence seq(2, 3);
634 for (int i = 1; i <= numAlign; ++i)
635 {
636 double ra, dec;
637 auto raw = seq.getRaw(i);
638 getSkyPoint(raw.first, raw.second, kEQ_MinDec, kEQ_MaxDec, ra, dec);
640 buildEntry(gen, pSupport, ra, dec, lst, fixedJD, ::Alignment::EQ_GEM, entry);
641 db.GetAlignmentDatabase().push_back(entry);
642 }
643 ASSERT_TRUE(plugin.Initialise(&db));
644
645 // Ground truth encoder position (RA/Dec) from scopesim
646 Angle ha(get_local_hour_angle(lst, valRA), Angle::HOURS);
647 Angle adec(valDec);
648 Angle encHA, encDec;
649 gen.observedToInstrument(ha, adec, &encHA, &encDec);
650 double instRA = range24(lst - encHA.Hours());
651
652 // Plugin C->T prediction, decoded to RA/Dec without T->C
654 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(valRA, valDec, fixedJD, tdv));
656 pSupport->EquatorialCoordinatesFromTelescopeDirectionVector(tdv, raCoords);
657
658 double rawRaDiff = rangeHA(instRA - raCoords.rightascension);
659 // Handle possible meridian flip between simulator and plugin solution
660 if (std::abs(rawRaDiff) > 6.0)
661 rawRaDiff = rangeHA(rawRaDiff - 12.0);
662
663 double raDiff = DEG_TO_ARCSEC(rawRaDiff * 15.0) * std::cos(DEG_TO_RAD(valDec));
664 double decDiff = DEG_TO_ARCSEC(encDec.Degrees() - raCoords.declination);
665 double totalDiff = std::sqrt(raDiff * raDiff + decDiff * decDiff);
666
667 GTEST_LOG_(INFO) << " encRA=" << instRA << " pluginRA=" << raCoords.rightascension
668 << " raDiff=" << raDiff << "\"";
669 GTEST_LOG_(INFO) << " encDec=" << encDec.Degrees() << " pluginDec=" << raCoords.declination
670 << " decDiff=" << decDiff << "\"";
671
672 EXPECT_LT(totalDiff, tolArcsec) << "Encoder position differs by " << totalDiff << "\" — likely ME/MA mapping bug";
673 }
674
676 const MountErrors &errors,
677 int numAlign,
678 double valRA, double valDec,
679 double tolArcsec,
680 INDI::IGeographicCoordinates site = kLosAngeles)
681 {
683
686
687 ::Alignment gen = makeGenerator(errors, site, ::Alignment::ALTAZ);
688 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
689 ASSERT_NE(pSupport, nullptr);
690
691 double gmst = ln_get_apparent_sidereal_time(fixedJD);
692 double lst = range24(gmst + DEG_TO_HOURS(site.longitude));
693
694 HaltonSequence seq(2, 3);
695 for (int i = 1; i <= numAlign; ++i)
696 {
697 double ra, dec;
698 auto raw = seq.getRaw(i);
699 getSkyPoint(raw.first, raw.second, kAZ_MinEl, kAZ_MaxEl, ra, dec);
701 buildEntry(gen, pSupport, ra, dec, lst, fixedJD, ::Alignment::ALTAZ, entry);
702 db.GetAlignmentDatabase().push_back(entry);
703 }
704 ASSERT_TRUE(plugin.Initialise(&db));
705
706 // Ground truth encoder position (Alt/Az) from scopesim
707 Angle ha(get_local_hour_angle(lst, valRA), Angle::HOURS);
708 Angle adec(valDec);
709 Angle encAz, encAlt;
710 gen.apparentHaDecToMount(ha, adec, &encAz, &encAlt);
711
712 // Plugin C->T prediction, decoded to Alt/Az without T->C
714 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(valRA, valDec, fixedJD, tdv));
716 pSupport->AltitudeAzimuthFromTelescopeDirectionVector(tdv, horCoords);
717
718 double azDiff = std::fmod(horCoords.azimuth - encAz.Degrees() + 540.0, 360.0) - 180.0;
719 double altDiff = std::abs(horCoords.altitude - encAlt.Degrees());
720 double totalDiff = std::sqrt(std::pow(azDiff * std::cos(DEG_TO_RAD(horCoords.altitude)), 2) + altDiff * altDiff);
721 totalDiff *= 3600.0; // arcsec
722
723 GTEST_LOG_(INFO) << " encAz=" << encAz.Degrees() << " pluginAz=" << horCoords.azimuth
724 << " azDiff=" << azDiff * 3600.0 << "\"";
725 GTEST_LOG_(INFO) << " encAlt=" << encAlt.Degrees() << " pluginAlt=" << horCoords.altitude
726 << " altDiff=" << altDiff * 3600.0 << "\"";
727
728 EXPECT_LT(totalDiff, tolArcsec) << "Encoder position differs by " << totalDiff << "\" — likely AN/AW mapping bug";
729 }
730
731 // -----------------------------------------------------------------------
732 // RunMeridianFlipValidate -- exercises GEM pier flips in both hemispheres
733 // -----------------------------------------------------------------------
734 template <typename T>
735 void RunMeridianFlipValidate(T &plugin, const MountErrors &errors,
736 const INDI::IGeographicCoordinates &location,
737 const MountAlignment_t pole,
738 double targetRMSArcsec = 40.0)
739 {
740 plugin.SetApproximateMountAlignment(pole);
741
742 ::Alignment generator = makeGenerator(errors, location, ::Alignment::EQ_GEM);
743 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
744 ASSERT_NE(pSupport, nullptr);
745
746 InMemoryDatabase alignDb;
747 alignDb.SetDatabaseReferencePosition(location);
748
749 double gmst = ln_get_apparent_sidereal_time(fixedJD);
750 double lst = range24(gmst + DEG_TO_HOURS(location.longitude));
751
752 // Use 3 points for alignment in flip tests
753 HaltonSequence alignSeq(2, 3);
754 for (int i = 1; i <= 3; ++i)
755 {
756 double ra, dec;
757 auto raw = alignSeq.getRaw(i);
758 getSkyPoint(raw.first, raw.second, kEQ_MinDec, kEQ_MaxDec, ra, dec);
759
761 buildEntry(generator, pSupport, ra, dec, lst, fixedJD, ::Alignment::EQ_GEM, entry);
762 alignDb.GetAlignmentDatabase().push_back(entry);
763 }
764
765 ASSERT_TRUE(plugin.Initialise(&alignDb));
766
767 // 100-point validation set for flip tests
768 HaltonSequence validateSeq(5, 7);
769 std::vector<double> residuals;
770
771 for (int i = 1; i <= 100; ++i)
772 {
773 double ra, dec;
774 auto raw = validateSeq.getRaw(i);
775 getSkyPoint(raw.first, raw.second, kEQ_MinDec, kEQ_MaxDec, ra, dec);
776
778 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(ra, dec, fixedJD, resultV));
779
780 double outRA, outDec;
781 plugin.TransformTelescopeToCelestialJD(resultV, outRA, outDec, fixedJD);
782
783 double dRA = DEG_TO_ARCSEC(rangeHA(outRA - ra) * 15.0) * std::cos(DEG_TO_RAD(dec));
784 double dDec = DEG_TO_ARCSEC(outDec - dec);
785 double residual = std::sqrt(dRA * dRA + dDec * dDec);
786
787 residuals.push_back(residual);
788
789 if (i <= 5)
790 {
791 GTEST_LOG_(INFO) << " flip_validate[" << i << "] ra=" << ra
792 << " dec=" << dec
793 << " dRA=" << dRA << "\" dDec=" << dDec
794 << "\" residual=" << residual << "\"";
795 }
796 }
797
798 ErrorStats stats = ErrorStats::compute(residuals);
799 GTEST_LOG_(INFO) << "[ STATS ] RMS: " << stats.rms << "\" Target: " << targetRMSArcsec
800 << "\" P95: " << stats.p95 << "\" Peak: " << stats.max << "\"";
801
802 EXPECT_LT(stats.rms, targetRMSArcsec)
803 << "Plugin failed meridian flip RMS quality target " << targetRMSArcsec << "\"";
804
805 EXPECT_LT(stats.p95, targetRMSArcsec * 1.5)
806 << "Plugin failed meridian flip P95 quality target " << targetRMSArcsec * 1.5 << "\"";
807 }
808
809 // -----------------------------------------------------------------------
810 // Incremental hot-path helpers
811 // -----------------------------------------------------------------------
812
813 // Build a database of n sync points, initialise plugin, and return the
814 // generator so the caller can add more points with the same sequence.
816 const MountErrors &errors,
817 int n,
820 INDI::IGeographicCoordinates site = kLosAngeles)
821 {
823 MountAlignment_t alignment = (mountType == ::Alignment::ALTAZ) ? ZENITH :
824 site.latitude >= 0 ? NORTH_CELESTIAL_POLE
826 plugin.SetApproximateMountAlignment(alignment);
827
828 ::Alignment generator = makeGenerator(errors, site, mountType);
829 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
830
831 double gmst = ln_get_apparent_sidereal_time(fixedJD);
832 double lst = range24(gmst + DEG_TO_HOURS(site.longitude));
833 double minDec = (mountType == ::Alignment::ALTAZ) ? kAZ_MinEl : kEQ_MinDec;
834 double maxDec = (mountType == ::Alignment::ALTAZ) ? kAZ_MaxEl : kEQ_MaxDec;
835
836 HaltonSequence seq(2, 3);
837 for (int i = 1; i <= n; i++)
838 {
839 double ra, dec;
840 auto raw = seq.getRaw(i);
841 getSkyPoint(raw.first, raw.second, minDec, maxDec, ra, dec);
843 buildEntry(generator, pSupport, ra, dec, lst, fixedJD, mountType, entry);
844 db.GetAlignmentDatabase().push_back(entry);
845 }
846 EXPECT_TRUE(plugin.Initialise(&db));
847 return generator;
848 }
849
850 // Verify hot-path result matches a cold-initialised reference plugin.
853 {
854 // Hot path uses pm=0 as the linearization point (Pmfit iteration-0);
855 // Pmfit iterates to convergence. With arcminute-level mount errors
856 // the difference is a few arcseconds in angle space (~5e-5 in
857 // direction vector units), well below observational noise.
858 const double tol = 5e-5;
859 for (double testRA : {3.0, 8.0, 15.0, 21.0})
860 {
861 // Clamp to physical range: equatorial [-60, 70], AltAz [20, 85]
862 std::vector<double> decs = (mountType == ::Alignment::ALTAZ)
863 ? std::vector<double>{25.0, 40.0, 60.0, 80.0}
864 : std::vector<double>{-30.0, 0.0, 30.0, 60.0};
865 for (double testDec : decs)
866 {
867 TelescopeDirectionVector hotV, coldV;
868 ASSERT_TRUE(hotPlugin.TransformCelestialToTelescopeJD(testRA, testDec, fixedJD, hotV));
869 ASSERT_TRUE(coldPlugin.TransformCelestialToTelescopeJD(testRA, testDec, fixedJD, coldV));
870 EXPECT_NEAR(hotV.x, coldV.x, tol) << "RA=" << testRA << " Dec/El=" << testDec;
871 EXPECT_NEAR(hotV.y, coldV.y, tol) << "RA=" << testRA << " Dec/El=" << testDec;
872 EXPECT_NEAR(hotV.z, coldV.z, tol) << "RA=" << testRA << " Dec/El=" << testDec;
873 }
874 }
875 }
876};
877
878// ---------------------------------------------------------------------------
879// Error recovery tests
880// ---------------------------------------------------------------------------
881
882TEST_F(AlignmentPluginTest, Test_BuiltIn_ErrorRecovery)
883{
884 BuiltInMathPlugin plugin;
885 RunPluginAllInAlignment(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 3);
886}
887
888TEST_F(AlignmentPluginTest, Test_SVD_ErrorRecovery)
889{
890 SVDMathPlugin plugin;
891 RunPluginAllInAlignment(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 3);
892}
893
894TEST_F(AlignmentPluginTest, Test_Nearest_ErrorRecovery)
895{
896 NearestMathPlugin plugin;
897 RunPluginAllInAlignment(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 10);
898}
899
901{
902 SVDMathPlugin plugin;
903 RunPluginAllInAlignment(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 6, kLosAngeles, ::Alignment::ALTAZ);
904}
905
906// ---------------------------------------------------------------------------
907// Single sync point -- all plugins
908//
909// Verifies that each plugin initialises, transforms, and round-trips correctly
910// with exactly one sync point. This exercises the size() >= 1 gate in
911// AlignmentSubsystemForDrivers: corrections must apply from the first sync.
912//
913// Index errors on both axes (IH = 5 arcmin, ID = 3 arcmin) are used so the
914// correction has both an RA and a Dec component. With a single sync point
915// every plugin's minimal model recovers only index errors, not polar errors,
916// so pure index errors are the appropriate choice here.
917//
918// NearestMathPlugin applies the single point's offset uniformly across the
919// sky — for pure index errors the offset is position-independent, so the
920// round-trip is exact at any validation point.
921// ---------------------------------------------------------------------------
922
923TEST_F(AlignmentPluginTest, SinglePoint_BuiltIn)
924{
925 BuiltInMathPlugin plugin;
926 RunPluginAllInAlignment(plugin, {.ih = ARCMIN_TO_DEG(5), .id = ARCMIN_TO_DEG(3)}, 1);
927}
928
930{
931 SVDMathPlugin plugin;
932 RunPluginAllInAlignment(plugin, {.ih = ARCMIN_TO_DEG(5), .id = ARCMIN_TO_DEG(3)}, 1);
933}
934
935TEST_F(AlignmentPluginTest, SinglePoint_Nearest)
936{
937 NearestMathPlugin plugin;
938 RunPluginAllInAlignment(plugin, {.ih = ARCMIN_TO_DEG(5), .id = ARCMIN_TO_DEG(3)}, 1);
939}
940
941// ---------------------------------------------------------------------------
942// AlignValidate -- equatorial mounts
943// ---------------------------------------------------------------------------
944
945TEST_F(AlignmentPluginTest, SVD_AlignValidate_Polar)
946{
947 SVDMathPlugin plugin;
948 RunPluginAlignValidate(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 3, 100, 1.0);
949}
950
951TEST_F(AlignmentPluginTest, SVD_AlignValidate_AxisErrors)
952{
953 SVDMathPlugin plugin;
954 RunPluginAlignValidate(plugin, {.ih = ARCMIN_TO_DEG(5), .id = ARCMIN_TO_DEG(3), .ch = ARCMIN_TO_DEG(8)}, 3, 100, 1.0);
955}
956
957TEST_F(AlignmentPluginTest, SVD_AlignValidate_NonPerp)
958{
959 SVDMathPlugin plugin;
960 RunPluginAlignValidate(plugin, {.np = ARCMIN_TO_DEG(2)}, 3, 100, 1.0);
961}
962
963TEST_F(AlignmentPluginTest, SVD_AlignValidate_IHOnly)
964{
965 SVDMathPlugin plugin;
966 RunPluginAlignValidate(plugin, {.ih = ARCMIN_TO_DEG(15)}, 3, 100, 1.0);
967}
968
969TEST_F(AlignmentPluginTest, SVD_AlignValidate_CHOnly)
970{
971 SVDMathPlugin plugin;
972 RunPluginAlignValidate(plugin, {.ch = ARCMIN_TO_DEG(20)}, 3, 100, 1.0);
973}
974
975TEST_F(AlignmentPluginTest, SVD_AlignValidate_AllTerms_LA)
976{
977 SVDMathPlugin plugin;
978 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0);
979}
980
981TEST_F(AlignmentPluginTest, SVD_AlignValidate_AllTerms_Tokyo)
982{
983 SVDMathPlugin plugin;
984 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0, kTokyo);
985}
986
987TEST_F(AlignmentPluginTest, SVD_AlignValidate_SouthernHemisphere)
988{
989 SVDMathPlugin plugin;
990 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0, kSydney);
991}
992
993TEST_F(AlignmentPluginTest, SVD_AlignValidate_Arctic)
994{
995 SVDMathPlugin plugin;
996 RunPluginAlignValidate(plugin, kMixed, 12, 100, 1.0, kArctic);
997}
998
999TEST_F(AlignmentPluginTest, SVD_AlignValidate_Antarctic)
1000{
1001 SVDMathPlugin plugin;
1002 RunPluginAlignValidate(plugin, kMixed, 12, 100, 1.0, kAntarctic);
1003}
1004
1005TEST_F(AlignmentPluginTest, SVD_AlignValidate_SmallErrors)
1006{
1007 SVDMathPlugin plugin;
1008 RunPluginAlignValidate(plugin, {.ih = ARCSEC_TO_DEG(30), .me = ARCSEC_TO_DEG(18)}, 3, 100, 0.5);
1009}
1010
1011TEST_F(AlignmentPluginTest, SVD_AlignValidate_LargeErrors)
1012{
1013 SVDMathPlugin plugin;
1014 RunPluginAlignValidate(plugin, {.ma = ARCMIN_TO_DEG(30), .me = ARCMIN_TO_DEG(15)}, 3, 100, 1.0);
1015}
1016
1017TEST_F(AlignmentPluginTest, BuiltIn_AlignValidate_AllTerms)
1018{
1019 BuiltInMathPlugin plugin;
1020 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0);
1021}
1022
1023TEST_F(AlignmentPluginTest, Nearest_AlignValidate_AllTerms)
1024{
1025 // NearestMathPlugin round-trip is only reliable when the validate point
1026 // has an alignment point at the same HA. Use all-in-alignment with many
1027 // points rather than an align/validate split.
1028 NearestMathPlugin plugin;
1029 RunPluginAllInAlignment(plugin, kMixed, 20);
1030}
1031
1032TEST_F(AlignmentPluginTest, Nearest_AlignValidate_QualityCheck)
1033{
1034 // Nearest is a coarse point-lookup plugin. Performance is expected to be
1035 // much lower than vector-fit models (SVD/SPK).
1036 // We set a 20000" (~5.5 deg) target to reflect this coarse precision class
1037 // given a 12-point alignment grid.
1038 NearestMathPlugin plugin;
1039 RunPluginAlignValidate(plugin, kMixed, 3, 100, 35000.0);
1040}
1041
1042// ---------------------------------------------------------------------------
1043// AlignValidate -- AltAz mounts
1044// ---------------------------------------------------------------------------
1045
1046TEST_F(AlignmentPluginTest, SVD_AlignValidate_AltAz_Polar)
1047{
1048 SVDMathPlugin plugin;
1049 RunPluginAlignValidate(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 3, 100, 20.0,
1050 kLosAngeles, ::Alignment::ALTAZ);
1051}
1052
1053TEST_F(AlignmentPluginTest, SVD_AlignValidate_AltAz_AxisErrors)
1054{
1055 SVDMathPlugin plugin;
1056 RunPluginAlignValidate(plugin, {.ih = ARCMIN_TO_DEG(5), .id = ARCMIN_TO_DEG(3), .ch = ARCMIN_TO_DEG(8)}, 3, 100, 20.0,
1057 kLosAngeles, ::Alignment::ALTAZ);
1058}
1059
1060TEST_F(AlignmentPluginTest, SVD_AlignValidate_AltAz_NonPerp)
1061{
1062 SVDMathPlugin plugin;
1063 RunPluginAlignValidate(plugin, {.np = ARCMIN_TO_DEG(2)}, 3, 100, 20.0,
1064 kLosAngeles, ::Alignment::ALTAZ);
1065}
1066
1067TEST_F(AlignmentPluginTest, SVD_AlignValidate_AltAz_AllTerms)
1068{
1069 SVDMathPlugin plugin;
1070 RunPluginAlignValidate(plugin, kMixed, 3, 100, 20.0,
1071 kLosAngeles, ::Alignment::ALTAZ);
1072}
1073
1074TEST_F(AlignmentPluginTest, SVD_AlignValidate_AltAz_Southern)
1075{
1076 SVDMathPlugin plugin;
1077 RunPluginAlignValidate(plugin, kMixed, 3, 100, 20.0,
1078 kSydney, ::Alignment::ALTAZ);
1079}
1080
1081// Tolerance 2deg: SVD's Kabsch rotation on 3 points absorbs errors as a
1082// rigid rotation, so a held-out point carries some interpolation residual.
1083// Still far from a 180deg convention failure.
1084TEST_F(AlignmentPluginTest, SVD_AltAz_Goto_Convention_Regression)
1085{
1086 SVDMathPlugin plugin;
1087 RunGotoConventionRegression(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)},
1088 3, 12.0, 50.0, 2.0);
1089}
1090
1091TEST_F(AlignmentPluginTest, BuiltIn_AlignValidate_AltAz)
1092{
1093 BuiltInMathPlugin plugin;
1094 RunPluginAlignValidate(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 3, 100, 20.0,
1095 kLosAngeles, ::Alignment::ALTAZ);
1096}
1097
1098// Tolerance 2deg: BuiltIn uses a raw 3x3 matrix inversion on 3 points which
1099// does not explicitly fit Wallace errors, so interpolation at a held-out point
1100// carries more residual than SPK. Still far from a 180deg convention failure.
1101TEST_F(AlignmentPluginTest, BuiltIn_AltAz_Goto_Convention_Regression)
1102{
1103 BuiltInMathPlugin plugin;
1104 RunGotoConventionRegression(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)},
1105 3, 12.0, 50.0, 2.0);
1106}
1107
1108TEST_F(AlignmentPluginTest, Nearest_AlignValidate_AltAz)
1109{
1110 // NearestMathPlugin round-trip is only reliable when the validate point
1111 // has an alignment point at the same HA. Use all-in-alignment with many
1112 // points rather than an align/validate split.
1113 NearestMathPlugin plugin;
1114 RunPluginAllInAlignment(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 20, kLosAngeles, ::Alignment::ALTAZ);
1115}
1116
1117// Tolerance 1deg: Nearest returns the stored TDV of the closest alignment
1118// point, so accuracy at the exact alignment position is limited only by the
1119// encoder ground truth, not by interpolation. We use the first Halton point
1120// as the validation target to guarantee the nearest neighbour is that entry.
1121// A 180deg convention bug would produce azDiff ~180deg.
1122TEST_F(AlignmentPluginTest, Nearest_AltAz_Goto_Convention_Regression)
1123{
1124 NearestMathPlugin plugin;
1126
1127 MountErrors errors = {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)};
1128 ::Alignment gen = makeGenerator(errors, kLosAngeles, ::Alignment::ALTAZ);
1129 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
1130 ASSERT_NE(pSupport, nullptr);
1131
1132 double gmst = ln_get_apparent_sidereal_time(fixedJD);
1133 double lst = range24(gmst + DEG_TO_HOURS(kLosAngeles.longitude));
1134
1136 db.SetDatabaseReferencePosition(kLosAngeles);
1137
1138 // Build 5 alignment entries; record the first point for validation.
1139 double valRA = 0, valDec = 0;
1140 HaltonSequence seq(2, 3);
1141 for (int i = 1; i <= 5; ++i)
1142 {
1143 double ra, dec;
1144 auto raw = seq.getRaw(i);
1145 getSkyPoint(raw.first, raw.second, kAZ_MinEl, kAZ_MaxEl, ra, dec);
1146 if (i == 1) { valRA = ra; valDec = dec; }
1148 buildEntry(gen, pSupport, ra, dec, lst, fixedJD, ::Alignment::ALTAZ, entry);
1149 db.GetAlignmentDatabase().push_back(entry);
1150 }
1151 ASSERT_TRUE(plugin.Initialise(&db));
1152
1153 Angle ha(get_local_hour_angle(lst, valRA), Angle::HOURS);
1154 Angle adec(valDec);
1155 Angle encAz, encAlt;
1156 gen.apparentHaDecToMount(ha, adec, &encAz, &encAlt);
1157
1159 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(valRA, valDec, fixedJD, tdv));
1161 pSupport->AltitudeAzimuthFromTelescopeDirectionVector(tdv, hor);
1162
1163 double azDiff = std::fmod(hor.azimuth - encAz.Degrees() + 540.0, 360.0) - 180.0;
1164 azDiff = std::abs(azDiff);
1165 double altDiff = std::abs(hor.altitude - encAlt.Degrees());
1166
1167 GTEST_LOG_(INFO) << " encAz=" << encAz.Degrees() << " pluginAz=" << hor.azimuth
1168 << " azDiff=" << azDiff * 3600.0 << "\"";
1169 GTEST_LOG_(INFO) << " encAlt=" << encAlt.Degrees() << " pluginAlt=" << hor.altitude
1170 << " altDiff=" << altDiff * 3600.0 << "\"";
1171
1172 EXPECT_LT(azDiff, 1.0) << "Az differs by " << azDiff << "deg — likely Az convention bug";
1173 EXPECT_LT(altDiff, 1.0) << "Alt differs by " << altDiff << "deg";
1174}
1175
1176// ---------------------------------------------------------------------------
1177// Convex hull fallback -- validate points outside the aligned sky region
1178// ---------------------------------------------------------------------------
1179
1180TEST_F(AlignmentPluginTest, SVD_AlignValidate_ConvexHullFallback)
1181{
1182 SVDMathPlugin plugin;
1184
1185 MountErrors errors = {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)};
1186 ::Alignment generator = makeGenerator(errors, kLosAngeles, ::Alignment::EQ_GEM);
1187 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
1188 ASSERT_NE(pSupport, nullptr);
1189
1190 InMemoryDatabase alignDb;
1191 alignDb.SetDatabaseReferencePosition(kLosAngeles);
1192
1193 double gmst = ln_get_apparent_sidereal_time(fixedJD);
1194 double lst = range24(gmst + DEG_TO_HOURS(kLosAngeles.longitude));
1195
1196 // Alignment from eastern sky only: RA in [18h, 24h) or [0h, 6h)
1197 HaltonSequence seq(2, 3);
1198 int alignCount = 0;
1199 for (int i = 1; alignCount < 6; ++i)
1200 {
1201 double ra, dec;
1202 auto raw = seq.getRaw(i);
1203 getSkyPoint(raw.first, raw.second, kEQ_MinDec, kEQ_MaxDec, ra, dec);
1204
1205 if (ra >= 6.0 && ra < 18.0)
1206 continue;
1208 buildEntry(generator, pSupport, ra, dec, lst, fixedJD, ::Alignment::EQ_GEM, entry);
1209 alignDb.GetAlignmentDatabase().push_back(entry);
1210 ++alignCount;
1211 }
1212
1213 ASSERT_TRUE(plugin.Initialise(&alignDb));
1214
1215 // Validation from western sky: RA in [6h, 18h) -- outside the convex hull
1216 int validateCount = 0;
1217 for (int i = 1; validateCount < 6; ++i)
1218 {
1219 double ra, dec;
1220 auto raw = seq.getRaw(i);
1221 getSkyPoint(raw.first, raw.second, kEQ_MinDec, kEQ_MaxDec, ra, dec);
1222
1223 if (ra < 6.0 || ra >= 18.0)
1224 continue;
1225
1227 // Must not crash or fail; fallback should return a usable (if coarse) TDV
1228 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(ra, dec, fixedJD, resultV));
1229
1230 double outRA, outDec;
1231 plugin.TransformTelescopeToCelestialJD(resultV, outRA, outDec, fixedJD);
1232
1233 double dRA = DEG_TO_ARCSEC(rangeHA(outRA - ra) * 15.0) * std::cos(DEG_TO_RAD(dec));
1234 double dDec = DEG_TO_ARCSEC(outDec - dec);
1235 double residual = std::sqrt(dRA * dRA + dDec * dDec);
1236
1237 GTEST_LOG_(INFO) << " fallback[" << validateCount << "] ra=" << ra
1238 << " dec=" << dec << " res=" << residual;
1239 EXPECT_LT(residual, 180.0);
1240 ++validateCount;
1241 }
1242}
1243
1244// ---------------------------------------------------------------------------
1245// EQ_FORK mount type
1246// ---------------------------------------------------------------------------
1247
1248TEST_F(AlignmentPluginTest, SVD_AlignValidate_EqFork_LA)
1249{
1250 SVDMathPlugin plugin;
1251 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0,
1252 kLosAngeles, ::Alignment::EQ_FORK);
1253}
1254
1255TEST_F(AlignmentPluginTest, SVD_AlignValidate_EqFork_Sydney)
1256{
1257 SVDMathPlugin plugin;
1258 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0,
1259 kSydney, ::Alignment::EQ_FORK);
1260}
1261
1262// ---------------------------------------------------------------------------
1263// Meridian flip -- exercises GEM pier flips in both hemispheres
1264// ---------------------------------------------------------------------------
1265
1266TEST_F(AlignmentPluginTest, SVD_AlignValidate_MeridianFlip_Northern)
1267{
1268 SVDMathPlugin plugin;
1269 RunMeridianFlipValidate(plugin, kMixed, kLosAngeles, NORTH_CELESTIAL_POLE);
1270}
1271
1272TEST_F(AlignmentPluginTest, SVD_AlignValidate_MeridianFlip_Southern)
1273{
1274 SVDMathPlugin plugin;
1275 RunMeridianFlipValidate(plugin, kMixed, kSydney, SOUTH_CELESTIAL_POLE);
1276}
1277
1278TEST_F(AlignmentPluginTest, BuiltIn_AlignValidate_MeridianFlip_Northern)
1279{
1280 BuiltInMathPlugin plugin;
1281 RunMeridianFlipValidate(plugin, kMixed, kLosAngeles, NORTH_CELESTIAL_POLE);
1282}
1283
1284TEST_F(AlignmentPluginTest, BuiltIn_AlignValidate_MeridianFlip_Southern)
1285{
1286 BuiltInMathPlugin plugin;
1287 RunMeridianFlipValidate(plugin, kMixed, kSydney, SOUTH_CELESTIAL_POLE);
1288}
1289
1290TEST_F(AlignmentPluginTest, SPK_AlignValidate_MeridianFlip_Northern)
1291{
1292 SPKMathPlugin plugin;
1293 RunMeridianFlipValidate(plugin, kMixed, kLosAngeles, NORTH_CELESTIAL_POLE);
1294}
1295
1296TEST_F(AlignmentPluginTest, SPK_AlignValidate_MeridianFlip_Southern)
1297{
1298 SPKMathPlugin plugin;
1299 RunMeridianFlipValidate(plugin, kMixed, kSydney, SOUTH_CELESTIAL_POLE);
1300}
1301
1302TEST_F(AlignmentPluginTest, DISABLED_Nearest_AlignValidate_MeridianFlip_Northern)
1303{
1304 NearestMathPlugin plugin;
1305 RunMeridianFlipValidate(plugin, kMixed, kLosAngeles, NORTH_CELESTIAL_POLE);
1306}
1307
1308TEST_F(AlignmentPluginTest, DISABLED_Nearest_AlignValidate_MeridianFlip_Southern)
1309{
1310 NearestMathPlugin plugin;
1311 RunMeridianFlipValidate(plugin, kMixed, kSydney, SOUTH_CELESTIAL_POLE);
1312}
1313
1314// ---------------------------------------------------------------------------
1315// Equatorial site (Nairobi, lat=0 deg)
1316// ---------------------------------------------------------------------------
1317
1318TEST_F(AlignmentPluginTest, SVD_AlignValidate_Equatorial)
1319{
1320 SVDMathPlugin plugin;
1321 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0, kNairobi);
1322}
1323
1324TEST_F(AlignmentPluginTest, Test_SPK_ErrorRecovery)
1325{
1326 SPKMathPlugin plugin;
1327 // 10 points gives Pmfit a well-conditioned full 6-term fit (nt=6, 14 DoF).
1328 RunPluginAllInAlignment(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 10);
1329}
1330
1332{
1333 SPKMathPlugin plugin;
1334 RunPluginAllInAlignment(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 10, kLosAngeles, ::Alignment::ALTAZ);
1335}
1336
1337TEST_F(AlignmentPluginTest, Test_SPK_SouthernHemisphere)
1338{
1339 SPKMathPlugin plugin;
1340 RunPluginAllInAlignment(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 10, kSydney);
1341}
1342
1343// ---------------------------------------------------------------------------
1344// SPK -- AlignValidate suite (equatorial mounts)
1345//
1346// SPKMathPlugin fits a 6-term Wallace pointing model (IH, ID, ME, MA, CH, TF)
1347// via Pmfit. NP is excluded (IA, NP, CA are correlated; negligible in
1348// well-built mounts). Polar-axis terms (ME, MA) precede collimation (CH) so
1349// that a 3-point fit yields a well-conditioned 4-term polar solution.
1350//
1351// TF (tube flexure) requires a two-pass AXES call: the first pass gives the
1352// initial encoder demand, the second re-evaluates at that position so the
1353// VD correction (pvd*cos(el)) is self-consistent with the TARG path.
1354// ---------------------------------------------------------------------------
1355
1356TEST_F(AlignmentPluginTest, SPK_AlignValidate_Polar)
1357{
1358 SPKMathPlugin plugin;
1359 RunPluginAlignValidate(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 3, 100, 1.0);
1360}
1361
1362TEST_F(AlignmentPluginTest, SPK_AlignValidate_AxisErrors)
1363{
1364 SPKMathPlugin plugin;
1365 // IH and ID are terms 0-1 and are fitted even at 3 points. CH is term 4
1366 // and requires 5+ points; it is tested separately in SPK_AlignValidate_CHOnly.
1367 RunPluginAlignValidate(plugin, {.ih = ARCMIN_TO_DEG(5), .id = ARCMIN_TO_DEG(3)}, 3, 100, 1.0);
1368}
1369
1370TEST_F(AlignmentPluginTest, SPK_AlignValidate_IHOnly)
1371{
1372 SPKMathPlugin plugin;
1373 RunPluginAlignValidate(plugin, {.ih = ARCMIN_TO_DEG(15)}, 3, 100, 1.0);
1374}
1375
1376TEST_F(AlignmentPluginTest, SPK_AlignValidate_CHOnly)
1377{
1378 SPKMathPlugin plugin;
1379 // CH is term 4 (IH, ID, ME, MA, CH, TF); Pmfit fits terms 0..nt-1 where 5+ points yields nt=5.
1380 RunPluginAlignValidate(plugin, {.ch = ARCMIN_TO_DEG(20)}, 5, 100, 1.0);
1381}
1382
1383// NP (non-perpendicularity of the RA and Dec axes) is not fitted by Pmfit,
1384// but the pure-NP pointing offset is mathematically near-degenerate with the
1385// (IH, CH) pair: a 12-point fit with 12' NP yields IH=+318", CH=-311" and
1386// sub-arcsecond residuals. This means residual magnitude is NOT a useful
1387// signal for detecting accidental NP fitting -- the two cases are
1388// indistinguishable to validation. Keep the test as a regression guard on
1389// the residual ceiling only.
1390TEST_F(AlignmentPluginTest, SPK_AlignValidate_NonPerp)
1391{
1392 SPKMathPlugin plugin;
1393 RunPluginAlignValidate(plugin, {.np = ARCMIN_TO_DEG(2)}, 3, 100, 1.0);
1394}
1395
1396// Individual term isolation: ID, MA, ME (IH and CH already have isolated tests).
1397// These give fault localization when combined tests break.
1398TEST_F(AlignmentPluginTest, SPK_AlignValidate_IDOnly)
1399{
1400 SPKMathPlugin plugin;
1401 RunPluginAlignValidate(plugin, {.id = ARCMIN_TO_DEG(5)}, 3, 100, 1.0);
1402}
1403
1404TEST_F(AlignmentPluginTest, SPK_AlignValidate_MAOnly)
1405{
1406 SPKMathPlugin plugin;
1407 RunPluginAlignValidate(plugin, {.ma = ARCMIN_TO_DEG(10)}, 3, 100, 1.0);
1408}
1409
1410TEST_F(AlignmentPluginTest, SPK_AlignValidate_MEOnly)
1411{
1412 SPKMathPlugin plugin;
1413 RunPluginAlignValidate(plugin, {.me = ARCMIN_TO_DEG(5)}, 3, 100, 1.0);
1414}
1415
1416// Zero-error baseline: with no mount errors injected, the model should produce
1417// near-zero corrections and behave as an identity transform.
1418TEST_F(AlignmentPluginTest, SPK_AlignValidate_NoErrors)
1419{
1420 SPKMathPlugin plugin;
1421 RunPluginAlignValidate(plugin, {}, 3, 100, 1.0);
1422}
1423
1424TEST_F(AlignmentPluginTest, SPK_AlignValidate_AllTerms_Tokyo)
1425{
1426 SPKMathPlugin plugin;
1427 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0, kTokyo);
1428}
1429
1430TEST_F(AlignmentPluginTest, SPK_AlignValidate_EqGem_Northern)
1431{
1432 SPKMathPlugin plugin;
1433 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0, kLosAngeles, ::Alignment::EQ_GEM);
1434}
1435
1436TEST_F(AlignmentPluginTest, SPK_AlignValidate_EqGem_Southern)
1437{
1438 SPKMathPlugin plugin;
1439 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0, kSydney, ::Alignment::EQ_GEM);
1440}
1441
1442TEST_F(AlignmentPluginTest, SPK_AlignValidate_Arctic)
1443{
1444 SPKMathPlugin plugin;
1445 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0, kArctic);
1446}
1447
1448TEST_F(AlignmentPluginTest, SPK_AlignValidate_Antarctic)
1449{
1450 SPKMathPlugin plugin;
1451 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0, kAntarctic);
1452}
1453
1454TEST_F(AlignmentPluginTest, SPK_AlignValidate_SmallErrors)
1455{
1456 SPKMathPlugin plugin;
1457 RunPluginAlignValidate(plugin, {.ih = ARCSEC_TO_DEG(30), .me = ARCSEC_TO_DEG(18)}, 3, 100, 0.5);
1458}
1459
1460TEST_F(AlignmentPluginTest, SPK_AlignValidate_LargeErrors)
1461{
1462 SPKMathPlugin plugin;
1463 RunPluginAlignValidate(plugin, {.ma = ARCMIN_TO_DEG(30), .me = ARCMIN_TO_DEG(15)}, 3, 100, 1.0);
1464}
1465
1466TEST_F(AlignmentPluginTest, SPK_AlignValidate_Equatorial)
1467{
1468 SPKMathPlugin plugin;
1469 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0, kNairobi);
1470}
1471
1472TEST_F(AlignmentPluginTest, SPK_AlignValidate_HugeIndex)
1473{
1474 SPKMathPlugin plugin;
1475 // SPK fits a 6-parameter model. With 7 alignment points, it should be able to deduce
1476 // the model perfectly even with 35 degree index errors, within reasonable limits.
1477 // However, such massive non-linearities might demand a looser target RMS.
1478 RunPluginAlignValidate(plugin, kHugeIndex, 7, 100, 30000.0);
1479}
1480
1481TEST_F(AlignmentPluginTest, SPK_AlignValidate_HighDec)
1482{
1483 SPKMathPlugin plugin;
1484 // Target High Dec regions (80 degrees to 89 degrees) where NP/CH explode
1485 RunPluginAlignValidate(plugin, kMixed, 7, 100, 100.0, kLosAngeles, ::Alignment::EQ_GEM, 80.0, 89.0);
1486}
1487
1488TEST_F(AlignmentPluginTest, SPK_AlignValidate_EqFork_Northern)
1489{
1490 SPKMathPlugin plugin;
1491 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0,
1492 kLosAngeles, ::Alignment::EQ_FORK);
1493}
1494
1495TEST_F(AlignmentPluginTest, SPK_AlignValidate_EqFork_Southern)
1496{
1497 SPKMathPlugin plugin;
1498 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0,
1499 kSydney, ::Alignment::EQ_FORK);
1500}
1501
1502// ---------------------------------------------------------------------------
1503// SPK -- Minimal sync point boundary tests
1504// ---------------------------------------------------------------------------
1505
1506// 6 alignment points: full 6-term fit with 6 DoF. Tests the 6+ sync-point path.
1507TEST_F(AlignmentPluginTest, SPK_AlignValidate_MinimalPoints_6)
1508{
1509 SPKMathPlugin plugin;
1510 RunPluginAlignValidate(plugin, kMixed, 6, 100, 1.0);
1511}
1512
1513// 2 alignment points: 2 terms fitted (IH, ID — index errors only), zero DoF.
1514// Exercises the outTermCount=2 path in BuildObservationData.
1515TEST_F(AlignmentPluginTest, SPK_AlignValidate_MinimalPoints_2)
1516{
1517 SPKMathPlugin plugin;
1518 RunPluginAllInAlignment(plugin, {.ih = ARCMIN_TO_DEG(5), .id = ARCMIN_TO_DEG(3)}, 2);
1519}
1520
1521// 1 alignment point: 2 terms fitted (IA and IB — index errors only), zero DoF.
1522// Exercises the outTermCount=2 path in BuildObservationData.
1523TEST_F(AlignmentPluginTest, SPK_AlignValidate_MinimalPoints_1)
1524{
1525 SPKMathPlugin plugin;
1526 RunPluginAllInAlignment(plugin, {.ih = ARCMIN_TO_DEG(5)}, 1);
1527}
1528
1529// 3 alignment points: 4 terms fitted (IH, ID, ME, MA), 2 DoF.
1530// Pure polar errors (MA, ME) are fully recoverable with this minimal set.
1531TEST_F(AlignmentPluginTest, SPK_AlignValidate_MinimalPoints_3)
1532{
1533 SPKMathPlugin plugin;
1534 RunPluginAlignValidate(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 3, 100, 1.0);
1535}
1536
1537// Near-pole: Dec range [78, 88] exercises the CH/cos(Dec) near-singularity regime.
1538// Verifies no NaN or infinite residuals near the celestial pole.
1539TEST_F(AlignmentPluginTest, SPK_AlignValidate_NearPole)
1540{
1541 SPKMathPlugin plugin;
1542 RunPluginAlignValidate(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)},
1543 12, 100, 1.0,
1544 kLosAngeles, ::Alignment::EQ_GEM,
1545 /*minDecOverride=*/78.0, /*maxDecOverride=*/88.0);
1546}
1547
1548// ---------------------------------------------------------------------------
1549// SPK -- AlignValidate suite (AltAz mounts)
1550// ---------------------------------------------------------------------------
1551
1552TEST_F(AlignmentPluginTest, SPK_AlignValidate_AltAz_Polar)
1553{
1554 SPKMathPlugin plugin;
1555 RunPluginAlignValidate(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)}, 3, 100, 1.0,
1556 kLosAngeles, ::Alignment::ALTAZ);
1557}
1558
1559TEST_F(AlignmentPluginTest, SPK_AlignValidate_AltAz_AxisErrors)
1560{
1561 SPKMathPlugin plugin;
1562 // IA and IE are terms 0-1 and are fitted at 3 points.
1563 RunPluginAlignValidate(plugin, {.ih = ARCMIN_TO_DEG(5), .id = ARCMIN_TO_DEG(3)}, 3, 100, 1.0,
1564 kLosAngeles, ::Alignment::ALTAZ);
1565}
1566
1567// Regression test for SPK altaz roll = pi-Az_NE convention fix.
1568// See RunGotoConventionRegression for the methodology.
1569// Tolerance 0.1deg: SPK fits the injected IH/ID errors exactly, so the
1570// residual at a held-out point is sub-arcsecond. A 180deg convention bug
1571// would produce azDiff ~180deg.
1572TEST_F(AlignmentPluginTest, SPK_AltAz_Goto_Convention_Regression)
1573{
1574 SPKMathPlugin plugin;
1575 RunGotoConventionRegression(plugin, {.ih = ARCMIN_TO_DEG(5), .id = ARCMIN_TO_DEG(3)},
1576 3, 12.0, 50.0, 0.1);
1577}
1578
1579TEST_F(AlignmentPluginTest, SPK_EQ_Polar_Mapping_Regression)
1580{
1581 SPKMathPlugin plugin;
1582 // Inject significant ME and MA and verify the plugin maps them to the correct axes.
1583 // Use 10 points to ensure a full 6-term fit is well-conditioned.
1584 RunEqPolarRegression(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)},
1585 10, 18.0, 45.0, 1.0);
1586}
1587
1588TEST_F(AlignmentPluginTest, SPK_EQ_ConeError_Mapping_Regression)
1589{
1590 SPKMathPlugin plugin;
1591 // Inject significant CH and verify the plugin maps it correctly.
1592 RunEqPolarRegression(plugin, {.ch = ARCMIN_TO_DEG(10)},
1593 10, 18.0, 45.0, 1.0);
1594}
1595
1596TEST_F(AlignmentPluginTest, SPK_EQ_Sydney_Mapping_Regression)
1597{
1598 SPKMathPlugin plugin;
1599 // Verify Southern Hemisphere polar mapping (Sydney, lat=-33.9)
1600 RunEqPolarRegression(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)},
1601 10, 18.0, -45.0, 1.0, kSydney);
1602}
1603
1604TEST_F(AlignmentPluginTest, SPK_AltAz_Polar_Mapping_Regression)
1605{
1606 SPKMathPlugin plugin;
1607 // Inject significant AN and AW and verify the plugin maps them to the correct axes.
1608 // Use 10 points to ensure a full 6-term fit is well-conditioned.
1609 RunAltAzPolarRegression(plugin, {.ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(5)},
1610 10, 18.0, 45.0, 1.0);
1611}
1612
1613TEST_F(AlignmentPluginTest, SPK_AlignValidate_AltAz_NonPerp)
1614{
1615 SPKMathPlugin plugin;
1616 RunPluginAlignValidate(plugin, {.np = ARCMIN_TO_DEG(2)}, 3, 100, 1.0,
1617 kLosAngeles, ::Alignment::ALTAZ);
1618}
1619
1620TEST_F(AlignmentPluginTest, SPK_AlignValidate_AltAz_AllTerms)
1621{
1622 SPKMathPlugin plugin;
1623 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0,
1624 kLosAngeles, ::Alignment::ALTAZ);
1625}
1626
1627TEST_F(AlignmentPluginTest, SPK_AlignValidate_AltAz_Southern)
1628{
1629 SPKMathPlugin plugin;
1630 RunPluginAlignValidate(plugin, kMixed, 3, 100, 1.0,
1631 kSydney, ::Alignment::ALTAZ);
1632}
1633
1634// ---------------------------------------------------------------------------
1635// Noise stress tests -- Gaussian centroiding noise injected into alignment
1636// entries to simulate real-world pointing uncertainty (σ = 30" per axis).
1637//
1638// Residual is the angular error between the plugin's predicted telescope
1639// direction and the true (noiseless) direction from the simulator, so it
1640// directly measures generalization accuracy rather than round-trip consistency.
1641//
1642// Expected behavior:
1643// - 3 pts: SPK fits 4 terms (IH, ID, ME, MA) with 2 DoF; SVD is near
1644// determined. Residuals are larger than at 12 pts.
1645// - 12 pts: SPK's 6-term systematic model has 18 DoF to average down noise;
1646// SVD's general linear fit has more free parameters and averages less.
1647// SPK should show lower residuals at new sky positions.
1648// ---------------------------------------------------------------------------
1649
1650static constexpr double kNoiseSigma30arcsec = ARCSEC_TO_DEG(30.0); // 30" per axis
1651
1652TEST_F(AlignmentPluginTest, SPK_TimeShift_Regression)
1653{
1654 SPKMathPlugin plugin;
1655 RunTimeShiftValidate(plugin, kMixed, 6, 100, 3.0, 1.0);
1656}
1657
1658TEST_F(AlignmentPluginTest, SVD_TimeShift_Regression)
1659{
1660 SVDMathPlugin plugin;
1661 RunTimeShiftValidate(plugin, kMixed, 3, 100, 3.0, 1.0);
1662}
1663
1664TEST_F(AlignmentPluginTest, NoiseStress_SPK_3pts)
1665{
1666 SPKMathPlugin noisyPlugin, refPlugin;
1667 RunPluginNoiseValidate(noisyPlugin, refPlugin, kMixed, 3, 100, kNoiseSigma30arcsec, 300.0);
1668}
1669
1670TEST_F(AlignmentPluginTest, NoiseStress_SVD_3pts)
1671{
1672 SVDMathPlugin noisyPlugin, refPlugin;
1673 RunPluginNoiseValidate(noisyPlugin, refPlugin, kMixed, 3, 100, kNoiseSigma30arcsec, 300.0);
1674}
1675
1676TEST_F(AlignmentPluginTest, NoiseStress_SPK_12pts)
1677{
1678 SPKMathPlugin noisyPlugin, refPlugin;
1679 RunPluginNoiseValidate(noisyPlugin, refPlugin, kMixed, 12, 100, kNoiseSigma30arcsec, 120.0);
1680}
1681
1682TEST_F(AlignmentPluginTest, NoiseStress_SVD_12pts)
1683{
1684 SVDMathPlugin noisyPlugin, refPlugin;
1685 RunPluginNoiseValidate(noisyPlugin, refPlugin, kMixed, 12, 100, kNoiseSigma30arcsec, 120.0);
1686}
1687
1688// ---------------------------------------------------------------------------
1689// Polar-degeneracy regression tests
1690//
1691// When all sync points are near the celestial pole (EQ) or zenith (altaz),
1692// the roll-index term (IH/IA) is unobservable because its basis function
1693// scales as cos(pitch) which is ~0. Without the degeneracy guard in
1694// Initialise(), the solver produces an arbitrary IH/IA value that causes
1695// wildly wrong gotos at any other part of the sky.
1696//
1697// These tests reproduce the exact failure scenario from the field:
1698// 1. Single sync point near pole/zenith with mount errors
1699// 2. Goto + roundtrip to a target far from the pole/zenith
1700// 3. Verify the result is sane (not garbage)
1701// ---------------------------------------------------------------------------
1702
1703// ---------------------------------------------------------------------------
1704// PolarDegeneracy tests
1705//
1706// Syncs a plugin at a single near-pole (EQ) or near-zenith (altaz) point
1707// with mount errors, then does TransformCelestialToTelescope to a target
1708// far from the pole/zenith and checks the resulting encoder position is
1709// sane. A roundtrip (C->T->C) always passes for invertible plugins, so
1710// we check the forward direction: decode the TDV to encoder HA/Dec (or
1711// Az/El) and verify it's not wildly different from what a zero-error mount
1712// would produce.
1713// ---------------------------------------------------------------------------
1714
1715// Defined as TEST_F body helper so it can access fixture members.
1716// Called from individual TEST_F cases below.
1717
1718static void RunPolarDegeneracyTestImpl(
1719 MathPlugin &plugin, const MountErrors &errors,
1721 ::Alignment::MOUNT_TYPE mountType,
1722 double fixedJD = 2461113.81667)
1723{
1724 auto makeGenerator = [](const MountErrors &e, INDI::IGeographicCoordinates s,
1726 {
1727 ::Alignment gen;
1728 gen.mountType = mt;
1729 gen.latitude = Angle(s.latitude);
1730 gen.longitude = Angle(s.longitude);
1731 gen.setCorrections(e.ih, e.id, e.ch, e.np, e.ma, e.me);
1732 return gen;
1733 };
1734
1735 auto buildEntryLocal = [&](::Alignment &gen,
1737 double ra, double dec, double lst_hrs, double jd,
1740 {
1741 Angle ha(get_local_hour_angle(lst_hrs, ra), Angle::HOURS), adec(dec);
1742 entry.RightAscension = ra;
1743 entry.Declination = dec;
1744 entry.ObservationJulianDate = jd;
1745 if (mt == ::Alignment::ALTAZ)
1746 {
1747 Angle primary, secondary;
1748 gen.apparentHaDecToMount(ha, adec, &primary, &secondary);
1749 INDI::IHorizontalCoordinates horCoords = { primary.Degrees(), secondary.Degrees() };
1750 entry.TelescopeDirection = pSupport->TelescopeDirectionVectorFromAltitudeAzimuth(horCoords);
1751 }
1752 else
1753 {
1754 Angle mha, mdec;
1755 gen.observedToInstrument(ha, adec, &mha, &mdec);
1756 double instRA = range24(lst_hrs - mha.Hours());
1757 INDI::IEquatorialCoordinates raCoords = { instRA, mdec.Degrees() };
1758 entry.TelescopeDirection = pSupport->TelescopeDirectionVectorFromEquatorialCoordinates(raCoords);
1759 }
1760 };
1761 MountAlignment_t alignment = (mountType == ::Alignment::ALTAZ) ? ZENITH :
1762 site.latitude >= 0 ? NORTH_CELESTIAL_POLE
1764 plugin.SetApproximateMountAlignment(alignment);
1765
1768
1769 ::Alignment gen = makeGenerator(errors, site, mountType);
1770
1771 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
1772 ASSERT_NE(pSupport, nullptr);
1773
1774 double gmst = ln_get_apparent_sidereal_time(fixedJD);
1775 double lst = range24(gmst + DEG_TO_HOURS(site.longitude));
1776
1777 // Single sync point near the pole/zenith
1778 double syncRA, syncDec;
1779 if (mountType == ::Alignment::ALTAZ)
1780 {
1781 // Near-zenith: RA ≈ LST, Dec ≈ latitude
1782 syncRA = lst;
1783 syncDec = site.latitude + 1.0;
1784 }
1785 else
1786 {
1787 // Near celestial pole
1788 syncRA = lst - 1.0;
1789 syncDec = 88.0;
1790 }
1791
1793 buildEntryLocal(gen, pSupport, syncRA, syncDec, lst, fixedJD, mountType, entry);
1794 db.GetAlignmentDatabase().push_back(entry);
1795
1796 ASSERT_TRUE(plugin.Initialise(&db));
1797
1798 // Target far from the pole/zenith
1799 double testRA = 12.0;
1800 double testDec = (mountType == ::Alignment::ALTAZ) ? 30.0 : 45.0;
1801
1803 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(testRA, testDec, fixedJD, resultV));
1804
1805 // Decode the TDV to encoder coordinates and sanity-check
1806 if (mountType == ::Alignment::ALTAZ)
1807 {
1809 pSupport->AltitudeAzimuthFromTelescopeDirectionVector(resultV, hor);
1810 // For a target at Dec=30 from LA, the elevation should be positive
1811 // and less than ~90. A degenerate transform might produce negative
1812 // elevation or wildly wrong azimuth.
1813 EXPECT_GT(hor.altitude, -10.0)
1814 << "AltAz polar degeneracy: El=" << hor.altitude
1815 << " -- forward transform produced sub-horizon encoder position";
1816 EXPECT_LT(hor.altitude, 90.0)
1817 << "AltAz polar degeneracy: El=" << hor.altitude;
1818 }
1819 else
1820 {
1822 pSupport->EquatorialCoordinatesFromTelescopeDirectionVector(resultV, eq);
1823 double encoderRA = eq.rightascension;
1824 // The encoder RA should be roughly near the target RA.
1825 // With small mount errors (arcminutes) the difference should be
1826 // well under a few degrees, not tens of degrees.
1827 double dRA = rangeHA(encoderRA - testRA) * 15.0; // degrees
1828 // Tolerances are generous (15 deg) because this test uses large mount
1829 // errors (IH=5 deg, ID=5 deg) with a single sync point far from the
1830 // test target. The goal is to detect catastrophic failures, not to
1831 // verify sub-degree accuracy.
1832 EXPECT_LT(std::abs(dRA), 15.0)
1833 << "EQ polar degeneracy: encoder RA=" << encoderRA
1834 << "h vs target RA=" << testRA
1835 << "h (dRA=" << dRA << " deg)"
1836 << " -- forward transform produced garbage encoder RA";
1837 EXPECT_LT(std::abs(eq.declination - testDec), 15.0)
1838 << "EQ polar degeneracy: encoder Dec=" << eq.declination
1839 << " vs target Dec=" << testDec
1840 << " -- forward transform produced garbage encoder Dec";
1841 }
1842}
1843
1844// Field-scenario errors: IH=300' ID=300' CH=5' NP=0.5' MA=10' ME=6'
1845static const MountErrors kFieldErrors = {
1846 .ih = 5.0, .id = 5.0, .ch = ARCMIN_TO_DEG(5),
1847 .np = ARCMIN_TO_DEG(0.5), .ma = ARCMIN_TO_DEG(10), .me = ARCMIN_TO_DEG(6),
1848};
1849
1850TEST_F(AlignmentPluginTest, BuiltIn_PolarDegeneracy_EQ_SinglePoint)
1851{
1852 BuiltInMathPlugin plugin;
1853 RunPolarDegeneracyTestImpl(plugin, kFieldErrors, kLosAngeles, ::Alignment::EQ_GEM);
1854}
1855
1856TEST_F(AlignmentPluginTest, SVD_PolarDegeneracy_EQ_SinglePoint)
1857{
1858 SVDMathPlugin plugin;
1859 RunPolarDegeneracyTestImpl(plugin, kFieldErrors, kLosAngeles, ::Alignment::EQ_GEM);
1860}
1861
1862TEST_F(AlignmentPluginTest, Nearest_PolarDegeneracy_EQ_SinglePoint)
1863{
1864 NearestMathPlugin plugin;
1865 RunPolarDegeneracyTestImpl(plugin, kFieldErrors, kLosAngeles, ::Alignment::EQ_GEM);
1866}
1867
1868TEST_F(AlignmentPluginTest, BuiltIn_PolarDegeneracy_AltAz_SinglePoint)
1869{
1870 BuiltInMathPlugin plugin;
1871 RunPolarDegeneracyTestImpl(plugin, kFieldErrors, kLosAngeles, ::Alignment::ALTAZ);
1872}
1873
1874TEST_F(AlignmentPluginTest, SVD_PolarDegeneracy_AltAz_SinglePoint)
1875{
1876 SVDMathPlugin plugin;
1877 RunPolarDegeneracyTestImpl(plugin, kFieldErrors, kLosAngeles, ::Alignment::ALTAZ);
1878}
1879
1880TEST_F(AlignmentPluginTest, Nearest_PolarDegeneracy_AltAz_SinglePoint)
1881{
1882 NearestMathPlugin plugin;
1883 RunPolarDegeneracyTestImpl(plugin, kFieldErrors, kLosAngeles, ::Alignment::ALTAZ);
1884}
1885
1886TEST_F(AlignmentPluginTest, SPK_PolarDegeneracy_EQ_SinglePoint)
1887{
1888 SPKMathPlugin plugin;
1889 RunPolarDegeneracyTestImpl(plugin, kFieldErrors, kLosAngeles, ::Alignment::EQ_GEM);
1890}
1891
1892TEST_F(AlignmentPluginTest, SPK_PolarDegeneracy_AltAz_SinglePoint)
1893{
1894 SPKMathPlugin plugin;
1895 RunPolarDegeneracyTestImpl(plugin, kFieldErrors, kLosAngeles, ::Alignment::ALTAZ);
1896}
1897
1898// ---------------------------------------------------------------------------
1899// SPK incremental hot-path tests
1900//
1901// Each test cold-initialises a plugin with 6 sync points (which builds the
1902// NE accumulator at nt=6), adds one more point, and verifies the hot-path
1903// result matches a fresh cold-initialised plugin with all 7 points.
1904// ---------------------------------------------------------------------------
1905
1906TEST_F(AlignmentPluginTest, SPK_Incremental_MatchesCold_Equatorial)
1907{
1909 SPKMathPlugin hotPlugin;
1910 auto generator = buildIncrementalDb(hotPlugin, kMixed, 6, db);
1911
1912 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&hotPlugin);
1913 double gmst = ln_get_apparent_sidereal_time(fixedJD);
1914 double lst = range24(gmst + DEG_TO_HOURS(kLosAngeles.longitude));
1915 {
1916 double ra, dec;
1917 auto raw = HaltonSequence(2, 3).getRaw(7);
1918 getSkyPoint(raw.first, raw.second, kEQ_MinDec, kEQ_MaxDec, ra, dec);
1920 buildEntry(generator, pSupport, ra, dec, lst, fixedJD, ::Alignment::EQ_GEM, entry);
1921 db.GetAlignmentDatabase().push_back(entry);
1922 }
1923 ASSERT_TRUE(hotPlugin.Initialise(&db));
1924
1925 SPKMathPlugin coldPlugin;
1927 ASSERT_TRUE(coldPlugin.Initialise(&db));
1928
1929 verifyHotMatchesCold(hotPlugin, coldPlugin);
1930}
1931
1932TEST_F(AlignmentPluginTest, SPK_Incremental_MatchesCold_AltAz)
1933{
1935 SPKMathPlugin hotPlugin;
1936 auto generator = buildIncrementalDb(hotPlugin, kMixed, 6, db, ::Alignment::ALTAZ);
1937
1938 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&hotPlugin);
1939 double gmst = ln_get_apparent_sidereal_time(fixedJD);
1940 double lst = range24(gmst + DEG_TO_HOURS(kLosAngeles.longitude));
1941 {
1942 double ra, dec;
1943 auto raw = HaltonSequence(2, 3).getRaw(7);
1944 getSkyPoint(raw.first, raw.second, kAZ_MinEl, kAZ_MaxEl, ra, dec);
1946 buildEntry(generator, pSupport, ra, dec, lst, fixedJD, ::Alignment::ALTAZ, entry);
1947 db.GetAlignmentDatabase().push_back(entry);
1948 }
1949 ASSERT_TRUE(hotPlugin.Initialise(&db));
1950
1951 SPKMathPlugin coldPlugin;
1953 ASSERT_TRUE(coldPlugin.Initialise(&db));
1954
1955 verifyHotMatchesCold(hotPlugin, coldPlugin, ::Alignment::ALTAZ);
1956}
1957
1958TEST_F(AlignmentPluginTest, SPK_Incremental_InvalidStateNotUsed)
1959{
1960 // With fewer than 6 points (m_NE_valid=false), adding a 6th must take the
1961 // cold path (which then builds the NE accumulator). A subsequent 7th
1962 // point should use the hot path and produce a sensible result.
1964 SPKMathPlugin plugin;
1965 auto generator = buildIncrementalDb(plugin, kMixed, 5, db);
1966
1967 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
1968 double gmst = ln_get_apparent_sidereal_time(fixedJD);
1969 double lst = range24(gmst + DEG_TO_HOURS(kLosAngeles.longitude));
1970
1971 for (int i : {6, 7})
1972 {
1973 double ra, dec;
1974 auto raw = HaltonSequence(2, 3).getRaw(i);
1975 getSkyPoint(raw.first, raw.second, kEQ_MinDec, kEQ_MaxDec, ra, dec);
1977 buildEntry(generator, pSupport, ra, dec, lst, fixedJD, ::Alignment::EQ_GEM, entry);
1978 db.GetAlignmentDatabase().push_back(entry);
1979 ASSERT_TRUE(plugin.Initialise(&db));
1980 }
1981
1983 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(12.0, 45.0, fixedJD, v));
1984 double outRA, outDec;
1985 plugin.TransformTelescopeToCelestialJD(v, outRA, outDec, fixedJD);
1986 EXPECT_NEAR(12.0, outRA, kRoundTripTolHours);
1987 EXPECT_NEAR(45.0, outDec, kRoundTripTolDeg);
1988}
1989
1990TEST_F(AlignmentPluginTest, SPK_Incremental_MountTypeChange)
1991{
1992 // Cold-init equatorial (m_NE_mountChar='E'). Switch to AltAz. The
1993 // mountChar mismatch must force the cold path on the next Initialise call.
1995 SPKMathPlugin plugin;
1996 buildIncrementalDb(plugin, kMixed, 6, db);
1997
1999
2000 InMemoryDatabase dbAz;
2001 auto generatorAz = buildIncrementalDb(plugin, kMixed, 7, dbAz, ::Alignment::ALTAZ);
2002 (void)generatorAz;
2003
2005 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(12.0, 60.0, fixedJD, v));
2006 double outRA, outDec;
2007 plugin.TransformTelescopeToCelestialJD(v, outRA, outDec, fixedJD);
2008 EXPECT_NEAR(12.0, outRA, kRoundTripTolHours);
2009 EXPECT_NEAR(60.0, outDec, kRoundTripTolDeg);
2010}
2011
2012// ---------------------------------------------------------------------------
2013// SPK plugin lifecycle tests
2014//
2015// Verify defensive behaviour around missing location and mount-type propagation.
2016// ---------------------------------------------------------------------------
2017
2018// Transforms must fail gracefully when no reference position has been set.
2019TEST_F(AlignmentPluginTest, SPK_Lifecycle_NoLocation_TransformsFail)
2020{
2021 InMemoryDatabase db; // no SetDatabaseReferencePosition
2022 SPKMathPlugin plugin;
2023 plugin.Initialise(&db);
2025
2027 EXPECT_FALSE(plugin.TransformCelestialToTelescopeJD(12.0, 45.0, fixedJD, v));
2028
2029 // Provide a dummy TDV and confirm T->C also fails without a location.
2030 v = TelescopeDirectionVector{0, 0, 1};
2031 double outRA, outDec;
2032 EXPECT_FALSE(plugin.TransformTelescopeToCelestialJD(v, outRA, outDec, fixedJD));
2033}
2034
2035// Initialise without a location must not crash and must return true
2036// (model is simply unconstrained, not an error).
2037TEST_F(AlignmentPluginTest, SPK_Lifecycle_NoLocation_InitialiseOk)
2038{
2040 SPKMathPlugin plugin;
2041 EXPECT_TRUE(plugin.Initialise(&db));
2042}
2043
2044// Transforms must succeed once a location is provided, even if it arrives
2045// after the initial Initialise call (simulates late ISGetProperties config load).
2046TEST_F(AlignmentPluginTest, SPK_Lifecycle_LateLocation_TransformsSucceed)
2047{
2049 SPKMathPlugin plugin;
2050 plugin.Initialise(&db);
2052
2053 // Location arrives late (e.g. from a saved config property).
2054 db.SetDatabaseReferencePosition(kLosAngeles);
2055
2057 EXPECT_TRUE(plugin.TransformCelestialToTelescopeJD(12.0, 45.0, fixedJD, v));
2058 double outRA, outDec;
2059 EXPECT_TRUE(plugin.TransformTelescopeToCelestialJD(v, outRA, outDec, fixedJD));
2060}
2061
2062// Mount type set after Initialise must be picked up on the next transform call,
2063// without requiring a second Initialise. This is the celestronaux bug scenario:
2064// Initialise is called at physical connection before the driver re-propagates
2065// its mount type to the plugin.
2066TEST_F(AlignmentPluginTest, SPK_Lifecycle_MountTypeAfterInit_PickedUpByTransform)
2067{
2069 db.SetDatabaseReferencePosition(kLosAngeles);
2070 SPKMathPlugin plugin;
2071
2072 // Initialise with EQUAT (default from MathPlugin constructor)
2073 plugin.Initialise(&db);
2074 ASSERT_EQ(plugin.GetApproximateMountAlignment(), ZENITH); // default
2075
2076 // Driver now sets ALTAZ — no second Initialise.
2078
2079 // Transform should use ALTAZ formulas. Verify by round-trip: C->T->C.
2081 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(12.0, 30.0, fixedJD, v));
2082 double outRA, outDec;
2083 ASSERT_TRUE(plugin.TransformTelescopeToCelestialJD(v, outRA, outDec, fixedJD));
2084 EXPECT_NEAR(12.0, outRA, kRoundTripTolHours);
2085 EXPECT_NEAR(30.0, outDec, kRoundTripTolDeg);
2086
2087 // Now switch to EQUAT without Initialise — must also be picked up.
2089 ASSERT_TRUE(plugin.TransformCelestialToTelescopeJD(6.0, 45.0, fixedJD, v));
2090 ASSERT_TRUE(plugin.TransformTelescopeToCelestialJD(v, outRA, outDec, fixedJD));
2091 EXPECT_NEAR(6.0, outRA, kRoundTripTolHours);
2092 EXPECT_NEAR(45.0, outDec, kRoundTripTolDeg);
2093}
2094
2095// ---------------------------------------------------------------------------
2096// Driver pipeline integration tests
2097//
2098// These simulate what telescope_simulator.cpp does with scopesim_helper
2099// outputs, catching convention mismatches (e.g. stale +-180 offsets) that
2100// unit tests on individual functions miss.
2101//
2102// The pattern mirrors the driver's actual flow:
2103// Goto: RA/Dec -> instrumentHaDecToMount -> axisPrimary.position = primary
2104// Read: mountToInstrumentHaDec(axisPrimary.position, ...) -> RA/Dec
2105// Sync: TelescopeDirectionVectorFromAltitudeAzimuth({primary.Degrees(), ...})
2106// PE: mountToApparentHaDec(axisPrimary.position, ...) -> RA/Dec
2107// ---------------------------------------------------------------------------
2108
2110{
2111protected:
2112 struct SkyPoint { double ra; double dec; };
2113
2114 static constexpr SkyPoint kTestPoints[] = {
2115 { 3.0, 45.0},
2116 { 8.0, -15.0},
2117 {14.0, 70.0},
2118 {20.0, 10.0},
2119 };
2120
2122 {
2123 double gmst = ln_get_apparent_sidereal_time(fixedJD);
2124 return range24(gmst + DEG_TO_HOURS(site.longitude));
2125 }
2126};
2127
2128// instrumentHaDecToMount -> mountToInstrumentHaDec round-trip for ALTAZ.
2129// The driver stores instrumentHaDecToMount output directly in axisPrimary.position
2130// (no +-180 conversion) and reads it back via mountToInstrumentHaDec.
2131TEST_F(DriverPipelineTest, AltAz_InstrumentRoundTrip)
2132{
2133 ::Alignment gen = makeGenerator({}, kLosAngeles, ::Alignment::ALTAZ);
2134 double lst = getLST(kLosAngeles);
2135
2136 for (auto [testRA, testDec] : kTestPoints)
2137 {
2138 Angle ha(get_local_hour_angle(lst, testRA), Angle::HOURS);
2139 Angle primary, secondary;
2140 gen.instrumentHaDecToMount(ha, Angle(testDec), &primary, &secondary);
2141
2142 // Driver does: axisPrimary.position = primary (no conversion)
2143 Angle instHA, instDec;
2144 gen.mountToInstrumentHaDec(primary, secondary, &instHA, &instDec);
2145
2146 double recoveredRA = range24(lst - instHA.Hours());
2147 EXPECT_NEAR(testRA, recoveredRA, 0.001) << "RA round-trip failed";
2148 EXPECT_NEAR(testDec, instDec.Degrees(), 0.001) << "Dec round-trip failed";
2149 }
2150}
2151
2152// instrumentHaDecToMount -> mountToApparentHaDec round-trip for ALTAZ.
2153// With zero mount errors, apparent == instrument, so HA/Dec should round-trip.
2154// We use mountToApparentHaDec (not mountToApparentRaDec) to avoid dependency
2155// on system-time LST which differs from the test's fixed JD LST.
2156TEST_F(DriverPipelineTest, AltAz_ApparentRoundTrip)
2157{
2158 ::Alignment gen = makeGenerator({}, kLosAngeles, ::Alignment::ALTAZ);
2159 double lst = getLST(kLosAngeles);
2160
2161 for (auto [testRA, testDec] : kTestPoints)
2162 {
2163 double inputHA = get_local_hour_angle(lst, testRA);
2164 Angle ha(inputHA, Angle::HOURS);
2165 Angle primary, secondary;
2166 gen.instrumentHaDecToMount(ha, Angle(testDec), &primary, &secondary);
2167
2168 // With zero errors, mountToApparentHaDec should return the same HA/Dec
2169 Angle apparentHa, apparentDec;
2170 gen.mountToApparentHaDec(primary, secondary, &apparentHa, &apparentDec);
2171
2172 EXPECT_NEAR(range24(inputHA), apparentHa.Hours(), 0.001) << "HA apparent round-trip failed at RA=" << testRA;
2173 EXPECT_NEAR(testDec, apparentDec.Degrees(), 0.001) << "Dec apparent round-trip failed at RA=" << testRA;
2174 }
2175}
2176
2177// Verify that instrumentHaDecToMount output yields the correct TDV when
2178// passed to TelescopeDirectionVectorFromAltitudeAzimuth (the Sync path).
2179// The TDV should match what EquatorialToHorizontal + TDV conversion produces.
2180TEST_F(DriverPipelineTest, AltAz_SyncTDV_Convention)
2181{
2182 ::Alignment gen = makeGenerator({}, kLosAngeles, ::Alignment::ALTAZ);
2183 SPKMathPlugin plugin;
2184 auto *pSupport = dynamic_cast<TelescopeDirectionVectorSupportFunctions *>(&plugin);
2185 double lst = getLST(kLosAngeles);
2186
2187 for (auto [testRA, testDec] : kTestPoints)
2188 {
2189 Angle ha(get_local_hour_angle(lst, testRA), Angle::HOURS);
2190 Angle primary, secondary;
2191 gen.instrumentHaDecToMount(ha, Angle(testDec), &primary, &secondary);
2192
2193 // Driver Sync path: TDV from axisPrimary.position (no conversion)
2194 INDI::IHorizontalCoordinates horCoords = { primary.Degrees(), secondary.Degrees() };
2195 TelescopeDirectionVector driverTDV =
2196 pSupport->TelescopeDirectionVectorFromAltitudeAzimuth(horCoords);
2197
2198 // Reference: EquatorialToHorizontal for the same sky position
2199 INDI::IEquatorialCoordinates eq = { testRA, testDec };
2200 INDI::IGeographicCoordinates site = kLosAngeles;
2202 INDI::EquatorialToHorizontal(&eq, &site, fixedJD, &refAltAz);
2204 pSupport->TelescopeDirectionVectorFromAltitudeAzimuth(refAltAz);
2205
2206 // TDVs should be nearly identical (no mount errors)
2207 EXPECT_NEAR(refTDV.x, driverTDV.x, 1e-4) << "TDV.x mismatch at RA=" << testRA;
2208 EXPECT_NEAR(refTDV.y, driverTDV.y, 1e-4) << "TDV.y mismatch at RA=" << testRA;
2209 EXPECT_NEAR(refTDV.z, driverTDV.z, 1e-4) << "TDV.z mismatch at RA=" << testRA;
2210 }
2211}
2212
2213// Same pipeline tests for EQ_GEM to ensure we didn't break equatorial.
2214TEST_F(DriverPipelineTest, EqGem_InstrumentRoundTrip)
2215{
2216 ::Alignment gen = makeGenerator({}, kLosAngeles, ::Alignment::EQ_GEM);
2217 double lst = getLST(kLosAngeles);
2218
2219 for (auto [testRA, testDec] : kTestPoints)
2220 {
2221 Angle ha(get_local_hour_angle(lst, testRA), Angle::HOURS);
2222 Angle primary, secondary;
2223 gen.instrumentHaDecToMount(ha, Angle(testDec), &primary, &secondary);
2224
2225 Angle instHA, instDec;
2226 gen.mountToInstrumentHaDec(primary, secondary, &instHA, &instDec);
2227
2228 double recoveredRA = range24(lst - instHA.Hours());
2229 EXPECT_NEAR(testRA, recoveredRA, 0.001) << "RA round-trip failed";
2230 EXPECT_NEAR(testDec, instDec.Degrees(), 0.001) << "Dec round-trip failed";
2231 }
2232}
2233
2234// Southern hemisphere ALTAZ pipeline
2235TEST_F(DriverPipelineTest, AltAz_InstrumentRoundTrip_Southern)
2236{
2237 ::Alignment gen = makeGenerator({}, kSydney, ::Alignment::ALTAZ);
2238 double lst = getLST(kSydney);
2239
2240 for (auto [testRA, testDec] : kTestPoints)
2241 {
2242 Angle ha(get_local_hour_angle(lst, testRA), Angle::HOURS);
2243 Angle primary, secondary;
2244 gen.instrumentHaDecToMount(ha, Angle(testDec), &primary, &secondary);
2245
2246 Angle instHA, instDec;
2247 gen.mountToInstrumentHaDec(primary, secondary, &instHA, &instDec);
2248
2249 double recoveredRA = range24(lst - instHA.Hours());
2250 EXPECT_NEAR(testRA, recoveredRA, 0.001) << "RA round-trip failed";
2251 EXPECT_NEAR(testDec, instDec.Degrees(), 0.001) << "Dec round-trip failed";
2252 }
2253}
2254
2255// ---------------------------------------------------------------------------
2256// LegacyPlugin fallback: verifies MathPlugin::TransformCelestialToTelescopeJD
2257// default converts absolute JD -> correct JulianOffset for legacy external plugins.
2258//
2259// A legacy plugin implements only the old pure-virtual methods and does not
2260// override the new *JD methods. The base-class default must compute
2261// JulianOffset = JulianDate - ln_get_julian_from_sys()
2262// so that when the legacy plugin adds ln_get_julian_from_sys() internally it
2263// recovers the original absolute JD. Passing 0.0 (the old bug) would give
2264// the wrong time to any legacy plugin.
2265// ---------------------------------------------------------------------------
2266
2267namespace {
2268
2269struct LegacyPlugin : public MathPlugin
2270{
2271 double lastOffset = 999.0; // sentinel; 0.0 would mask the old bug
2272
2273 bool TransformCelestialToTelescope(const double, const double,
2274 double JulianOffset,
2275 TelescopeDirectionVector &) override
2276 {
2277 lastOffset = JulianOffset;
2278 return true;
2279 }
2280
2282 double &, double &, double JulianOffset) override
2283 {
2284 lastOffset = JulianOffset;
2285 return true;
2286 }
2287};
2288
2289} // namespace
2290
2291TEST_F(AlignmentPluginTest, LegacyPlugin_JD_Fallback_CelestialToTelescope)
2292{
2293 LegacyPlugin plugin;
2295 plugin.Initialise(&db);
2296
2298 double t1 = ln_get_julian_from_sys();
2299 plugin.TransformCelestialToTelescopeJD(1.0, 45.0, fixedJD, tdv);
2300 double t2 = ln_get_julian_from_sys();
2301
2302 // The default fallback computes offset = fixedJD - ln_get_julian_from_sys().
2303 // Bracket with t1/t2 to tolerate any wall-clock delta during the call:
2304 // fixedJD - t2 <= capturedOffset <= fixedJD - t1
2305 // With the old 0.0 bug, capturedOffset == 0.0 which fails EXPECT_LE when
2306 // fixedJD != now (i.e., always in practice).
2307 EXPECT_GE(plugin.lastOffset, fixedJD - t2) << "offset too small";
2308 EXPECT_LE(plugin.lastOffset, fixedJD - t1) << "offset too large (was 0.0 before fix)";
2309}
2310
2311TEST_F(AlignmentPluginTest, LegacyPlugin_JD_Fallback_TelescopeToCelestial)
2312{
2313 LegacyPlugin plugin;
2315 plugin.Initialise(&db);
2316
2317 TelescopeDirectionVector tdv{0.0, 0.0, 1.0};
2318 double ra = 0, dec = 0;
2319 double t1 = ln_get_julian_from_sys();
2320 plugin.TransformTelescopeToCelestialJD(tdv, ra, dec, fixedJD);
2321 double t2 = ln_get_julian_from_sys();
2322
2323 EXPECT_GE(plugin.lastOffset, fixedJD - t2) << "offset too small";
2324 EXPECT_LE(plugin.lastOffset, fixedJD - t1) << "offset too large (was 0.0 before fix)";
2325}
2326
2327int main(int argc, char **argv)
2328{
2329 ::testing::InitGoogleTest(&argc, argv);
2330 return RUN_ALL_TESTS();
2331}
Generic mathematical utility for generating 2-D Halton quasi-random sequences.
void RunPluginAlignValidate(MathPlugin &plugin, const MountErrors &errors, int numAlign, int numValidate, double targetRMSArcsec, INDI::IGeographicCoordinates site=kLosAngeles, ::Alignment::MOUNT_TYPE mountType=::Alignment::EQ_GEM, double minDecOverride=NAN, double maxDecOverride=NAN, double *maxResidualOut=nullptr)
static void buildEntry(::Alignment &gen, TelescopeDirectionVectorSupportFunctions *pSupport, double ra, double dec, double lst_hrs, double jd, ::Alignment::MOUNT_TYPE mountType, AlignmentDatabaseEntry &entry)
void RunTimeShiftValidate(MathPlugin &plugin, const MountErrors &errors, int numAlign, int numValidate, double shiftHours, double targetRMSArcsec, INDI::IGeographicCoordinates site=kLosAngeles, ::Alignment::MOUNT_TYPE mountType=::Alignment::EQ_GEM)
void RunPluginAllInAlignment(MathPlugin &plugin, const MountErrors &errors, int numPoints, INDI::IGeographicCoordinates site=kLosAngeles, ::Alignment::MOUNT_TYPE mountType=::Alignment::EQ_GEM, double minDecOverride=NAN, double maxDecOverride=NAN)
::Alignment makeGenerator(const MountErrors &e, INDI::IGeographicCoordinates site, ::Alignment::MOUNT_TYPE mountType=::Alignment::EQ_GEM)
Build and configure a scopesim_helper Alignment generator.
void RunEqPolarRegression(SPKMathPlugin &plugin, const MountErrors &errors, int numAlign, double valRA, double valDec, double tolArcsec, INDI::IGeographicCoordinates site=kLosAngeles)
void verifyHotMatchesCold(SPKMathPlugin &hotPlugin, SPKMathPlugin &coldPlugin, ::Alignment::MOUNT_TYPE mountType=::Alignment::EQ_GEM)
void RunAltAzPolarRegression(SPKMathPlugin &plugin, const MountErrors &errors, int numAlign, double valRA, double valDec, double tolArcsec, INDI::IGeographicCoordinates site=kLosAngeles)
void RunMeridianFlipValidate(T &plugin, const MountErrors &errors, const INDI::IGeographicCoordinates &location, const MountAlignment_t pole, double targetRMSArcsec=40.0)
void RunPluginNoiseValidate(MathPlugin &noisyPlugin, MathPlugin &refPlugin, const MountErrors &errors, int numAlign, int numValidate, double noiseSigmaDeg, double targetRMSArcsec, INDI::IGeographicCoordinates site=kLosAngeles, ::Alignment::MOUNT_TYPE mountType=::Alignment::EQ_GEM)
void RunGotoConventionRegression(MathPlugin &plugin, const MountErrors &errors, int numAlign, double valRA, double valDec, double azTolDeg, INDI::IGeographicCoordinates site=kLosAngeles)
static constexpr double fixedJD
::Alignment buildIncrementalDb(SPKMathPlugin &plugin, const MountErrors &errors, int n, InMemoryDatabase &db, ::Alignment::MOUNT_TYPE mountType=::Alignment::EQ_GEM, INDI::IGeographicCoordinates site=kLosAngeles)
The Alignment class This converts between the mount axis angles and the sky hour angle and declinatio...
void setCorrections(double ih, double id, double ch, double np, double ma, double me)
setCorrections set the values of the six corrections
void instrumentHaDecToMount(Angle instrumentHa, Angle instrumentDec, Angle *primary, Angle *secondary)
void mountToApparentHaDec(Angle primary, Angle secondary, Angle *apparentHa, Angle *apparentDec)
mountToApparentHaDec: convert mount position to apparent Ha, Dec
void apparentHaDecToMount(Angle apparentHa, Angle apparentDec, Angle *primary, Angle *secondary)
apparentHaDecToMount
void observedToInstrument(Angle observedHa, Angle observedDec, Angle *instrumentHa, Angle *instrumentDec)
MOUNT_TYPE mountType
void mountToInstrumentHaDec(Angle primary, Angle secondary, Angle *instrumentHa, Angle *instrumentDec)
mountToInstrumentHaDec: convert mount axis positions to instrument (encoder) HA/Dec without applying ...
The Angle class This class implements an angle type. This holds an angle that is always in the range ...
double Hours()
Hours.
double Degrees()
Degrees.
static double getLST(INDI::IGeographicCoordinates site)
static constexpr SkyPoint kTestPoints[]
virtual bool TransformTelescopeToCelestialJD(const TelescopeDirectionVector &ApparentTelescopeDirectionVector, double &RightAscension, double &Declination, double JulianDate) override
Override for the base class virtual function.
virtual bool TransformCelestialToTelescopeJD(double RightAscension, double Declination, double JulianDate, TelescopeDirectionVector &ApparentTelescopeDirectionVector) override
Override for the base class virtual function.
virtual bool Initialise(InMemoryDatabase *pInMemoryDatabase) override
Override for the base class virtual function.
This class implements the default math plugin.
Generic 2-D Halton quasi-random generator.
std::pair< double, double > getRaw(int n) const
Get the n-th 2-D point in the sequence.
This class provides the driver side API to the in memory alignment database.
void SetDatabaseReferencePosition(double Latitude, double Longitude)
Set the database reference position.
AlignmentDatabaseType & GetAlignmentDatabase()
Get a reference to the in memory database.
Provides alignment subsystem functions to INDI alignment math plugins.
Definition MathPlugin.h:31
virtual bool TransformCelestialToTelescope(const double RightAscension, const double Declination, double JulianOffset, TelescopeDirectionVector &ApparentTelescopeDirectionVector)=0
Get the alignment corrected telescope pointing direction for the supplied celestial coordinates.
virtual bool Initialise(InMemoryDatabase *pInMemoryDatabase)
Initialise or re-initialise the math plugin. Re-reading the in memory database as necessary.
virtual void SetApproximateMountAlignment(MountAlignment_t ApproximateAlignment)
Set the approximate alignment of the mount.
Definition MathPlugin.h:56
virtual bool TransformTelescopeToCelestialJD(const TelescopeDirectionVector &ApparentTelescopeDirectionVector, double &RightAscension, double &Declination, double JulianDate)
Get the true celestial coordinates for the supplied telescope pointing direction.
virtual MountAlignment_t GetApproximateMountAlignment()
Get the approximate alignment of the mount.
Definition MathPlugin.h:45
virtual bool TransformTelescopeToCelestial(const TelescopeDirectionVector &ApparentTelescopeDirectionVector, double &RightAscension, double &Declination, double JulianOffset=0)=0
Get the true celestial coordinates for the supplied telescope pointing direction.
virtual bool TransformCelestialToTelescopeJD(double RightAscension, double Declination, double JulianDate, TelescopeDirectionVector &ApparentTelescopeDirectionVector)
Get the alignment corrected telescope pointing direction for the supplied celestial coordinates.
virtual bool TransformCelestialToTelescopeJD(double RightAscension, double Declination, double JulianDate, TelescopeDirectionVector &ApparentTelescopeDirectionVector) override
Get the alignment corrected telescope pointing direction for the supplied celestial coordinates.
virtual bool Initialise(InMemoryDatabase *pInMemoryDatabase) override
Initialise or re-initialise the math plugin. Re-reading the in memory database as necessary.
virtual bool TransformTelescopeToCelestialJD(const TelescopeDirectionVector &ApparentTelescopeDirectionVector, double &RightAscension, double &Declination, double JulianDate) override
Get the true celestial coordinates for the supplied telescope pointing direction.
virtual bool TransformCelestialToTelescopeJD(double RightAscension, double Declination, double JulianDate, TelescopeDirectionVector &ApparentTelescopeDirectionVector) override
Get the alignment corrected telescope pointing direction for the supplied celestial coordinates.
virtual bool Initialise(InMemoryDatabase *pInMemoryDatabase) override
Initialise or re-initialise the math plugin. Re-reading the in memory database as necessary.
This class implements the SVD math plugin.
These functions are used to convert different coordinate systems to and from the telescope direction ...
const TelescopeDirectionVector TelescopeDirectionVectorFromAltitudeAzimuth(INDI::IHorizontalCoordinates HorizontalCoordinates)
Calculates a normalised direction vector from the supplied altitude and azimuth.
const TelescopeDirectionVector TelescopeDirectionVectorFromEquatorialCoordinates(INDI::IEquatorialCoordinates EquatorialCoordinates)
Calculates a telescope direction vector from the supplied equatorial coordinates.
int main()
double ra
double dec
double rangeHA(double r)
rangeHA Limits the hour angle value to be between -12 —> 12
Definition indicom.c:1301
double range24(double r)
range24 Limits a number to be between 0-24 range.
Definition indicom.c:1311
double get_local_hour_angle(double sideral_time, double ra)
get_local_hour_angle Returns local hour angle of an object
Definition indicom.c:1379
Implementations for common driver routines.
#define ARCSEC_TO_DEG(as)
Definition indimacros.h:116
#define DEG_TO_ARCSEC(d)
Definition indimacros.h:119
#define DEG_TO_HOURS(d)
Definition indimacros.h:107
#define RAD_TO_DEG(r)
Definition indimacros.h:131
#define DEG_TO_RAD(d)
Definition indimacros.h:128
#define ARCMIN_TO_DEG(am)
Definition indimacros.h:110
INumber eq[]
Namespace to encapsulate the INDI Alignment Subsystem classes. For more information see "INDI Alignme...
enum INDI::AlignmentSubsystem::MountAlignment MountAlignment_t
void EquatorialToHorizontal(IEquatorialCoordinates *object, IGeographicCoordinates *observer, double JD, IHorizontalCoordinates *position)
EquatorialToHorizontal Calculate horizontal coordinates from equatorial coordinates.
Definition libastro.cpp:141
static ErrorStats compute(std::vector< double > residuals)
Entry in the in memory alignment database.
Definition Common.h:152
double RightAscension
Right ascension in decimal hours. N.B. libnova works in decimal degrees so conversion is always neede...
Definition Common.h:190
TelescopeDirectionVector TelescopeDirection
Normalised vector giving telescope pointing direction. This is referred to elsewhere as the "apparent...
Definition Common.h:197
double Declination
Declination in decimal degrees.
Definition Common.h:193
Holds a normalised direction vector (direction cosines)
Definition Common.h:69
static const loggerConf file_off
Definition indilogger.h:219
void configure(const std::string &outputFile, const loggerConf configuration, const int fileVerbosityLevel, const int screenVerbosityLevel)
Method to configure the logger. Called by the DEBUG_CONF() macro. To make implementation easier,...
static Logger & getInstance()
Method to get a reference to the object (i.e., Singleton) It is a static method.
double ma
Polar Azimuth error (degrees)
double me
Polar Elevation error (degrees)
double np
Non-perpendicularity (degrees)
double ch
Collimation/cone error (degrees)
double id
Dec index error (degrees)
double ih
HA index error (degrees)
TEST_F(AlignmentPluginTest, Test_BuiltIn_ErrorRecovery)