File indexing completed on 2024-04-21 14:47:28

0001 /*  KStars scheduler operations tests
0002     SPDX-FileCopyrightText: 2021 Hy Murveit <hy@murveit.com>
0003 
0004     SPDX-License-Identifier: GPL-2.0-or-later
0005 */
0006 
0007 #include <QFile>
0008 
0009 #include <chrono>
0010 #include <ctime>
0011 
0012 #include "test_ekos_scheduler_ops.h"
0013 #include "test_ekos_scheduler_helper.h"
0014 #include "ekos/scheduler/scheduler.h"
0015 #include "ekos/scheduler/schedulermodulestate.h"
0016 #include "ekos/scheduler/schedulerjob.h"
0017 #include "ekos/scheduler/greedyscheduler.h"
0018 #include "ekos/scheduler/schedulerprocess.h"
0019 
0020 #include "skymapcomposite.h"
0021 
0022 #if defined(HAVE_INDI)
0023 
0024 #include "artificialhorizoncomponent.h"
0025 #include "kstars_ui_tests.h"
0026 #include "test_ekos.h"
0027 #include "test_ekos_simulator.h"
0028 #include "linelist.h"
0029 #include "mockmodules.h"
0030 #include "Options.h"
0031 
0032 #define QWAIT_TIME 10
0033 
0034 #define DEFAULT_TOLERANCE 300
0035 #define DEFAULT_ITERATIONS 50
0036 
0037 using Ekos::Scheduler;
0038 
0039 // Use this class to temporarily modify the scheduler's update interval
0040 // by creating a WithInterval variable in a scope.
0041 // The constructor sets the scheduler's update interval to the number of ms
0042 // in the 1st arg, and restore it at the end of the scope. For example:
0043 // {
0044 //   WithInterval interval(10000, scheduler)
0045 //   // scheduler update interval is now 10000
0046 //   ...
0047 // }
0048 // // scheduler update interval is back to the original value.
0049 class WithInterval
0050 {
0051     public:
0052         WithInterval(int interval, QSharedPointer<Ekos::Scheduler> &_scheduler) : scheduler(_scheduler)
0053         {
0054             keepInterval = scheduler->moduleState()->updatePeriodMs();
0055             scheduler->moduleState()->setUpdatePeriodMs(interval);
0056         }
0057         ~WithInterval()
0058         {
0059             scheduler->moduleState()->setUpdatePeriodMs(keepInterval);
0060         }
0061     private:
0062         int keepInterval;
0063         QSharedPointer<Ekos::Scheduler> scheduler;
0064 };
0065 
0066 TestEkosSchedulerOps::TestEkosSchedulerOps(QObject *parent) : QObject(parent)
0067 {
0068 }
0069 
0070 void TestEkosSchedulerOps::initTestCase()
0071 {
0072 
0073     QDBusConnection::sessionBus().registerObject("/MockKStars", this);
0074     QDBusConnection::sessionBus().registerService("org.kde.mockkstars");
0075 
0076     // This gets executed at the start of testing
0077 
0078     disableSkyMap();
0079 }
0080 
0081 void TestEkosSchedulerOps::cleanupTestCase()
0082 {
0083     // This gets executed at the end of testing
0084 }
0085 
0086 void TestEkosSchedulerOps::init()
0087 {
0088     // This gets executed at the start of each of the individual tests.
0089     testTimer.start();
0090 
0091     focuser.reset(new Ekos::MockFocus);
0092     mount.reset(new Ekos::MockMount);
0093     capture.reset(new Ekos::MockCapture);
0094     align.reset(new Ekos::MockAlign);
0095     guider.reset(new Ekos::MockGuide);
0096     ekos.reset(new Ekos::MockEkos);
0097 
0098     scheduler.reset(new Scheduler("/MockKStars/MockEkos/Scheduler", "org.kde.mockkstars",
0099                                   Ekos::MockEkos::mockPath, "org.kde.mockkstars.MockEkos"));
0100     // These org.kde.* interface strings are set up in the various .xml files.
0101     scheduler->setFocusInterfaceString("org.kde.mockkstars.MockEkos.MockFocus");
0102     scheduler->setMountInterfaceString("org.kde.mockkstars.MockEkos.MockMount");
0103     scheduler->setCaptureInterfaceString("org.kde.mockkstars.MockEkos.MockCapture");
0104     scheduler->setAlignInterfaceString("org.kde.mockkstars.MockEkos.MockAlign");
0105     scheduler->setGuideInterfaceString("org.kde.mockkstars.MockEkos.MockGuide");
0106     scheduler->setFocusPathString(Ekos::MockFocus::mockPath);
0107     scheduler->setMountPathString(Ekos::MockMount::mockPath);
0108     scheduler->setCapturePathString(Ekos::MockCapture::mockPath);
0109     scheduler->setAlignPathString(Ekos::MockAlign::mockPath);
0110     scheduler->setGuidePathString(Ekos::MockGuide::mockPath);
0111 
0112     // Let's not deal with the dome for now.
0113     scheduler->schedulerUnparkDome->setChecked(false);
0114 
0115     // For now don't deal with files that were left around from previous testing.
0116     // Should put these is a temporary directory that will be removed, if we generate
0117     // them at all.
0118     Options::setRememberJobProgress(false);
0119 
0120     // This allows testing of the shutdown.
0121     Options::setStopEkosAfterShutdown(true);
0122     Options::setDitherEnabled(false);
0123 
0124     // define START_ASAP and FINISH_SEQUENCE as default startup/completion conditions.
0125     m_startupCondition.type = Ekos::START_ASAP;
0126     m_completionCondition.type = Ekos::FINISH_SEQUENCE;
0127 
0128     Options::setDawnOffset(0);
0129     Options::setDuskOffset(0);
0130     Options::setSettingAltitudeCutoff(0);
0131     Options::setSchedulerAlgorithm(Ekos::ALGORITHM_GREEDY);
0132     Options::setGreedyScheduling(true);
0133 }
0134 
0135 void TestEkosSchedulerOps::cleanup()
0136 {
0137     // This gets executed at the end of each of the individual tests.
0138 
0139     // The signal and/or dbus communications seems to get confused
0140     // without explicit resetting of these objects.
0141     focuser.reset();
0142     mount.reset();
0143     capture.reset();
0144     align.reset();
0145     guider.reset();
0146     ekos.reset();
0147     scheduler.reset();
0148     fprintf(stderr, "Test took %.1fs\n", testTimer.elapsed() / 1000.0);
0149 }
0150 
0151 void TestEkosSchedulerOps::disableSkyMap()
0152 {
0153     Options::setShowAsteroids(false);
0154     Options::setShowComets(false);
0155     Options::setShowSupernovae(false);
0156     Options::setShowDeepSky(false);
0157     Options::setShowEcliptic(false);
0158     Options::setShowEquator(false);
0159     Options::setShowLocalMeridian(false);
0160     Options::setShowGround(false);
0161     Options::setShowHorizon(false);
0162     Options::setShowFlags(false);
0163     Options::setShowOther(false);
0164     Options::setShowMilkyWay(false);
0165     Options::setShowSolarSystem(false);
0166     Options::setShowStars(false);
0167     Options::setShowSatellites(false);
0168     Options::setShowHIPS(false);
0169     Options::setShowTerrain(false);
0170 }
0171 
0172 // When checking that something happens near a certain time, the tolerance of the
0173 // check is affected by how often the scheduler iterates. E.g. if the scheduler only
0174 // runs once a minute (to speed up simulation), it is unreasonable to check for 1 second
0175 // tolerances. This function returns the max of the tolerance passed in and 3 times
0176 // the scheduler's iteration period to compensate for that.
0177 int TestEkosSchedulerOps::timeTolerance(int seconds)
0178 {
0179     const int tolerance = std::max(seconds, 3 * (scheduler->moduleState()->updatePeriodMs() / 1000));
0180     return tolerance;
0181 }
0182 
0183 // Thos tests an empty scheduler job and makes sure dbus communications
0184 // work between the scheduler and the mock modules.
0185 void TestEkosSchedulerOps::testBasics()
0186 {
0187     QVERIFY(scheduler->process()->focusInterface().isNull());
0188     QVERIFY(scheduler->process()->mountInterface().isNull());
0189     QVERIFY(scheduler->process()->captureInterface().isNull());
0190     QVERIFY(scheduler->process()->alignInterface().isNull());
0191     QVERIFY(scheduler->process()->guideInterface().isNull());
0192 
0193     ekos->addModule(QString("Focus"));
0194     ekos->addModule(QString("Mount"));
0195     ekos->addModule(QString("Capture"));
0196     ekos->addModule(QString("Align"));
0197     ekos->addModule(QString("Guide"));
0198 
0199     // Allow Qt to pass around the messages.
0200     // Wait time is set short (10ms) for longer tests where the scheduler is
0201     // iterating and can miss on one iteration. Here we make it longer
0202     // for a more stable test.
0203 
0204     // Not sure why processEvents() doesn't always work. Would be quicker that way.
0205     //qApp->processEvents();
0206     QTest::qWait(10 * QWAIT_TIME);
0207 
0208     QVERIFY(!scheduler->process()->focusInterface().isNull());
0209     QVERIFY(!scheduler->process()->mountInterface().isNull());
0210     QVERIFY(!scheduler->process()->captureInterface().isNull());
0211     QVERIFY(!scheduler->process()->alignInterface().isNull());
0212     QVERIFY(!scheduler->process()->guideInterface().isNull());
0213 
0214     // Verify the mocks can use the DBUS.
0215     QVERIFY(!focuser->isReset);
0216     scheduler->process()->focusInterface()->call(QDBus::AutoDetect, "resetFrame");
0217 
0218     //qApp->processEvents();  // this fails, is it because dbus calls are on a separate thread?
0219     QTest::qWait(10 * QWAIT_TIME);
0220 
0221     QVERIFY(focuser->isReset);
0222 
0223     // Run the scheduler with nothing setup. Should quickly exit.
0224     scheduler->moduleState()->init();
0225     QVERIFY(scheduler->moduleState()->timerState() == Ekos::RUN_WAKEUP);
0226     int sleepMs = scheduler->process()->runSchedulerIteration();
0227     QVERIFY(scheduler->moduleState()->timerState() == Ekos::RUN_SCHEDULER);
0228     sleepMs = scheduler->process()->runSchedulerIteration();
0229     QVERIFY(sleepMs == 1000);
0230     QVERIFY(scheduler->moduleState()->timerState() == Ekos::RUN_SHUTDOWN);
0231     sleepMs = scheduler->process()->runSchedulerIteration();
0232     QVERIFY(scheduler->moduleState()->timerState() == Ekos::RUN_NOTHING);
0233 }
0234 
0235 // Runs the scheduler for a number of iterations between 1 and the arg "iterations".
0236 // Each iteration it  increments the simulated clock (currentUTime, which is in Universal
0237 // Time) by *sleepMs, then runs the scheduler, then calls fcn().
0238 // If fcn() returns true, it stops iterating and returns true.
0239 // It returns false if it completes all the with fnc() returning false.
0240 bool TestEkosSchedulerOps::iterateScheduler(const QString &label, int iterations,
0241         int *sleepMs, KStarsDateTime* currentUTime, std::function<bool ()> fcn)
0242 {
0243     fprintf(stderr, "\n----------------------------------------\n");
0244     fprintf(stderr, "Starting iterateScheduler(%s)\n",  label.toLatin1().data());
0245 
0246     for (int i = 0; i < iterations; ++i)
0247     {
0248         //qApp->processEvents();
0249         QTest::qWait(QWAIT_TIME); // this takes ~10ms per iteration!
0250         // Is there a way to speed up the above?
0251         // I didn't reduce it, because the basic test fails to call a dbus function
0252         // with less than 10ms wait time.
0253 
0254         *currentUTime = currentUTime->addSecs(*sleepMs / 1000.0);
0255         KStarsData::Instance()->changeDateTime(*currentUTime); // <-- 175ms
0256         *sleepMs = scheduler->process()->runSchedulerIteration();
0257         fprintf(stderr, "current time LT %s UT %s\n",
0258                 KStarsData::Instance()->lt().toString().toLatin1().data(),
0259                 KStarsData::Instance()->ut().toString().toLatin1().data());
0260         if (fcn())
0261         {
0262             fprintf(stderr, "IterateScheduler %s returning TRUE at %s %s after %d iterations\n",
0263                     label.toLatin1().data(),
0264                     KStarsData::Instance()->lt().toString().toLatin1().data(),
0265                     KStarsData::Instance()->ut().toString().toLatin1().data(), i + 1);
0266             return true;
0267         }
0268     }
0269     fprintf(stderr, "IterateScheduler %s returning FALSE at %s %s after %d iterations\n",
0270             label.toLatin1().data(),
0271             KStarsData::Instance()->lt().toString().toLatin1().data(),
0272             KStarsData::Instance()->ut().toString().toLatin1().data(), iterations);
0273     return false;
0274 }
0275 
0276 // Sets up the scheduler in a particular location (geo) and a UTC start time.
0277 void TestEkosSchedulerOps::initTimeGeo(const GeoLocation &geo, const QDateTime &startUTime)
0278 {
0279     KStarsData::Instance()->geo()->setLat(*(geo.lat()));
0280     KStarsData::Instance()->geo()->setLong(*(geo.lng()));
0281     // Note, the actual TZ would be -7 as there is a DST correction for these dates.
0282     KStarsData::Instance()->geo()->setTZ0(geo.TZ0());
0283 
0284     KStarsDateTime currentUTime(startUTime);
0285     KStarsData::Instance()->changeDateTime(currentUTime);
0286     KStarsData::Instance()->clock()->setManualMode(true);
0287 }
0288 
0289 QString TestEkosSchedulerOps::writeFiles(const QString &label, QTemporaryDir &dir,
0290         const QVector<TestEkosSchedulerHelper::CaptureJob> &captureJob,
0291         const QString &schedulerXML)
0292 {
0293     // nanoseconds since epoch will be tacked on to the label to keep them unique.
0294     long int nn = std::chrono::duration_cast<std::chrono::nanoseconds>(
0295                       std::chrono::system_clock::now().time_since_epoch()).count();
0296 
0297     const QString eslFilename = dir.filePath(QString("%1-%2.esl").arg(label).arg(nn));
0298     const QString esqFilename = dir.filePath(QString("%1-%2.esq").arg(label).arg(nn));
0299     const QString eslContents = QString(schedulerXML);
0300     const QString esqContents = TestEkosSchedulerHelper::getEsqContent(captureJob);
0301 
0302     TestEkosSchedulerHelper::writeSimpleSequenceFiles(eslContents, eslFilename, esqContents, esqFilename);
0303     return eslFilename;
0304 }
0305 
0306 void TestEkosSchedulerOps::initFiles(QTemporaryDir *dir, const QVector<QString> &esls, const QVector<QString> &esqs)
0307 {
0308     QVERIFY(dir->isValid());
0309     QVERIFY(dir->autoRemove());
0310 
0311     for (int i = 0; i < esls.size(); ++i)
0312     {
0313         const QString eslFile = dir->filePath(QString("test%1.esl").arg(i));
0314         const QString esqFile = dir->filePath(QString("test%1.esq").arg(i));
0315 
0316         QVERIFY(TestEkosSchedulerHelper::writeSimpleSequenceFiles(esls[i], eslFile, esqs[i], esqFile));
0317         scheduler->load(i == 0, QString("file://%1").arg(eslFile));
0318         QVERIFY(scheduler->moduleState()->jobs().size() == (i + 1));
0319         scheduler->moduleState()->jobs()[i]->setSequenceFile(QUrl(QString("file://%1").arg(esqFile)));
0320         fprintf(stderr, "seq file: %s \"%s\"\n", esqFile.toLatin1().data(), QString("file://%1").arg(esqFile).toLatin1().data());
0321     }
0322 }
0323 
0324 // Sets up the scheduler in a particular location (geo) and a UTC start time.
0325 void TestEkosSchedulerOps::initScheduler(const GeoLocation &geo, const QDateTime &startUTime, QTemporaryDir *dir,
0326         const QVector<QString> &esls, const QVector<QString> &esqs)
0327 {
0328     initTimeGeo(geo, startUTime);
0329     initFiles(dir, esls, esqs);
0330     scheduler->process()->evaluateJobs(false);
0331     scheduler->moduleState()->init();
0332     QVERIFY(scheduler->moduleState()->timerState() == Ekos::RUN_WAKEUP);
0333 }
0334 
0335 void TestEkosSchedulerOps::startupJob(
0336     const GeoLocation &geo, const QDateTime &startUTime,
0337     QTemporaryDir *dir, const QString &esl, const QString &esq,
0338     const QDateTime &wakeupTime, KStarsDateTime &endUTime, int &endSleepMs)
0339 {
0340     QVector<QString> esls, esqs;
0341     esls.push_back(esl);
0342     esqs.push_back(esq);
0343     startupJobs(geo, startUTime, dir, esls, esqs, wakeupTime, endUTime, endSleepMs);
0344 }
0345 
0346 void TestEkosSchedulerOps::startupJobs(
0347     const GeoLocation &geo, const QDateTime &startUTime,
0348     QTemporaryDir *dir, const QVector<QString> &esls, const QVector<QString> &esqs,
0349     const QDateTime &wakeupTime, KStarsDateTime &endUTime, int &endSleepMs)
0350 {
0351     initScheduler(geo, startUTime, dir, esls, esqs);
0352     {
0353         // Have the scheduler update quickly when running these init routines.
0354         WithInterval interval(1000, scheduler);
0355 
0356         KStarsDateTime currentUTime(startUTime);
0357         int sleepMs = 0;
0358         QVERIFY(scheduler->moduleState()->timerState() == Ekos::RUN_WAKEUP);
0359         QVERIFY(iterateScheduler("Wait for RUN_SCHEDULER", 2, &sleepMs, &currentUTime, [&]() -> bool
0360         {
0361             return (scheduler->moduleState()->timerState() == Ekos::RUN_SCHEDULER);
0362         }));
0363 
0364         if (wakeupTime.isValid())
0365         {
0366             // This is the sequence when it goes to sleep, then wakes up later to start.
0367 
0368             QVERIFY(iterateScheduler("Wait for RUN_WAKEUP", 10, &sleepMs, &currentUTime, [&]() -> bool
0369             {
0370                 return (scheduler->moduleState()->timerState() == Ekos::RUN_WAKEUP);
0371             }));
0372 
0373             // Verify that it's near the original start time.
0374             const qint64 delta_t = KStarsData::Instance()->ut().secsTo(startUTime);
0375             QVERIFY2(std::abs(delta_t) < timeTolerance(300),
0376                      QString("Delta to original time %1 too large, failing.").arg(delta_t).toLatin1());
0377 
0378             QVERIFY(iterateScheduler("Wait for RUN_SCHEDULER", 2, &sleepMs, &currentUTime, [&]() -> bool
0379             {
0380                 return (scheduler->moduleState()->timerState() == Ekos::RUN_SCHEDULER);
0381             }));
0382 
0383             // Verify that it wakes up at the right time, after the twilight constraint
0384             // and the stars rises above 30 degrees. See the time comment above.
0385             QVERIFY(std::abs(KStarsData::Instance()->ut().secsTo(wakeupTime)) < timeTolerance(DEFAULT_TOLERANCE));
0386         }
0387         else
0388         {
0389             // check if there is a job scheduled
0390             bool scheduled_job = false;
0391             foreach (Ekos::SchedulerJob *sched_job, scheduler->moduleState()->jobs())
0392                 if (sched_job->state == Ekos::SCHEDJOB_SCHEDULED)
0393                     scheduled_job = true;
0394             if (scheduled_job)
0395             {
0396                 // This is the sequence when it can start-up right away.
0397 
0398                 // Verify that it's near the original start time.
0399                 const qint64 delta_t = KStarsData::Instance()->ut().secsTo(startUTime);
0400                 QVERIFY2(std::abs(delta_t) < timeTolerance(DEFAULT_TOLERANCE),
0401                          QString("Delta to original time %1 too large, failing.").arg(delta_t).toLatin1());
0402 
0403                 QVERIFY(iterateScheduler("Wait for RUN_SCHEDULER", 2, &sleepMs, &currentUTime, [&]() -> bool
0404                 {
0405                     return (scheduler->moduleState()->timerState() == Ekos::RUN_SCHEDULER);
0406                 }));
0407             }
0408             else
0409                 // if there is no job scheduled, we're done
0410             {
0411                 return;
0412             }
0413         }
0414         // When the scheduler starts up, it sends connectDevices to Ekos
0415         // which sets Indi --> Ekos::Success,
0416         // and then it sends start() to Ekos which sets Ekos --> Ekos::Success
0417         bool sentOnce = false, readyOnce = false;
0418         QVERIFY(iterateScheduler("Wait for Indi and Ekos", 30, &sleepMs, &currentUTime, [&]() -> bool
0419         {
0420             if ((scheduler->moduleState()->indiState() == Ekos::INDI_READY) &&
0421                     (scheduler->moduleState()->ekosState() == Ekos::EKOS_READY))
0422             {
0423                 return true;
0424             }
0425             //else if (scheduler->m_EkosCommunicationStatus == Ekos::Success)
0426             else if (ekos->ekosStatus() == Ekos::Success)
0427             {
0428                 // Once Ekos is woken up, say mount and capture are ready.
0429                 if (!sentOnce)
0430                 {
0431                     // Add the modules once ekos is started up.
0432                     sentOnce = true;
0433                     ekos->addModule("Focus");
0434                     ekos->addModule("Capture");
0435                     ekos->addModule("Mount");
0436                     ekos->addModule("Align");
0437                     ekos->addModule("Guide");
0438                 }
0439                 else if (scheduler->process()->mountInterface() != nullptr &&
0440                          scheduler->process()->captureInterface() != nullptr && !readyOnce)
0441                 {
0442                     // Can't send the ready messages until the devices are registered.
0443                     readyOnce = true;
0444                     mount->sendReady();
0445                     capture->sendReady();
0446                 }
0447             }
0448             return false;
0449         }));
0450 
0451         endUTime = currentUTime;
0452         endSleepMs = sleepMs;
0453     }
0454 }
0455 
0456 void TestEkosSchedulerOps::startModules(KStarsDateTime &currentUTime, int &sleepMs)
0457 {
0458     WithInterval interval(1000, scheduler);
0459     QVERIFY(iterateScheduler("Wait for MountTracking", 30, &sleepMs, &currentUTime, [&]() -> bool
0460     {
0461         if (mount->status() == ISD::Mount::MOUNT_SLEWING)
0462             mount->setStatus(ISD::Mount::MOUNT_TRACKING);
0463         else if (mount->status() == ISD::Mount::MOUNT_TRACKING)
0464             return true;
0465         return false;
0466     }));
0467 
0468     QVERIFY(iterateScheduler("Wait for Focus", 30, &sleepMs, &currentUTime, [&]() -> bool
0469     {
0470         if (focuser->status() == Ekos::FOCUS_PROGRESS)
0471             focuser->setStatus(Ekos::FOCUS_COMPLETE);
0472         else if (focuser->status() == Ekos::FOCUS_COMPLETE)
0473             return true;
0474         return false;
0475     }));
0476 
0477     QVERIFY(iterateScheduler("Wait for Align", 30, &sleepMs, &currentUTime, [&]() -> bool
0478     {
0479         if (align->status() == Ekos::ALIGN_PROGRESS)
0480             align->setStatus(Ekos::ALIGN_COMPLETE);
0481         else if (align->status() == Ekos::ALIGN_COMPLETE)
0482             return true;
0483         return false;
0484     }));
0485 
0486     QVERIFY(iterateScheduler("Wait for Guide", 30, &sleepMs, &currentUTime, [&]() -> bool
0487     {
0488         return (guider->status() == Ekos::GUIDE_GUIDING);
0489     }));
0490     QVERIFY(guider->connected);
0491 }
0492 
0493 // Roughly compare the slew coordinates sent to the mount to Deneb's.
0494 // Rough comparison because these will have been converted to JNow.
0495 // Should be called after simulated slew has been completed.
0496 bool TestEkosSchedulerOps::checkLastSlew(const SkyObject* targetObject)
0497 {
0498     constexpr double halfDegreeInHours = 1 / (15 * 2.0);
0499     bool success = (fabs(mount->lastRaHoursSlew - targetObject->ra().Hours()) < halfDegreeInHours) &&
0500                    (fabs(mount->lastDecDegreesSlew - targetObject->dec().Degrees()) < 0.5);
0501     if (!success)
0502         fprintf(stderr, "Expected slew RA: %f DEC: %F but got %f %f\n",
0503                 targetObject->ra().Hours(), targetObject->dec().Degrees(),
0504                 mount->lastRaHoursSlew, mount->lastDecDegreesSlew);
0505     return success;
0506 }
0507 
0508 // Utility to print the state of the current scheduler job list.
0509 void TestEkosSchedulerOps::printJobs(const QString &label)
0510 {
0511     fprintf(stderr, "%-30s: ", label.toLatin1().data());
0512     for (int i = 0; i < scheduler->moduleState()->jobs().size(); ++i)
0513     {
0514         fprintf(stderr, "(%d) %s %-15s ", i, scheduler->moduleState()->jobs()[i]->getName().toLatin1().data(),
0515                 Ekos::SchedulerJob::jobStatusString(scheduler->moduleState()->jobs()[i]->getState()).toLatin1().data());
0516     }
0517     fprintf(stderr, "\n");
0518 }
0519 
0520 void TestEkosSchedulerOps::initJob(const KStarsDateTime &startUTime, const KStarsDateTime &jobStartUTime)
0521 {
0522     KStarsDateTime currentUTime(startUTime);
0523     int sleepMs = 0;
0524 
0525     // wait for the scheduler select the configured job for execution
0526     QVERIFY(iterateScheduler("Wait for RUN_SCHEDULER", 2, &sleepMs, &currentUTime, [&]() -> bool
0527     {
0528         return (scheduler->moduleState()->timerState() == Ekos::RUN_SCHEDULER);
0529     }));
0530 
0531     // wait until the scheduler turns to the wakeup mode sleeping until the startup condition is met
0532     QVERIFY(iterateScheduler("Wait for RUN_WAKEUP", 10, &sleepMs, &currentUTime, [&]() -> bool
0533     {
0534         return (scheduler->moduleState()->timerState() == Ekos::RUN_WAKEUP);
0535     }));
0536 
0537     // wait until the scheduler starts the job
0538     QVERIFY(iterateScheduler("Wait for RUN_SCHEDULER", 2, &sleepMs, &currentUTime, [&]() -> bool
0539     {
0540         return (scheduler->moduleState()->timerState() == Ekos::RUN_SCHEDULER);
0541     }));
0542 
0543     // check the distance from the expected start time
0544     qint64 delta = KStars::Instance()->data()->ut().secsTo(jobStartUTime);
0545     // real offset should be maximally 5 min off the configured offset
0546     QVERIFY2(std::abs(delta) < 300,
0547              QString("wrong startup time: %1 secs distance to planned %2.").arg(delta).arg(jobStartUTime.toString(
0548                          Qt::ISODate)).toLocal8Bit());
0549 }
0550 
0551 // This tests a simple scheduler job.
0552 // The job initializes Ekos and Indi, slews, plate-solves, focuses, starts guiding, and
0553 // captures. Capture completes and the scheduler shuts down.
0554 void TestEkosSchedulerOps::runSimpleJob(const GeoLocation &geo, const SkyObject *targetObject, const QDateTime &startUTime,
0555                                         const QDateTime &wakeupTime, bool enforceArtificialHorizon)
0556 {
0557     KStarsDateTime currentUTime;
0558     int sleepMs = 0;
0559 
0560     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
0561 
0562     startupJob(geo, startUTime, &dir, TestEkosSchedulerHelper::getSchedulerFile(targetObject, m_startupCondition,
0563                m_completionCondition, {true, true, true, true},
0564                false, enforceArtificialHorizon),
0565                TestEkosSchedulerHelper::getDefaultEsqContent(), wakeupTime, currentUTime, sleepMs);
0566     startModules(currentUTime, sleepMs);
0567     QVERIFY(checkLastSlew(targetObject));
0568 
0569     QVERIFY(iterateScheduler("Wait for Capturing", DEFAULT_ITERATIONS, &sleepMs, &currentUTime, [&]() -> bool
0570     {
0571         return (scheduler->activeJob() != nullptr &&
0572                 scheduler->activeJob()->getStage() == Ekos::SCHEDSTAGE_CAPTURING);
0573     }));
0574 
0575     {
0576         WithInterval interval(1000, scheduler);
0577 
0578         // Tell the scheduler that capture is done.
0579         capture->setStatus(Ekos::CAPTURE_COMPLETE);
0580 
0581         QVERIFY(iterateScheduler("Wait for Abort Guider", DEFAULT_ITERATIONS, &sleepMs, &currentUTime, [&]() -> bool
0582         {
0583             return (guider->status() == Ekos::GUIDE_ABORTED);
0584         }));
0585         QVERIFY(iterateScheduler("Wait for Shutdown", DEFAULT_ITERATIONS, &sleepMs, &currentUTime, [&]() -> bool
0586         {
0587             return (scheduler->moduleState()->shutdownState() == Ekos::SHUTDOWN_COMPLETE);
0588         }));
0589 
0590         // Here the scheduler sends a message to ekosInterface to disconnectDevices,
0591         // which will cause indi --> IDLE,
0592         // and then calls stop() which will cause ekos --> IDLE
0593         // This will cause the scheduler to shutdown.
0594         QVERIFY(iterateScheduler("Wait for Scheduler Complete", DEFAULT_ITERATIONS, &sleepMs, &currentUTime, [&]() -> bool
0595         {
0596             return (scheduler->moduleState()->timerState() == Ekos::RUN_NOTHING);
0597         }));
0598     }
0599 }
0600 
0601 void TestEkosSchedulerOps::testSimpleJob()
0602 {
0603     GeoLocation geo(dms(-122, 10), dms(37, 26, 30), "Silicon Valley", "CA", "USA", -8);
0604     SkyObject *targetObject = KStars::Instance()->data()->skyComposite()->findByName("Altair");
0605 
0606     // Setup an initial time.
0607     // Note that the start time is 3pm local (10pm UTC - 7 TZ).
0608     // Altair, the target, should be at about -40 deg altitude at this time,.
0609     // The dawn/dusk constraints are 4:03am and 10:12pm (lst=13:43)
0610     // At 10:12pm it should have an altitude of about 14 degrees, still below the 30-degree constraint.
0611     // It achieves 30-degrees altitude at about 23:35.
0612     QDateTime startUTime(QDateTime(QDate(2021, 6, 13), QTime(22, 0, 0), Qt::UTC));
0613     const QDateTime wakeupTime(QDate(2021, 6, 14), QTime(06, 35, 0), Qt::UTC);
0614     runSimpleJob(geo, targetObject, startUTime, wakeupTime, true);
0615     QVERIFY(checkLastSlew(targetObject));
0616 }
0617 
0618 // This test has the same start as testSimpleJob, except that it but runs in NYC
0619 // instead of silicon valley. This makes sure testing doesn't depend on timezone.
0620 void TestEkosSchedulerOps::testTimeZone()
0621 {
0622     WithInterval interval(5000, scheduler);
0623     GeoLocation geo(dms(-74, 0), dms(40, 42, 0), "NYC", "NY", "USA", -5);
0624     SkyObject *targetObject = KStars::Instance()->data()->skyComposite()->findByName("Altair");
0625     KStarsDateTime startUTime(QDateTime(QDate(2021, 6, 13), QTime(22, 0, 0), Qt::UTC));
0626 
0627     // It crosses 30-degrees altitude around the same time locally, but that's
0628     // 3 hours earlier UTC.
0629     const QDateTime wakeupTime(QDate(2021, 6, 14), QTime(03, 26, 0), Qt::UTC);
0630 
0631     runSimpleJob(geo, targetObject, startUTime, wakeupTime, true);
0632     QVERIFY(checkLastSlew(targetObject));
0633 }
0634 
0635 void TestEkosSchedulerOps::testDawnShutdown()
0636 {
0637     // remove the various options that play with the dawn/dusk times
0638     Options::setDawnOffset(0);
0639     Options::setDuskOffset(0);
0640     Options::setSettingAltitudeCutoff(0);
0641     Options::setPreDawnTime(0);
0642 
0643     // This test will iterate the scheduler every 40 simulated seconds (to save testing time).
0644     WithInterval interval(40000, scheduler);
0645 
0646     // At this geo/date, Dawn is calculated = .1625 of a day = 3:53am local = 10:52 UTC
0647     // If we started at 23:35 local time, as before, it's a little over 4 hours
0648     // or over 4*3600 iterations. Too many? Instead we start at 3am local.
0649 
0650     // According to https://www.timeanddate.com/sun/usa/san-francisco?month=6&year=2021
0651     // astronomical dawn for SF was 3:52am on 6/14/2021
0652 
0653     GeoLocation geo(dms(-122, 10), dms(37, 26, 30), "Silicon Valley", "CA", "USA", -8);
0654     QVector<SkyObject*> targetObjects;
0655     targetObjects.push_back(KStars::Instance()->data()->skyComposite()->findByName("Altair"));
0656 
0657     // We'll start the scheduler at 3am local time.
0658     QDateTime startUTime(QDateTime(QDate(2021, 6, 14), QTime(10, 10, 0), Qt::UTC));
0659     // The job should start at 3:12am local time.
0660     QDateTime startJobUTime = startUTime.addSecs(180);
0661     // The job should be interrupted at the pre-dawn time, which is about 3:53am
0662     QDateTime preDawnUTime(QDateTime(QDate(2021, 6, 14), QTime(10, 53, 0), Qt::UTC));
0663     // Consider pre-dawn security range
0664     preDawnUTime = preDawnUTime.addSecs(-60.0 * abs(Options::preDawnTime()));
0665 
0666     KStarsDateTime currentUTime;
0667     int sleepMs = 0;
0668     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
0669 
0670     startup(geo, targetObjects, startUTime, currentUTime, sleepMs, dir);
0671     slewAndRun(targetObjects[0], startJobUTime, preDawnUTime, currentUTime, sleepMs, DEFAULT_TOLERANCE);
0672     parkAndSleep(currentUTime, sleepMs);
0673 
0674     const QDateTime restartTime(QDate(2021, 6, 15), QTime(06, 31, 0), Qt::UTC);
0675     wakeupAndRestart(restartTime, currentUTime, sleepMs);
0676 }
0677 
0678 // Expect the job to start running at startJobUTime.
0679 // Check that the correct slew was made
0680 // Expect the job to be interrupted at interruptUTime (if the time is valid)
0681 void TestEkosSchedulerOps::slewAndRun(SkyObject *object, const QDateTime &startUTime, const QDateTime &interruptUTime,
0682                                       KStarsDateTime &currentUTime, int &sleepMs, int tolerance, const QString &label)
0683 {
0684     QVERIFY(iterateScheduler("Wait for Job Startup", DEFAULT_ITERATIONS, &sleepMs, &currentUTime, [&]() -> bool
0685     {
0686         return (scheduler->moduleState()->timerState() == Ekos::RUN_JOBCHECK);
0687     }));
0688 
0689     double delta = KStarsData::Instance()->ut().secsTo(startUTime);
0690     QVERIFY2(std::abs(delta) < timeTolerance(tolerance),
0691              QString("Unexpected difference to job statup time: %1 secs (%2 vs %3) %4")
0692              .arg(delta).arg(KStarsData::Instance()->ut().toString("MM/dd hh:mm"))
0693              .arg(startUTime.toString("MM/dd hh:mm")).arg(label).toLocal8Bit());
0694 
0695     // We should be unparked at this point.
0696     QVERIFY(mount->parkStatus() == ISD::PARK_UNPARKED);
0697 
0698     QVERIFY(iterateScheduler("Wait for MountTracking", DEFAULT_ITERATIONS, &sleepMs, &currentUTime, [&]() -> bool
0699     {
0700         if (mount->status() == ISD::Mount::MOUNT_SLEWING)
0701             mount->setStatus(ISD::Mount::MOUNT_TRACKING);
0702         else if (mount->status() == ISD::Mount::MOUNT_TRACKING)
0703             return true;
0704         return false;
0705     }));
0706 
0707     QVERIFY(checkLastSlew(object));
0708 
0709     if (interruptUTime.isValid())
0710     {
0711         // Wait until the job stops processing,
0712         // When scheduler state JOBCHECK changes to RUN_SCHEDULER.
0713         QVERIFY(iterateScheduler("Wait for Job Interruption", 1000, &sleepMs, &currentUTime, [&]() -> bool
0714         {
0715             return (scheduler->moduleState()->timerState() == Ekos::RUN_SCHEDULER);
0716         }));
0717 
0718         delta = KStarsData::Instance()->ut().secsTo(interruptUTime);
0719         QVERIFY2(std::abs(delta) < timeTolerance(tolerance),
0720                  QString("Unexpected difference to interrupt time: %1 secs (%2 vs %3) %4")
0721                  .arg(delta).arg(KStarsData::Instance()->ut().toString("MM/dd hh:mm"))
0722                  .arg(interruptUTime.toString("MM/dd hh:mm")).arg(label).toLocal8Bit());
0723 
0724         // It should start to shutdown now.
0725         QVERIFY(iterateScheduler("Wait for Guide Abort", DEFAULT_ITERATIONS, &sleepMs, &currentUTime, [&]() -> bool
0726         {
0727             return (guider->status() == Ekos::GUIDE_ABORTED);
0728         }));
0729     }
0730 }
0731 
0732 // Set up the target objects to run in the scheduler (in the order they're given)
0733 // Scheduler running at location geo.
0734 // Start the scheduler at startSchedulerUTime.
0735 // currentUTime and sleepMs can be set up as: KStarsDateTime currentUTime; int sleepMs = 0; and
0736 // their latest values are returned, if you want to continue the simulation after this call.
0737 // Similarly, dir is passed in so the temporary directory continues to exist for continued simulation.
0738 void TestEkosSchedulerOps::startup(const GeoLocation &geo, const QVector<SkyObject*> targetObjects,
0739                                    const QDateTime &startSchedulerUTime, KStarsDateTime &currentUTime, int &sleepMs, QTemporaryDir &dir)
0740 {
0741     const QDateTime wakeupTime;  // Not valid--it starts up right away.
0742     QVector<QString> esls, esqs;
0743     auto schedJob200x60 = QVector<TestEkosSchedulerHelper::CaptureJob>(1, {200, 60, "Red", "."});
0744     auto esqContent = TestEkosSchedulerHelper::getEsqContent(schedJob200x60);
0745     for (int i = 0; i < targetObjects.size(); ++i)
0746     {
0747         esls.push_back(TestEkosSchedulerHelper::getSchedulerFile(targetObjects[i], m_startupCondition, m_completionCondition, {true, true, true, true},
0748                        true, true));
0749         esqs.push_back(esqContent);
0750     }
0751     startupJobs(geo, startSchedulerUTime, &dir, esls, esqs, wakeupTime, currentUTime, sleepMs);
0752     startModules(currentUTime, sleepMs);
0753 }
0754 
0755 void TestEkosSchedulerOps::parkAndSleep(KStarsDateTime &currentUTime, int &sleepMs)
0756 {
0757     QVERIFY(iterateScheduler("Wait for Parked", DEFAULT_ITERATIONS, &sleepMs, &currentUTime, [&]() -> bool
0758     {
0759         return (mount->parkStatus() == ISD::PARK_PARKED);
0760     }));
0761 
0762     QVERIFY(iterateScheduler("Wait for Sleep State", DEFAULT_ITERATIONS, &sleepMs, &currentUTime, [&]() -> bool
0763     {
0764         return (scheduler->moduleState()->timerState() == Ekos::RUN_WAKEUP);
0765     }));
0766 }
0767 
0768 void TestEkosSchedulerOps::wakeupAndRestart(const QDateTime &restartTime, KStarsDateTime &currentUTime, int &sleepMs)
0769 {
0770     // Make sure it wakes up at the proper time.
0771     QVERIFY(iterateScheduler("Wait for Wakeup Tomorrow", DEFAULT_ITERATIONS, &sleepMs, &currentUTime, [&]() -> bool
0772     {
0773         return (scheduler->moduleState()->timerState() == Ekos::RUN_SCHEDULER);
0774     }));
0775 
0776     fprintf(stderr, "Times instance %s vs reference %s, diff %lld\n",
0777             KStarsData::Instance()->ut().toString().toLatin1().data(),
0778             restartTime.toString().toLatin1().data(),
0779             KStarsData::Instance()->ut().secsTo(restartTime));
0780     QVERIFY(std::abs(KStarsData::Instance()->ut().secsTo(restartTime)) < timeTolerance(DEFAULT_TOLERANCE));
0781 
0782     {
0783         WithInterval interval(1000, scheduler);
0784         // Verify the job starts up again, and the mount is once-again unparked.
0785         bool readyOnce = false;
0786         QVERIFY(iterateScheduler("Wait for Job Startup & Unparked", 50, &sleepMs, &currentUTime, [&]() -> bool
0787         {
0788             if (scheduler->process()->mountInterface() != nullptr &&
0789                     scheduler->process()->captureInterface() != nullptr && !readyOnce)
0790             {
0791                 // Send a ready signal since the scheduler expects it.
0792                 readyOnce = true;
0793                 mount->sendReady();
0794                 capture->sendReady();
0795             }
0796             return (scheduler->moduleState()->timerState() == Ekos::RUN_JOBCHECK &&
0797                     mount->parkStatus() == ISD::PARK_UNPARKED);
0798         }));
0799     }
0800 }
0801 
0802 void TestEkosSchedulerOps::testTwilightStartup_data()
0803 {
0804     QTest::addColumn<QString>("city");
0805     QTest::addColumn<QString>("state");
0806     QTest::addColumn<QString>("target");
0807     QTest::addColumn<QString>("startTimeUTC");
0808     QTest::addColumn<QString>("jobStartTimeUTC");
0809 
0810     QTest::newRow("SF")
0811             << "San Francisco" << "California" << "Rasalhague"
0812             << "Sun Jun 13 20:00:00 2021 GMT" <<  "Mon Jun 14 05:28:00 2021 GMT";
0813 
0814     QTest::newRow("Melbourne")
0815             << "Melbourne" << "Victoria" << "Arcturus"
0816             << "Sun Jun 13 02:00:00 2021 GMT" <<  "Mon Jun 13 08:42:00 2021 GMT";
0817 }
0818 
0819 void TestEkosSchedulerOps::testTwilightStartup()
0820 {
0821     QFETCH(QString, city);
0822     QFETCH(QString, state);
0823     QFETCH(QString, target);
0824     QFETCH(QString, startTimeUTC);
0825     QFETCH(QString, jobStartTimeUTC);
0826 
0827     SkyObject *targetObject = KStars::Instance()->data()->skyComposite()->findByName(target);
0828     GeoLocation * const geoPtr = KStars::Instance()->data()->locationNamed(city, state, "");
0829     GeoLocation &geo = *geoPtr;
0830 
0831     const KStarsDateTime startUTime(QDateTime::fromString(startTimeUTC));
0832     const KStarsDateTime jobStartUTime(QDateTime::fromString(jobStartTimeUTC));
0833 
0834     // move forward in 20s steps
0835     WithInterval interval(20000, scheduler);
0836     // define culmination offset of 1h as startup condition
0837     m_startupCondition.type = Ekos::START_ASAP;
0838     // initialize the the scheduler
0839     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
0840     QVector<QString> esqVector;
0841     esqVector.push_back(TestEkosSchedulerHelper::getDefaultEsqContent());
0842     QVector<QString> eslVector;
0843     // 3rd arg is the true for twilight enforced. 0 is minAltitude.
0844     eslVector.push_back(TestEkosSchedulerHelper::getSchedulerFile(targetObject, m_startupCondition, m_completionCondition, {true, true, true, true},
0845                         true, false, 0));
0846     initScheduler(geo, startUTime, &dir, eslVector, esqVector);
0847     initJob(startUTime, jobStartUTime);
0848 }
0849 
0850 void addHorizonConstraint(ArtificialHorizon *horizon, const QString &name, bool enabled,
0851                           const QVector<double> &azimuths, const QVector<double> &altitudes, bool ceiling = false)
0852 {
0853     std::shared_ptr<LineList> pointList(new LineList);
0854     for (int i = 0; i < azimuths.size(); ++i)
0855     {
0856         std::shared_ptr<SkyPoint> skyp1(new SkyPoint);
0857         skyp1->setAlt(altitudes[i]);
0858         skyp1->setAz(azimuths[i]);
0859         pointList->append(skyp1);
0860     }
0861     horizon->addRegion(name, enabled, pointList, ceiling);
0862 }
0863 
0864 void TestEkosSchedulerOps::testArtificialHorizonConstraints()
0865 {
0866     // In testSimpleJob, above, the wakeup time for the job was 11:35pm local time, and it used a 30-degrees min altitude.
0867     // Now let's add an artificial horizon constraint for 40-degrees at the azimuths where the object will be.
0868     // It should now wakeup and start processing at about 00:27am
0869 
0870     ArtificialHorizon horizon;
0871     addHorizonConstraint(&horizon, "r1", true, QVector<double>({100, 120}), QVector<double>({40, 40}));
0872     Ekos::SchedulerJob::setHorizon(&horizon);
0873 
0874     GeoLocation geo(dms(-122, 10), dms(37, 26, 30), "Silicon Valley", "CA", "USA", -8);
0875     QVector<SkyObject*> targetObjects;
0876     targetObjects.push_back(KStars::Instance()->data()->skyComposite()->findByName("Altair"));
0877     QDateTime startUTime(QDateTime(QDate(2021, 6, 13), QTime(22, 0, 0), Qt::UTC));
0878 
0879     const QDateTime wakeupTime(QDate(2021, 6, 14), QTime(07, 27, 0), Qt::UTC);
0880     runSimpleJob(geo, targetObjects[0], startUTime, wakeupTime, true);
0881 
0882     // Uncheck enforce artificial horizon and the wakeup time should go back to it's original time,
0883     // even though the artificial horizon is still there and enabled.
0884     init(); // Reset the scheduler.
0885     const QDateTime originalWakeupTime(QDate(2021, 6, 14), QTime(06, 35, 0), Qt::UTC);
0886     runSimpleJob(geo, targetObjects[0], startUTime, originalWakeupTime, /* enforce artificial horizon */false);
0887 
0888     // Re-check enforce artificial horizon, but remove the constraint, and the wakeup time also goes back to it's original time.
0889     init(); // Reset the scheduler.
0890     ArtificialHorizon emptyHorizon;
0891     Ekos::SchedulerJob::setHorizon(&emptyHorizon);
0892     runSimpleJob(geo, targetObjects[0], startUTime, originalWakeupTime, /* enforce artificial horizon */ true);
0893 
0894     // Testing that the artificial horizon constraint will end a job
0895     // when the altitude of the running job is below the artificial horizon at the
0896     // target's azimuth.
0897     //
0898     // This repeats testDawnShutdown() above, except that an artifical horizon
0899     // constraint is added so that the job doesn't reach dawn but rather is interrupted
0900     // at 3:19 local time. That's the time the azimuth reaches 175.
0901 
0902     init(); // Reset the scheduler.
0903     {
0904         WithInterval interval(40000, scheduler);
0905         ArtificialHorizon shutdownHorizon;
0906         // Note, just putting a constraint at 175->180 will fail this test because Altair will
0907         // cross past 180 and the scheduler will want to restart it before dawn.
0908         addHorizonConstraint(&shutdownHorizon, "h", true,
0909                              QVector<double>({175, 200}), QVector<double>({70, 70}));
0910         Ekos::SchedulerJob::setHorizon(&shutdownHorizon);
0911 
0912         // We'll start the scheduler at 3am local time.
0913         startUTime = QDateTime(QDate(2021, 6, 14), QTime(10, 0, 0), Qt::UTC);
0914         // The job should start at 3:12am local time.
0915         QDateTime startJobUTime = startUTime.addSecs(120);
0916         // The job should be interrupted by the horizon limit, which is reached about 3:19am local.
0917         QDateTime horizonStopUTime(QDateTime(QDate(2021, 6, 14), QTime(10, 19, 0), Qt::UTC));
0918 
0919         KStarsDateTime currentUTime;
0920         int sleepMs = 0;
0921         QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
0922 
0923         startup(geo, targetObjects, startUTime, currentUTime, sleepMs, dir);
0924         slewAndRun(targetObjects[0], startJobUTime, horizonStopUTime, currentUTime, sleepMs, DEFAULT_TOLERANCE,
0925                    "Horizon slewAndRun");
0926         parkAndSleep(currentUTime, sleepMs);
0927         const QDateTime restartTime(QDate(2021, 6, 15), QTime(06, 31, 0), Qt::UTC);
0928         wakeupAndRestart(restartTime, currentUTime, sleepMs);
0929     }
0930 }
0931 
0932 // Similar to the above testArtificialHorizonConstraints test,
0933 // Schedule Altair and give it an artificial horizon constraint that will stop it at 3:19am.
0934 // However, here we also have a second job, Deneb, and test to see that the 2nd job will
0935 // start up after Altair stops and run until dawn.
0936 
0937 // Test of running the full scheduler with the greedy algorithm.
0938 // This is the schedule that Greedy predicts
0939 // Deneb      starts 06/13 22:48 done:   2760/12225 s stops 23:34  2760s (interrupted) (Tcomp 02:11 Tint 23:34 Tconstraint 03:53)
0940 // Altair     starts 06/13 23:34 done:  12225/12225 s stops 02:57 12225s (completion) (Tcomp 02:57 Tint  Tconstraint 03:20)
0941 // Deneb      starts 06/14 02:57 done:   6059/12225 s stops 03:52  3299s (constraint) (Tcomp 05:35 Tint  Tconstraint 03:52)
0942 // Deneb      starts 06/14 22:43 done:  12225/12225 s stops 00:26  6166s (completion) (Tcomp 00:26 Tint  Tconstraint 03:52)
0943 // however the code below doesn't simulate completion, so instead the Altair should end at its 3:20 constraint time
0944 
0945 void TestEkosSchedulerOps::testGreedySchedulerRun()
0946 {
0947     Options::setSchedulerAlgorithm(Ekos::ALGORITHM_GREEDY);
0948     // This test will iterate the scheduler every 40 simulated seconds (to save testing time).
0949     WithInterval interval(40000, scheduler);
0950 
0951     GeoLocation geo(dms(-122, 10), dms(37, 26, 30), "Silicon Valley", "CA", "USA", -8);
0952     QVector<SkyObject*> targetObjects;
0953     constexpr int altairIndex = 0, denebIndex = 1;
0954     targetObjects.push_back(KStars::Instance()->data()->skyComposite()->findByName("Altair"));
0955     targetObjects.push_back(KStars::Instance()->data()->skyComposite()->findByName("Deneb"));
0956 
0957     ArtificialHorizon shutdownHorizon;
0958     addHorizonConstraint(&shutdownHorizon, "h", true,
0959                          QVector<double>({175, 200}), QVector<double>({70, 70}));
0960     Ekos::SchedulerJob::setHorizon(&shutdownHorizon);
0961 
0962     // Start the scheduler about 9pm local
0963     const QDateTime startUTime = QDateTime(QDate(2021, 6, 14), QTime(4, 0, 0), Qt::UTC);
0964 
0965     QDateTime d1Start (QDateTime(QDate(2021, 6, 14), QTime( 5, 50, 0), Qt::UTC)); // 10:48pm
0966     QDateTime a1Start (QDateTime(QDate(2021, 6, 14), QTime( 6, 34, 0), Qt::UTC)); // 11:34pm
0967     QDateTime d2Start (QDateTime(QDate(2021, 6, 14), QTime(10, 20, 0), Qt::UTC)); //  3:20am
0968     QDateTime d2End   (QDateTime(QDate(2021, 6, 14), QTime(10, 53, 0), Qt::UTC)); //  3:53am
0969     QDateTime d3Start (QDateTime(QDate(2021, 6, 15), QTime( 5, 44, 0), Qt::UTC)); // 10:43pm the next day
0970 
0971     KStarsDateTime currentUTime;
0972     int sleepMs = 0;
0973     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
0974 
0975     startup(geo, targetObjects, startUTime, currentUTime, sleepMs, dir);
0976     slewAndRun(targetObjects[denebIndex], d1Start, a1Start, currentUTime, sleepMs, 600, "Greedy job #1");
0977     slewAndRun(targetObjects[altairIndex], a1Start, d2Start, currentUTime, sleepMs, 600, "Greedy job #2");
0978     slewAndRun(targetObjects[denebIndex], d2Start, d2End, currentUTime, sleepMs, 600, "Greedy job #3");
0979 
0980     parkAndSleep(currentUTime, sleepMs);
0981     wakeupAndRestart(d3Start, currentUTime, sleepMs);
0982     startModules(currentUTime, sleepMs);
0983     slewAndRun(targetObjects[denebIndex], d3Start.addSecs(500), QDateTime(), currentUTime, sleepMs, 600, "Greedy job #4");
0984 }
0985 
0986 // Check if already existing captures are recognized properly and schedules are
0987 // recognized are started properly or as completed.
0988 void TestEkosSchedulerOps::testRememberJobProgress()
0989 {
0990     // turn on remember job progress
0991     Options::setRememberJobProgress(true);
0992     QVERIFY(Options::rememberJobProgress());
0993 
0994     // a well known place and target :)
0995     GeoLocation geo(dms(9, 45, 54), dms(49, 6, 22), "Schwaebisch Hall", "Baden-Wuerttemberg", "Germany", +1);
0996     SkyObject *targetObject = KStars::Instance()->data()->skyComposite()->findByName("Kocab");
0997 
0998     // Take 20:00 GMT (incl. +1h DST) and invalid wakeup time since the scheduler will start immediately
0999     QDateTime startUTime(QDateTime(QDate(2021, 10, 30), QTime(18, 0, 0), Qt::UTC));
1000     const QDateTime wakeupTime;
1001     KStarsDateTime currentUTime;
1002     int sleepMs = 0;
1003 
1004     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
1005     QDir fits_dir(dir.path() + "/images");
1006 
1007     QVector<TestEkosSchedulerHelper::CaptureJob> capture_jobs = QVector<TestEkosSchedulerHelper::CaptureJob>();
1008 
1009     // parse test data to create the capture jobs
1010     QFETCH(QString, jobs);
1011     if (jobs != "")
1012     {
1013         for (QString value : jobs.split(","))
1014         {
1015             QVERIFY(value.indexOf(":") > -1);
1016             QString filter = value.left(value.indexOf(":")).trimmed();
1017             int count      = value.right(value.length() - value.indexOf(":") - 1).toInt();
1018             capture_jobs.append({1000, count, filter, fits_dir.absolutePath()});
1019         }
1020     }
1021 
1022     // parse test data to create the existing frame files
1023     QFETCH(QString, frames);
1024     if (frames != "")
1025     {
1026         for (QString value : frames.split(","))
1027         {
1028             QVERIFY(value.indexOf(":") > -1);
1029             QString filter = value.left(value.indexOf(":")).trimmed();
1030             int count      = value.right(value.length() - value.indexOf(":") - 1).toInt();
1031             QDir img_dir(fits_dir);
1032             img_dir.mkpath("Kocab/Light/" + filter);
1033 
1034             // create files
1035             for (int i = 0; i < count; i++)
1036             {
1037                 QFile frame;
1038                 frame.setFileName(QString(img_dir.absolutePath() + "/Kocab/Light/" + filter + "/Kocab_Light_%1_%2.fits").arg(filter).arg(
1039                                       i));
1040                 frame.open(QIODevice::WriteOnly | QIODevice::Text);
1041                 frame.close();
1042             }
1043         }
1044     }
1045 
1046     // start up the scheduler job
1047     QFETCH(int, iterations);
1048 
1049     TestEkosSchedulerHelper::CompletionCondition completionCondition;
1050     completionCondition.type = Ekos::FINISH_REPEAT;
1051     completionCondition.repeat = iterations;
1052     startupJob(geo, startUTime, &dir,
1053                TestEkosSchedulerHelper::getSchedulerFile(targetObject, m_startupCondition, completionCondition, {true, true, true, true},
1054                        false, false, sleepMs, nullptr, {false, false, false, false}),
1055                TestEkosSchedulerHelper::getEsqContent(capture_jobs), wakeupTime, currentUTime, sleepMs);
1056 
1057     // fetch the expected result from the test data
1058     QFETCH(bool, scheduled);
1059 
1060     // verify if the job is scheduled as expected
1061     QVERIFY(scheduler->moduleState()->jobs()[0]->getState() == (scheduled ? Ekos::SCHEDJOB_SCHEDULED :
1062             Ekos::SCHEDJOB_COMPLETE));
1063 }
1064 
1065 void TestEkosSchedulerOps::loadGreedySchedule(
1066     bool first, const QString &targetName,
1067     const TestEkosSchedulerHelper::StartupCondition &startupCondition,
1068     const TestEkosSchedulerHelper::CompletionCondition &completionCondition,
1069     QTemporaryDir &dir, const QVector<TestEkosSchedulerHelper::CaptureJob> &captureJob, int minAltitude,
1070     const TestEkosSchedulerHelper::ScheduleSteps steps, bool enforceTwilight, bool enforceHorizon, int errorDelay)
1071 {
1072     SkyObject *object = KStars::Instance()->data()->skyComposite()->findByName(targetName);
1073     QVERIFY(object != nullptr);
1074     const QString schedulerXML =
1075         TestEkosSchedulerHelper::getSchedulerFile(
1076             object, startupCondition, completionCondition, steps, enforceTwilight, enforceHorizon, minAltitude,
1077             nullptr, {false, false, true, false}, errorDelay);
1078 
1079     // Write the scheduler and sequence files.
1080     QString f1 = writeFiles(targetName, dir, captureJob, schedulerXML);
1081     scheduler->load(first, QString("file://%1").arg(f1));
1082 }
1083 
1084 struct SPlan
1085 {
1086     QString name;
1087     QString start;
1088     QString stop;
1089 };
1090 
1091 bool checkSchedule(const QVector<SPlan> &ref, const QList<Ekos::GreedyScheduler::JobSchedule> &schedule, int tolerance)
1092 {
1093     bool result = true;
1094     if (schedule.size() != ref.size())
1095     {
1096         qCInfo(KSTARS_EKOS_TEST) << QString("Sizes don't match %1 vs ref %2").arg(schedule.size()).arg(ref.size());
1097         return false;
1098     }
1099     for (int i = 0; i < ref.size(); ++i)
1100     {
1101         QDateTime startTime = QDateTime::fromString(ref[i].start, "yyyy/MM/dd hh:mm");
1102         QDateTime stopTime = QDateTime::fromString(ref[i].stop, "yyyy/MM/dd hh:mm");
1103         if (!startTime.isValid() || !stopTime.isValid())
1104         {
1105             qCInfo(KSTARS_EKOS_TEST) << QString("Reference start or stop time invalid: %1 %2").arg(ref[i].start).arg(ref[i].stop);
1106             result = false;
1107         }
1108         else if (!schedule[i].startTime.isValid() || !schedule[i].stopTime.isValid())
1109         {
1110             qCInfo(KSTARS_EKOS_TEST) << QString("Scheduled start or stop time %1 invalid.").arg(i);
1111             result = false;
1112         }
1113         else if ((ref[i].name != schedule[i].job->getName()) ||
1114                  (std::abs(schedule[i].startTime.secsTo(startTime)) > tolerance) ||
1115                  (std::abs(startTime.secsTo(stopTime) - schedule[i].startTime.secsTo(schedule[i].stopTime)) > tolerance))
1116         {
1117             qCInfo(KSTARS_EKOS_TEST)
1118                     << QString("Mismatch on entry %1: ref \"%2\" \"%3\" \"%4\" result \"%5\" \"%6\" \"%7\"")
1119                     .arg(i)
1120                     .arg(ref[i].name, startTime.toString(), stopTime.toString(),
1121                          schedule[i].job->getName(),
1122                          schedule[i].startTime.toString(),
1123                          schedule[i].stopTime.toString());
1124             result = false;
1125         }
1126     }
1127     return result;
1128 }
1129 
1130 void TestEkosSchedulerOps::testGreedy()
1131 {
1132     // Allow 10 minutes of slop in the schedule. The scheduler simulates every 2 minutes,
1133     // so 10 minutes is approx 5 of these timesteps.
1134     constexpr int checkScheduleTolerance = 600;
1135 
1136     Options::setSchedulerAlgorithm(Ekos::ALGORITHM_GREEDY);
1137 
1138     // Setup geo and an artificial horizon.
1139     GeoLocation geo(dms(-122, 10), dms(37, 26, 30), "Silicon Valley", "CA", "USA", -8);
1140     ArtificialHorizon shutdownHorizon;
1141     addHorizonConstraint(&shutdownHorizon, "h", true, QVector<double>({175, 200}), QVector<double>({70, 70}));
1142     Ekos::SchedulerJob::setHorizon(&shutdownHorizon);
1143     // Start the scheduler about 9pm local
1144     const QDateTime startUTime = QDateTime(QDate(2021, 6, 14), QTime(4, 0, 0), Qt::UTC);
1145     initTimeGeo(geo, startUTime);
1146 
1147     auto schedJob200x60 = QVector<TestEkosSchedulerHelper::CaptureJob>(1, {200, 60, "Red", "."});
1148     auto schedJob400x60 = QVector<TestEkosSchedulerHelper::CaptureJob>(1, {400, 60, "Red", "."});
1149 
1150     TestEkosSchedulerHelper::StartupCondition asapStartupCondition, atStartupCondition;
1151     TestEkosSchedulerHelper::CompletionCondition finishCompletionCondition, loopCompletionCondition;
1152     TestEkosSchedulerHelper::CompletionCondition repeat2CompletionCondition, atCompletionCondition;
1153     asapStartupCondition.type = Ekos::START_ASAP;
1154     atStartupCondition.type = Ekos::START_AT;
1155     atStartupCondition.atLocalDateTime = QDateTime(QDate(2021, 6, 14), QTime(1, 0, 0), Qt::LocalTime);
1156     finishCompletionCondition.type = Ekos::FINISH_SEQUENCE;
1157     loopCompletionCondition.type = Ekos::FINISH_LOOP;
1158     repeat2CompletionCondition.type = Ekos::FINISH_REPEAT;
1159     repeat2CompletionCondition.repeat = 2;
1160     atCompletionCondition.type = Ekos::FINISH_AT;
1161     atCompletionCondition.atLocalDateTime = QDateTime(QDate(2021, 6, 14), QTime(3, 30, 0), Qt::LocalTime);
1162 
1163     // Write the scheduler and sequence files.
1164     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
1165 
1166     // Altair is scheduled with first priority, but doesn't clear constraints immediately.
1167     // Deneb is also scheduled, but with twice as many captures.  Both start ASAP and run to comletion.
1168     // Deneb runs first, gets interrupted by Altair which runs to completion.
1169     // Then Deneb runs for the rest of the night, and also again the next evening before it comletes.
1170     loadGreedySchedule(true, "Altair", asapStartupCondition, finishCompletionCondition, dir, schedJob200x60, 30);
1171     loadGreedySchedule(false, "Deneb", asapStartupCondition, finishCompletionCondition, dir, schedJob400x60, 30);
1172     scheduler->process()->evaluateJobs(false);
1173     QVERIFY(checkSchedule(
1174     {
1175         {"Deneb",  "2021/06/13 22:48", "2021/06/13 23:35"},
1176         {"Altair", "2021/06/13 23:35", "2021/06/14 02:59"},
1177         {"Deneb",  "2021/06/14 02:59", "2021/06/14 03:53"},
1178         {"Deneb",  "2021/06/14 22:44", "2021/06/15 03:48"}},
1179     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1180 
1181     // Disable greedy scheduling, and Deneb should NOT run until Altair is done.
1182     Options::setGreedyScheduling(false);
1183     scheduler->process()->evaluateJobs(false);
1184     QVERIFY(checkSchedule(
1185     {
1186         {"Altair", "2021/06/13 23:34", "2021/06/14 03:00"},
1187         {"Deneb",  "2021/06/14 03:01", "2021/06/14 03:53"},
1188         {"Deneb",  "2021/06/14 22:44", "2021/06/15 03:52"}},
1189     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1190     Options::setGreedyScheduling(true);
1191 
1192     // As above, except Altair has completion condition repeat 2. It should run longer.
1193     // This makes a mess of things, as Altair can't complete during the first night, running into an artificial horizon constraint.
1194     // It also can't start the 2nd night as early as Deneb, so the 2nd night is Deneb, Altair (completing), Deneb, and Deneb finishes the 3rd night.
1195     loadGreedySchedule(true, "Altair", asapStartupCondition, repeat2CompletionCondition, dir, schedJob200x60, 30);
1196     loadGreedySchedule(false, "Deneb", asapStartupCondition, finishCompletionCondition, dir, schedJob400x60, 30);
1197     scheduler->process()->evaluateJobs(false);
1198     QVERIFY(checkSchedule(
1199     {
1200         {"Deneb",  "2021/06/13 22:48", "2021/06/13 23:35"},
1201         {"Altair", "2021/06/13 23:35", "2021/06/14 03:21"},
1202         {"Deneb",  "2021/06/14 03:22", "2021/06/14 03:53"},
1203         {"Deneb",  "2021/06/14 22:44", "2021/06/14 23:30"},
1204         {"Altair", "2021/06/14 23:31", "2021/06/15 02:29"},
1205         {"Deneb",  "2021/06/15 02:30", "2021/06/15 03:53"}},
1206     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1207 
1208     // Again disable greedy scheduling, and Deneb should NOT run until Altair is done.
1209     Options::setGreedyScheduling(false);
1210     scheduler->process()->evaluateJobs(false);
1211     QVERIFY(checkSchedule(
1212     {
1213         {"Altair", "2021/06/13 23:34", "2021/06/14 03:18"},
1214         {"Altair", "2021/06/14 23:30", "2021/06/15 02:32"},
1215         {"Deneb",  "2021/06/15 02:33", "2021/06/15 03:53"}},
1216     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1217     Options::setGreedyScheduling(true);
1218 
1219     // Now we're using START_AT 6/14 1am for Altair (but not repeating twice).
1220     // Deneb will run until then (1am) the 1st night. Altair will run until it hits the horizon constraint.
1221     // Deneb runs through the end of the night, and again the next night until it completes.
1222     loadGreedySchedule(true, "Altair", atStartupCondition, finishCompletionCondition, dir, schedJob200x60, 30);
1223     loadGreedySchedule(false, "Deneb", asapStartupCondition, finishCompletionCondition, dir, schedJob400x60, 30);
1224     scheduler->process()->evaluateJobs(false);
1225     QVERIFY(checkSchedule(
1226     {
1227         {"Deneb",  "2021/06/13 22:48", "2021/06/14 01:00"},
1228         {"Altair", "2021/06/14 01:00", "2021/06/14 03:21"},
1229         {"Deneb",  "2021/06/14 03:22", "2021/06/14 03:53"},
1230         {"Deneb",  "2021/06/14 22:44", "2021/06/15 02:44"}},
1231     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1232 
1233     // Again disable greedy scheduling, and Deneb should NOT run until Altair is done.
1234     Options::setGreedyScheduling(false);
1235     scheduler->process()->evaluateJobs(false);
1236     QVERIFY(checkSchedule(
1237     {
1238         {"Altair", "2021/06/14 01:00", "2021/06/14 03:21"},
1239         {"Deneb",  "2021/06/14 03:22", "2021/06/14 03:53"},
1240         {"Deneb",  "2021/06/14 22:44", "2021/06/15 03:53"}},
1241     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1242     Options::setGreedyScheduling(true);
1243 
1244     // We again use START_AT 6/14 1am for Altair, but force Deneb to complete by 3:30am on 6/14.
1245     // So we get the same first two lines as above, but now Deneb stops on the 3rd line at 3:30.
1246     loadGreedySchedule(true, "Altair", atStartupCondition, finishCompletionCondition, dir, schedJob200x60, 30);
1247     loadGreedySchedule(false, "Deneb", asapStartupCondition, atCompletionCondition, dir, schedJob400x60, 30);
1248     scheduler->process()->evaluateJobs(false);
1249     QVERIFY(checkSchedule(
1250     {
1251         {"Deneb",  "2021/06/13 22:48", "2021/06/14 01:00"},
1252         {"Altair", "2021/06/14 01:00", "2021/06/14 03:21"},
1253         {"Deneb",  "2021/06/14 03:22", "2021/06/14 03:30"}},
1254     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1255 
1256     // Again disable greedy scheduling, and Deneb should NOT run until Altair is done.
1257     Options::setGreedyScheduling(false);
1258     scheduler->process()->evaluateJobs(false);
1259     QVERIFY(checkSchedule(
1260     {
1261         {"Altair", "2021/06/14 01:00", "2021/06/14 03:21"},
1262         {"Deneb",  "2021/06/14 03:22", "2021/06/14 03:30"}},
1263     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1264     Options::setGreedyScheduling(true);
1265 
1266     // We have the same Altair constraints, but this time allow Deneb to run forever.
1267     // It will look like the 3rd test, except Deneb keeps running through the end of the simulated time (2 days).
1268     loadGreedySchedule(true, "Altair", atStartupCondition, finishCompletionCondition, dir, schedJob200x60, 30);
1269     loadGreedySchedule(false, "Deneb", asapStartupCondition, loopCompletionCondition, dir, schedJob400x60, 30);
1270     scheduler->process()->evaluateJobs(false);
1271     QVERIFY(checkSchedule(
1272     {
1273         {"Deneb",  "2021/06/13 22:48", "2021/06/14 01:00"},
1274         {"Altair", "2021/06/14 01:00", "2021/06/14 03:21"},
1275         {"Deneb",  "2021/06/14 03:22", "2021/06/14 03:53"},
1276         {"Deneb",  "2021/06/14 22:44", "2021/06/15 03:52"}},
1277     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1278 
1279     // Again disable greedy scheduling, and Deneb should NOT run until Altair is done.
1280     Options::setGreedyScheduling(false);
1281     scheduler->process()->evaluateJobs(false);
1282     QVERIFY(checkSchedule(
1283     {
1284         {"Altair", "2021/06/14 01:00", "2021/06/14 03:19"},
1285         {"Deneb",  "2021/06/14 03:20", "2021/06/14 03:52"},
1286         {"Deneb",  "2021/06/14 22:44", "2021/06/15 03:52"}},
1287     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1288     Options::setGreedyScheduling(true);
1289 
1290     // Altair stars asap, Deneb has an at 1am startup and loop finish.
1291     // Altair should start when it's able (about 23:35) but get interrupted by deneb which is higher priority
1292     // because of its startat. Altair will start up again the next evening because Deneb's startat will have expired.
1293     loadGreedySchedule(true, "Altair", asapStartupCondition, finishCompletionCondition, dir, schedJob200x60, 30);
1294     loadGreedySchedule(false, "Deneb", atStartupCondition, loopCompletionCondition, dir, schedJob400x60, 30);
1295     scheduler->process()->evaluateJobs(false);
1296     QVERIFY(checkSchedule(
1297     {
1298         {"Altair", "2021/06/13 23:34", "2021/06/14 01:00"},
1299         {"Deneb",  "2021/06/14 01:00", "2021/06/14 03:52"},
1300         {"Altair", "2021/06/14 23:30", "2021/06/15 01:31"}},
1301     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1302 
1303     // Again disable greedy scheduling. Nothing should change as no jobs were running before higher priority ones.
1304     Options::setGreedyScheduling(false);
1305     scheduler->process()->evaluateJobs(false);
1306     QVERIFY(checkSchedule(
1307     {
1308         {"Altair", "2021/06/13 23:34", "2021/06/14 01:00"},
1309         {"Deneb",  "2021/06/14 01:00", "2021/06/14 03:52"},
1310         {"Altair", "2021/06/14 23:30", "2021/06/15 01:31"}},
1311     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1312     Options::setGreedyScheduling(true);
1313 }
1314 
1315 void TestEkosSchedulerOps::testGroups()
1316 {
1317     // Allow 10 minutes of slop in the schedule. The scheduler simulates every 2 minutes,
1318     // so 10 minutes is approx 5 of these timesteps.
1319     constexpr int checkScheduleTolerance = 600;
1320 
1321     Options::setSchedulerAlgorithm(Ekos::ALGORITHM_GREEDY);
1322 
1323     // Setup geo and an artificial horizon.
1324     GeoLocation geo(dms(-122, 10), dms(37, 26, 30), "Silicon Valley", "CA", "USA", -8);
1325     ArtificialHorizon shutdownHorizon;
1326     addHorizonConstraint(&shutdownHorizon, "h", true, QVector<double>({175, 200}), QVector<double>({70, 70}));
1327     Ekos::SchedulerJob::setHorizon(&shutdownHorizon);
1328     // Start the scheduler about 9pm local
1329     const QDateTime startUTime = QDateTime(QDate(2021, 6, 14), QTime(4, 0, 0), Qt::UTC);
1330     initTimeGeo(geo, startUTime);
1331 
1332     // About a 30-minute job
1333     auto schedJob30minutes = QVector<TestEkosSchedulerHelper::CaptureJob>(
1334     {{180, 4, "Red", "."}, {180, 6, "Blue", "."}});
1335 
1336     TestEkosSchedulerHelper::StartupCondition asapStartupCondition, atStartupCondition;
1337     TestEkosSchedulerHelper::CompletionCondition finishCompletionCondition, loopCompletionCondition;
1338     TestEkosSchedulerHelper::CompletionCondition repeat2CompletionCondition, atCompletionCondition;
1339     asapStartupCondition.type = Ekos::START_ASAP;
1340     finishCompletionCondition.type = Ekos::FINISH_SEQUENCE;
1341     loopCompletionCondition.type = Ekos::FINISH_LOOP;
1342     repeat2CompletionCondition.type = Ekos::FINISH_REPEAT;
1343     repeat2CompletionCondition.repeat = 2;
1344 
1345     // Write the scheduler and sequence files.
1346     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
1347 
1348     // Create 3 jobs in the same group, of the same target, each should last ~30 minutes (not counting repetitions).
1349     // the first job just runs the 30-minute sequence, the 2nd repeats forever, the 3rd repeats twice.
1350     // Given the grouping we should see jobs scheduled about every 30 minutes
1351     // orderdc as: J1, J2, J3, J2 (skips the completed J1), J3, J2 (looing forever as J3 is now done).
1352     loadGreedySchedule(true, "Altair", asapStartupCondition, finishCompletionCondition, dir, schedJob30minutes, 30);
1353     scheduler->moduleState()->jobs().last()->setName("J1finish");
1354     scheduler->moduleState()->jobs().last()->setGroup("group1");
1355     loadGreedySchedule(false, "Altair", asapStartupCondition, loopCompletionCondition, dir, schedJob30minutes, 30);
1356     scheduler->moduleState()->jobs().last()->setName("J2loop");
1357     scheduler->moduleState()->jobs().last()->setGroup("group1");
1358     loadGreedySchedule(false, "Altair", asapStartupCondition, repeat2CompletionCondition, dir, schedJob30minutes, 30);
1359     scheduler->moduleState()->jobs().last()->setName("J3repeat2");
1360     scheduler->moduleState()->jobs().last()->setGroup("group1");
1361     scheduler->process()->evaluateJobs(false);
1362 
1363     QVERIFY(checkSchedule(
1364     {
1365         {"J1finish",  "2021/06/13 23:34", "2021/06/14 00:10"},
1366         {"J2loop",    "2021/06/14 00:11", "2021/06/14 00:47"},
1367         {"J3repeat2", "2021/06/14 00:48", "2021/06/14 01:24"},
1368         {"J2loop",    "2021/06/14 01:25", "2021/06/14 01:55"},
1369         {"J3repeat2", "2021/06/14 01:56", "2021/06/14 02:26"},
1370         {"J2loop",    "2021/06/14 02:27", "2021/06/14 03:19"},
1371         {"J2loop",    "2021/06/14 23:31", "2021/06/15 03:15"}},
1372     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1373 
1374     // Now do the same thing, but this time disable the group scheduling (by not assigning groups).
1375     // This time J1 should run then J2 will just run/repeat forever.
1376     loadGreedySchedule(true, "Altair", asapStartupCondition, finishCompletionCondition, dir, schedJob30minutes, 30);
1377     scheduler->moduleState()->jobs().last()->setName("J1finish");
1378     loadGreedySchedule(false, "Altair", asapStartupCondition, loopCompletionCondition, dir, schedJob30minutes, 30);
1379     scheduler->moduleState()->jobs().last()->setName("J2loop");
1380     loadGreedySchedule(false, "Altair", asapStartupCondition, repeat2CompletionCondition, dir, schedJob30minutes, 30);
1381     scheduler->moduleState()->jobs().last()->setName("J3repeat2");
1382     scheduler->process()->evaluateJobs(false);
1383 
1384     QVERIFY(checkSchedule(
1385     {
1386         {"J1finish",  "2021/06/13 23:34", "2021/06/14 00:10"},
1387         {"J2loop",    "2021/06/14 00:11", "2021/06/14 03:19"},
1388         {"J2loop",    "2021/06/14 23:30", "2021/06/15 03:16"}},
1389     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1390 }
1391 
1392 void TestEkosSchedulerOps::testGreedyAborts()
1393 {
1394     Options::setSchedulerAlgorithm(Ekos::ALGORITHM_GREEDY);
1395 
1396     // Allow 10 minutes of slop in the schedule. The scheduler simulates every 2 minutes,
1397     // so 10 minutes is approx 5 of these timesteps.
1398     constexpr int checkScheduleTolerance = 600;
1399 
1400     Options::setSchedulerAlgorithm(Ekos::ALGORITHM_GREEDY);
1401     Ekos::SchedulerJob::setHorizon(nullptr);
1402 
1403     // Setup geo and an artificial horizon.
1404     GeoLocation geo(dms(-122, 10), dms(37, 26, 30), "Silicon Valley", "CA", "USA", -8);
1405 
1406     // Start the scheduler about 9pm local
1407     const QDateTime startUTime = QDateTime(QDate(2022, 2, 28), QTime(10, 29, 26), Qt::UTC);
1408     initTimeGeo(geo, startUTime);
1409 
1410     auto schedJob200x60 = QVector<TestEkosSchedulerHelper::CaptureJob>(1, {200, 60, "Red", "."});
1411     TestEkosSchedulerHelper::StartupCondition asapStartupCondition;
1412     TestEkosSchedulerHelper::CompletionCondition loopCompletionCondition;
1413     asapStartupCondition.type = Ekos::START_ASAP;
1414     loopCompletionCondition.type = Ekos::FINISH_LOOP;
1415     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
1416 
1417     // Parameters related to resheduling aborts and the delay times are in the scheduler .esl file created in loadGreedyScheduler.
1418     const TestEkosSchedulerHelper::ScheduleSteps steps = {true, true, true, true};
1419     const bool enforceTwilight = true;
1420     const bool enforceHorizon = true;
1421     const int errorDelay = 3600;
1422 
1423     loadGreedySchedule(true, "M 104", asapStartupCondition, loopCompletionCondition, dir, schedJob200x60, 36, steps,
1424                        enforceTwilight, enforceHorizon, errorDelay);
1425     loadGreedySchedule(false, "NGC 3628", asapStartupCondition, loopCompletionCondition, dir, schedJob200x60, 45, steps,
1426                        enforceTwilight, enforceHorizon, errorDelay);
1427     loadGreedySchedule(false, "M 5", asapStartupCondition, loopCompletionCondition, dir, schedJob200x60, 30, steps,
1428                        enforceTwilight, enforceHorizon, errorDelay);
1429     loadGreedySchedule(false, "M 42", asapStartupCondition, loopCompletionCondition, dir, schedJob200x60, 42, steps,
1430                        enforceTwilight, enforceHorizon, errorDelay);
1431 
1432     // start the scheduler at 1am
1433     KStarsDateTime evalUTime(QDate(2022, 2, 28), QTime(9, 00, 00), Qt::UTC);
1434     KStarsData::Instance()->changeDateTime(evalUTime);
1435 
1436     scheduler->process()->evaluateJobs(false);
1437 
1438     QVERIFY(checkSchedule(
1439     {
1440         {"M 104",      "2022/02/28 01:00", "2022/02/28 03:52"},
1441         {"M 5",        "2022/02/28 03:52", "2022/02/28 05:14"},
1442         {"M 42",       "2022/02/28 19:31", "2022/02/28 20:43"},
1443         {"NGC 3628",   "2022/02/28 22:02", "2022/03/01 00:38"},
1444         {"M 104",      "2022/03/01 00:39", "2022/03/01 03:49"},
1445         {"M 5",        "2022/03/01 03:50", "2022/03/01 05:12"},
1446         {"M 42",       "2022/03/01 19:31", "2022/03/01 20:39"},
1447         {"NGC 3628",   "2022/03/01 21:58", "2022/03/02 00:34"},
1448         {"M 104",      "2022/03/02 00:35", "2022/03/02 03:45"}},
1449     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1450 
1451     // Now load the same schedule, but set the M104 job to have been aborted a minute before.
1452     loadGreedySchedule(true, "M 104", asapStartupCondition, loopCompletionCondition, dir, schedJob200x60, 36, steps,
1453                        enforceTwilight, enforceHorizon, errorDelay);
1454     loadGreedySchedule(false, "NGC 3628", asapStartupCondition, loopCompletionCondition, dir, schedJob200x60, 45, steps,
1455                        enforceTwilight, enforceHorizon, errorDelay);
1456     loadGreedySchedule(false, "M 5", asapStartupCondition, loopCompletionCondition, dir, schedJob200x60, 30, steps,
1457                        enforceTwilight, enforceHorizon, errorDelay);
1458     loadGreedySchedule(false, "M 42", asapStartupCondition, loopCompletionCondition, dir, schedJob200x60, 42, steps,
1459                        enforceTwilight, enforceHorizon, errorDelay);
1460 
1461     // Otherwise time changes below will trigger reschedules and mess up test.
1462     scheduler->moduleState()->setSchedulerState(Ekos::SCHEDULER_RUNNING);
1463 
1464     // Find the M 104 job and make it aborted at 12:59am
1465     KStarsDateTime abortUTime(QDate(2022, 2, 28), QTime(8, 59, 00), Qt::UTC);
1466     KStarsData::Instance()->changeDateTime(abortUTime);
1467 
1468     Ekos::SchedulerJob *m104Job = nullptr;
1469     foreach (auto &job, scheduler->moduleState()->jobs())
1470         if (job->getName() == "M 104")
1471         {
1472             m104Job = job;
1473             m104Job->setState(Ekos::SCHEDJOB_ABORTED);
1474         }
1475     QVERIFY(m104Job != nullptr);
1476 
1477     // start the scheduler at 1am
1478     KStarsData::Instance()->changeDateTime(evalUTime);
1479 
1480     scheduler->process()->evaluateJobs(false);
1481 
1482     // The M104 job is no longer the first job, since aborted jobs are delayed an hour,
1483     QVERIFY(checkSchedule(
1484     {
1485         {"NGC 3628",   "2022/02/28 01:00", "2022/02/28 02:00"},
1486         {"M 104",      "2022/02/28 02:00", "2022/02/28 03:52"},
1487         {"M 5",        "2022/02/28 03:52", "2022/02/28 05:14"},
1488         {"M 42",       "2022/02/28 19:31", "2022/02/28 20:43"},
1489         {"NGC 3628",   "2022/02/28 22:02", "2022/03/01 00:38"},
1490         {"M 104",      "2022/03/01 00:39", "2022/03/01 03:49"},
1491         {"M 5",        "2022/03/01 03:50", "2022/03/01 05:12"},
1492         {"M 42",       "2022/03/01 19:31", "2022/03/01 20:39"},
1493         {"NGC 3628",   "2022/03/01 21:58", "2022/03/02 00:34"},
1494         {"M 104",      "2022/03/02 00:35", "2022/03/02 03:45"}},
1495 
1496     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1497     auto ngc3628 = scheduler->process()->getGreedyScheduler()->getSchedule()[0].job;
1498     QVERIFY(ngc3628->getName() == "NGC 3628");
1499 
1500     // And ngc3628 should not be preempted right away,
1501     QDateTime localTime(QDate(2022, 2, 28), QTime(1, 00, 00), Qt::LocalTime);
1502     bool keepRunning = scheduler->process()->getGreedyScheduler()->checkJob(scheduler->moduleState()->jobs(), localTime,
1503                        ngc3628);
1504     QVERIFY(keepRunning);
1505 
1506     // nor in a half-hour.
1507     auto newTime = evalUTime.addSecs(1800);
1508     KStarsData::Instance()->changeDateTime(newTime);
1509     localTime = localTime.addSecs(1800);
1510     keepRunning = scheduler->process()->getGreedyScheduler()->checkJob(scheduler->moduleState()->jobs(), localTime, ngc3628);
1511     QVERIFY(keepRunning);
1512 
1513     // But if we wait until 2am, m104 should preempt it,
1514     newTime = newTime.addSecs(1800);
1515     KStarsData::Instance()->changeDateTime(newTime);
1516     localTime = localTime.addSecs(1800);
1517     keepRunning = scheduler->process()->getGreedyScheduler()->checkJob(scheduler->moduleState()->jobs(), localTime, ngc3628);
1518     QVERIFY(!keepRunning);
1519 
1520     // and M104 should be scheduled to start running "now" (2am).
1521     scheduler->process()->evaluateJobs(false);
1522     auto newSchedule = scheduler->process()->getGreedyScheduler()->getSchedule();
1523     QVERIFY(newSchedule.size() > 0);
1524     QVERIFY(newSchedule[0].job->getName() == "M 104");
1525     QVERIFY(std::abs(newSchedule[0].startTime.secsTo(
1526                          QDateTime(QDate(2022, 2, 28), QTime(2, 00, 00), Qt::LocalTime))) < 200);
1527 }
1528 
1529 void TestEkosSchedulerOps::testArtificialCeiling()
1530 {
1531     constexpr int checkScheduleTolerance = 600;
1532     Options::setSchedulerAlgorithm(Ekos::ALGORITHM_GREEDY);
1533 
1534     ArtificialHorizon horizon;
1535     QVector<double> az1  = {259.0, 260.0, 299.0, 300.0, 330.0,   0.0,  30.0,  70.0,  71.0,  90.0, 120.0, 150.0, 180.0, 210.0, 240.0, 259.99};
1536     QVector<double> alt1 = { 90.0,  26.0,  26.0,  26.0,  26.0,  26.0,  26.0,  26.0,  90.0,  90.0,  90.0,  90.0,  90.0,  90.0,  90.0,  90.0};
1537     addHorizonConstraint(&horizon, "floor", true, az1, alt1);
1538 
1539     QVector<double> az2  = {260.0, 300.0, 330.0,  0.0,  30.0,  70.0};
1540     QVector<double> alt2 = { 66.0,  66.0,  66.0, 66.0,  66.0,  66.0};
1541     addHorizonConstraint(&horizon, "ceiling", true, az2, alt2, true); // last true --> ceiling
1542 
1543     // Setup geo and an artificial horizon.
1544     GeoLocation geo(dms(-75, 40), dms(45, 40), "New York", "NY", "USA", -5);
1545     Ekos::SchedulerJob::setHorizon(&horizon);
1546     // Start the scheduler about 9pm local
1547     const QDateTime startUTime = QDateTime(QDate(2022, 8, 21), QTime(16, 0, 0), Qt::UTC);
1548     initTimeGeo(geo, startUTime);
1549 
1550     auto schedJob200x60 = QVector<TestEkosSchedulerHelper::CaptureJob>(1, {200, 60, "Red", "."});
1551     auto schedJob400x60 = QVector<TestEkosSchedulerHelper::CaptureJob>(1, {400, 60, "Red", "."});
1552 
1553     TestEkosSchedulerHelper::StartupCondition asapStartupCondition;
1554     TestEkosSchedulerHelper::CompletionCondition loopCompletionCondition;
1555     asapStartupCondition.type = Ekos::START_ASAP;
1556     loopCompletionCondition.type = Ekos::FINISH_LOOP;
1557 
1558     // Write the scheduler and sequence files.
1559     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
1560 
1561     loadGreedySchedule(true, "theta Bootis", asapStartupCondition, loopCompletionCondition, dir, schedJob200x60, 0,
1562     {true, true, true, true}, false, true); // min alt = 0, don't enforce twilight
1563     scheduler->process()->evaluateJobs(false);
1564 
1565     // There are no altitude constraints, just an artificial horizon with 2 lines, the top a ceiling.
1566     // It should scheduler from "now" until the star reaches the ceiling, then shut off until it lowers
1567     // back into the window, then shutoff again when the star goes below the artificial horizon, and
1568     // then start-up again when it once again raises above the artificial horizon into the window.
1569     QVERIFY(checkSchedule(
1570     {
1571         {"HD 126660", "2022/08/21 12:00", "2022/08/21 15:05"},
1572         {"HD 126660", "2022/08/21 19:50", "2022/08/22 00:34"},
1573         {"HD 126660", "2022/08/22 10:19", "2022/08/22 15:03"},
1574         {"HD 126660", "2022/08/22 19:46", "2022/08/23 00:30"},
1575         {"HD 126660", "2022/08/23 10:15", "2022/08/23 14:59"}},
1576     scheduler->process()->getGreedyScheduler()->getSchedule(), checkScheduleTolerance));
1577 }
1578 
1579 void TestEkosSchedulerOps::testSettingAltitudeBug()
1580 {
1581     Options::setDawnOffset(0);
1582     Options::setDuskOffset(0);
1583     Options::setSettingAltitudeCutoff(3);
1584     Options::setPreDawnTime(0);
1585 
1586     Options::setSchedulerAlgorithm(Ekos::ALGORITHM_GREEDY);
1587     Ekos::SchedulerJob::setHorizon(nullptr);
1588 
1589     GeoLocation geo(dms(9, 45, 54), dms(49, 6, 22), "Schwaebisch Hall", "Baden-Wuerttemberg", "Germany", +1);
1590     const QDateTime time1 = QDateTime(QDate(2022, 3, 7), QTime(21, 28, 55), Qt::UTC); //22:28 local
1591     initTimeGeo(geo, time1);
1592 
1593     auto wolfgangJob = QVector<TestEkosSchedulerHelper::CaptureJob>(
1594     {
1595         {360, 3, "L", "."}, {360, 1, "R", "."}, {360, 1, "G", "."},
1596         {360, 1, "B", "."}, {360, 2, "L", "."}});
1597 
1598     // Guessing he was using 40minute offsets
1599     Options::setDawnOffset(.666);
1600     Options::setDuskOffset(-.666);
1601 
1602     TestEkosSchedulerHelper::StartupCondition asapStartupCondition;
1603     TestEkosSchedulerHelper::CompletionCondition loopCompletionCondition;
1604     asapStartupCondition.type = Ekos::START_ASAP;
1605     loopCompletionCondition.type = Ekos::FINISH_LOOP;
1606     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
1607 
1608     loadGreedySchedule(true, "NGC 2359", asapStartupCondition, loopCompletionCondition, dir, wolfgangJob, 20);
1609     loadGreedySchedule(false, "NGC 2392", asapStartupCondition, loopCompletionCondition, dir, wolfgangJob, 20);
1610     loadGreedySchedule(false, "M 101", asapStartupCondition, loopCompletionCondition, dir, wolfgangJob, 20);
1611 
1612     scheduler->process()->evaluateJobs(false);
1613 
1614     // In the log with bug, the original schedule had 2359 running 20:30 -> 23:04
1615     //  "Greedy Scheduler plan for the next 48 hours (0.075)s:"
1616     //  "NGC 2359  03/07  20:30 --> 23:04 altitude 19.8 < 20.0"
1617     //  "NGC 2392  03/07  23:05 --> 02:23 altitude 19.7 < 20.0"
1618     //  "M 101     03/08  02:23 --> 05:43 twilight"
1619     //  "NGC 2359  03/08  19:22 --> 22:58 altitude 19.9 < 20.0"
1620     //  "NGC 2392  03/08  22:59 --> 02:17 altitude 19.9 < 20.0"
1621     //  "M 101     03/09  02:18 --> 05:40 twilight"
1622     //  "NGC 2359  03/09  19:23 --> 22:55 altitude 19.8 < 20.0"
1623     //
1624     // but the job was stopped at 22:28, and when the schedule was made then
1625     // it could no longer run ngc2359.
1626     //
1627     //  "Greedy Scheduler plan for the next 48 hours (0.079)s:"
1628     //  "NGC 2392  03/07  22:28 --> 02:22 altitude 19.7 < 20.0"
1629     //  "M 101     03/08  02:23 --> 05:43 twilight"
1630     //  "NGC 2359  03/08  19:22 --> 22:58 altitude 19.9 < 20.0"
1631     //  "NGC 2392  03/08  22:59 --> 02:17 altitude 19.9 < 20.0"
1632     //  "M 101     03/09  02:18 --> 05:42 twilight"
1633     //  "NGC 2359  03/09  19:23 --> 22:55 altitude 19.8 < 20.0"
1634     //
1635     // The issue was that settingAltitudeCutoff was being applied to the running
1636     // job, preempting it. The intention of that parameter is to stop new jobs
1637     // from being scheduled near their altitude cutoff times, not to preempt existing ones.
1638 
1639     KStarsDateTime time2(QDate(2022, 3, 7), QTime(19, 30, 00), Qt::UTC); //20:30 local
1640     initTimeGeo(geo, time2);
1641     scheduler->process()->evaluateJobs(false);
1642 
1643     // This is fixed, and now, when re-evaluated at 22:28 it should not be preempted.
1644     auto greedy = scheduler->process()->getGreedyScheduler();
1645     Ekos::SchedulerJob *job2359 = scheduler->moduleState()->jobs()[0];
1646     auto time1Local = (Qt::UTC == time1.timeSpec() ? geo.UTtoLT(KStarsDateTime(time1)) : time1);
1647     QVERIFY(greedy->checkJob(scheduler->moduleState()->jobs(), time1Local, job2359));
1648 }
1649 
1650 // This creates empty/dummy fits files to be discovered inside estimateJobTime, when it
1651 // tries to figure out how of of the job is already completed.
1652 // Only useful when Options::rememberJobProgress() is true.
1653 void TestEkosSchedulerOps::makeFitsFiles(const QString &base, int num)
1654 {
1655     QFile f("/dev/null");
1656     QFileInfo info(base);
1657     info.dir().mkpath(".");
1658 
1659     for (int i = 0; i < num; ++i)
1660     {
1661         QString newName = QString("%1_%2.fits").arg(base).arg(i);
1662         QVERIFY(f.copy(newName));
1663     }
1664 }
1665 
1666 // In this test, we simulate pre-existing captures with the rememberJobProgress option,
1667 // and make sure Greedy estimates the job completion time properly.
1668 void TestEkosSchedulerOps::testEstimateTimeBug()
1669 {
1670     Options::setDawnOffset(0);
1671     Options::setDuskOffset(0);
1672     Options::setSettingAltitudeCutoff(3);
1673     Options::setPreDawnTime(0);
1674     Options::setRememberJobProgress(true);
1675     Options::setDitherEnabled(true);
1676     Options::setDitherFrames(1);
1677 
1678     Options::setSchedulerAlgorithm(Ekos::ALGORITHM_GREEDY);
1679     Ekos::SchedulerJob::setHorizon(nullptr);
1680     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
1681 
1682     GeoLocation geo(dms(9, 45, 54), dms(49, 6, 22), "Schwaebisch Hall", "Baden-Wuerttemberg", "Germany", +1);
1683     const QDateTime time1 = QDateTime(QDate(2022, 3, 20), QTime(18, 52, 48), Qt::UTC); //19:52 local
1684     initTimeGeo(geo, time1);
1685 
1686     auto path = dir.filePath(".");
1687     auto jobLRGB = QVector<TestEkosSchedulerHelper::CaptureJob>(
1688     {{360, 3, "L", path}, {360, 1, "R", path}, {360, 1, "G", path}, {360, 1, "B", path}, {360, 2, "L", path}});
1689     auto jobNB = QVector<TestEkosSchedulerHelper::CaptureJob>({{600, 1, "Ha", path}, {600, 1, "OIII", path}});
1690 
1691     // Guessing he was using 40minute offsets
1692     Options::setDawnOffset(.666);
1693     Options::setDuskOffset(-.666);
1694 
1695     TestEkosSchedulerHelper::StartupCondition asapStartupCondition;
1696     TestEkosSchedulerHelper::CompletionCondition loopCompletionCondition;
1697     asapStartupCondition.type = Ekos::START_ASAP;
1698     loopCompletionCondition.type = Ekos::FINISH_LOOP;
1699     TestEkosSchedulerHelper::CompletionCondition repeat9;
1700     repeat9.type = Ekos::FINISH_REPEAT;
1701     repeat9.repeat = 9;
1702 
1703     makeFitsFiles(QString("%1%2").arg(path, "/NGC_2359/Light/L/NGC_2359_Light_L"), 41);
1704     makeFitsFiles(QString("%1%2").arg(path, "/NGC_2359/Light/R/NGC_2359_Light_R"), 8);
1705     makeFitsFiles(QString("%1%2").arg(path, "/NGC_2359/Light/G/NGC_2359_Light_G"), 8);
1706     makeFitsFiles(QString("%1%2").arg(path, "/NGC_2359/Light/B/NGC_2359_Light_B"), 8);
1707     makeFitsFiles(QString("%1%2").arg(path, "/NGC_2359/Light/Ha/NGC_2359_Light_Ha"), 12);
1708     makeFitsFiles(QString("%1%2").arg(path, "/NGC_2359/Light/OIII/NGC_2359_Light_OIII"), 11);
1709 
1710     // Not focusing in these schedule steps.
1711     TestEkosSchedulerHelper::ScheduleSteps steps = {true, false, true, true};
1712 
1713     loadGreedySchedule(true, "NGC 2359", asapStartupCondition, repeat9, dir, jobLRGB, 20, steps);
1714     loadGreedySchedule(false, "NGC 2359", asapStartupCondition, loopCompletionCondition, dir, jobNB, 20, steps);
1715     loadGreedySchedule(false, "M 53", asapStartupCondition, loopCompletionCondition, dir, jobLRGB, 20, steps);
1716 
1717     scheduler->process()->evaluateJobs(false);
1718 
1719     // The first (LRGB) version of NGC 2359 is mostly completed and should just run for about 45 minutes.
1720     // At that point, the narrowband NGC2359 and LRGB M53 jobs run.
1721     QVERIFY(checkSchedule(
1722     {
1723         {"NGC 2359",   "2022/03/20 19:52", "2022/03/20 20:38"},
1724         {"NGC 2359",   "2022/03/20 20:39", "2022/03/20 22:11"},
1725         {"M 53",       "2022/03/20 22:12", "2022/03/21 05:14"},
1726         {"NGC 2359",   "2022/03/21 19:45", "2022/03/21 22:07"},
1727         {"M 53",       "2022/03/21 22:08", "2022/03/22 05:12"},
1728         {"NGC 2359",   "2022/03/22 19:47", "2022/03/22 22:03"}},
1729     scheduler->process()->getGreedyScheduler()->getSchedule(), 300));
1730 }
1731 
1732 // A helper for setting up the esl and esq files for the test below.
1733 // The issue is the test loads an esl file, and that file has in it the name of the esq files
1734 // it needs to load. These files are put in temporary directories, so the contents needs
1735 // to modified to reflect the locations of the esq files.
1736 namespace
1737 {
1738 QString setupMessierFiles(QTemporaryDir &dir, const QString &eslFilename, const QString esqFilename)
1739 {
1740     QString esq = esqFilename;
1741     QString esl = eslFilename;
1742 
1743     const QString esqPath(dir.filePath(esq));
1744     const QString eslPath(dir.filePath(esl));
1745 
1746     // Confused about where the test runs...
1747     if (!QFile::exists(esq) || !QFile::exists(esl))
1748     {
1749         esq = QString("../Tests/kstars_ui/%1").arg(esqFilename);
1750         esl = QString("../Tests/kstars_ui/%1").arg(eslFilename);
1751         qCInfo(KSTARS_EKOS_TEST) << QString("Didn't find the files, looking in %1 %2").arg(esq, esl);
1752         if (!QFile::exists(esq) || !QFile::exists(esl))
1753             return QString();
1754     }
1755 
1756     // Copy the sequence file to the temp direcotry
1757     QFile::copy(esq, esqPath);
1758 
1759     // Read and modify the esl file: chage __ESQ_FILE__ to the value of esqPath, and write it out to the temp directory.
1760     QFile eslFile(esl);
1761     if (!eslFile.open(QFile::ReadOnly | QFile::Text))
1762         return QString();
1763     QTextStream in(&eslFile);
1764     TestEkosSchedulerHelper::writeFile(eslPath, in.readAll().replace(QString("__ESQ_FILE__"), esqPath));
1765 
1766     if (!QFile::exists(esqPath) || !QFile::exists(eslPath))
1767         return QString();
1768 
1769     return eslPath;
1770 }
1771 }  // namespace
1772 
1773 void TestEkosSchedulerOps::testGreedyMessier()
1774 {
1775     QTemporaryDir dir(QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/test-XXXXXX");
1776     QString esl0Path = setupMessierFiles(dir, "Messier-1-40-alt0.esl", "Messier-1-40.esq");
1777     QString esl30Path = setupMessierFiles(dir, "Messier-1-40-alt30.esl", "Messier-1-40.esq");
1778     if (esl0Path.isEmpty() || esl30Path.isEmpty())
1779     {
1780         QSKIP("Skipping test because of missing esq or esl files");
1781         return;
1782     }
1783 
1784     Options::setDawnOffset(0);
1785     Options::setDuskOffset(0);
1786     Options::setSettingAltitudeCutoff(3);
1787     Options::setPreDawnTime(0);
1788     Options::setRememberJobProgress(false);
1789     Options::setSchedulerAlgorithm(Ekos::ALGORITHM_GREEDY);
1790 
1791     GeoLocation geo(dms(9, 45, 54), dms(49, 6, 22), "Schwaebisch Hall", "Baden-Wuerttemberg", "Germany", +1);
1792     const QDateTime time1 = QDateTime(QDate(2022, 3, 7), QTime(21, 28, 55), Qt::UTC); //22:28 local
1793     initTimeGeo(geo, time1);
1794 
1795     qCInfo(KSTARS_EKOS_TEST) << QString("Calculate schedule with no artificial horizon and 0 min altitude.");
1796     Ekos::SchedulerJob::setHorizon(nullptr);
1797     scheduler->load(true, QString("file://%1").arg(esl0Path));
1798     const QVector<SPlan> scheduleMinAlt0 =
1799     {
1800         {"M 39", "2022/03/07 22:28", "2022/03/07 22:39"},
1801         {"M 33", "2022/03/07 22:40", "2022/03/07 22:50"},
1802         {"M 32", "2022/03/07 22:51", "2022/03/07 23:02"},
1803         {"M 31", "2022/03/07 23:03", "2022/03/07 23:13"},
1804         {"M 34", "2022/03/07 23:14", "2022/03/07 23:25"},
1805         {"M 1",  "2022/03/07 23:26", "2022/03/07 23:36"},
1806         {"M 35", "2022/03/07 23:37", "2022/03/07 23:48"},
1807         {"M 38", "2022/03/07 23:49", "2022/03/07 23:59"},
1808         {"M 36", "2022/03/08 00:00", "2022/03/08 00:11"},
1809         {"M 40", "2022/03/08 00:12", "2022/03/08 00:22"},
1810         {"M 3",  "2022/03/08 00:23", "2022/03/08 00:34"},
1811         {"M 13", "2022/03/08 00:35", "2022/03/08 00:45"},
1812         {"M 29", "2022/03/08 00:46", "2022/03/08 00:57"},
1813         {"M 5",  "2022/03/08 00:58", "2022/03/08 01:08"},
1814         {"M 12", "2022/03/08 01:09", "2022/03/08 01:20"},
1815         {"M 27", "2022/03/08 01:23", "2022/03/08 01:34"},
1816         {"M 10", "2022/03/08 01:35", "2022/03/08 01:45"},
1817         {"M 14", "2022/03/08 01:46", "2022/03/08 01:57"},
1818         {"M 4",  "2022/03/08 02:04", "2022/03/08 02:14"},
1819         {"M 9",  "2022/03/08 02:15", "2022/03/08 02:26"},
1820         {"M 11", "2022/03/08 02:39", "2022/03/08 02:49"},
1821         {"M 19", "2022/03/08 02:50", "2022/03/08 03:01"},
1822         {"M 26", "2022/03/08 03:02", "2022/03/08 03:12"},
1823         {"M 16", "2022/03/08 03:13", "2022/03/08 03:24"},
1824         {"M 23", "2022/03/08 03:25", "2022/03/08 03:35"},
1825         {"M 17", "2022/03/08 03:36", "2022/03/08 03:47"},
1826         {"M 15", "2022/03/08 03:50", "2022/03/08 04:01"},
1827         {"M 18", "2022/03/08 04:02", "2022/03/08 04:12"},
1828         {"M 24", "2022/03/08 04:13", "2022/03/08 04:24"},
1829         {"M 21", "2022/03/08 04:25", "2022/03/08 04:35"},
1830         {"M 20", "2022/03/08 04:36", "2022/03/08 04:47"},
1831         {"M 2",  "2022/03/08 04:56", "2022/03/08 05:04"},
1832         {"M 25", "2022/03/09 03:21", "2022/03/09 03:32"},
1833         {"M 8",  "2022/03/09 03:33", "2022/03/09 03:43"},
1834         {"M 28", "2022/03/09 03:49", "2022/03/09 04:00"},
1835         {"M 6",  "2022/03/09 04:03", "2022/03/09 04:14"},
1836         {"M 22", "2022/03/09 04:15", "2022/03/09 04:25"},
1837         {"M 2",  "2022/03/09 04:51", "2022/03/09 04:54"},
1838         {"M 7",  "2022/03/09 04:55", "2022/03/09 05:01"}
1839     };
1840     QVERIFY(checkSchedule(scheduleMinAlt0, scheduler->process()->getGreedyScheduler()->getSchedule(), 300));
1841 
1842     qCInfo(KSTARS_EKOS_TEST) << QString("Calculate schedule with no artificial horizon and 30 min altitude.");
1843     Ekos::SchedulerJob::setHorizon(nullptr);
1844     scheduler->load(true, QString("file://%1").arg(esl30Path));
1845     const QVector<SPlan> scheduleMinAlt30 =
1846     {
1847         {"M 1",  "2022/03/07 22:28", "2022/03/07 22:39"},
1848         {"M 35", "2022/03/07 22:40", "2022/03/07 22:50"},
1849         {"M 38", "2022/03/07 22:51", "2022/03/07 23:02"},
1850         {"M 36", "2022/03/07 23:03", "2022/03/07 23:13"},
1851         {"M 40", "2022/03/07 23:14", "2022/03/07 23:25"},
1852         {"M 3",  "2022/03/07 23:26", "2022/03/07 23:36"},
1853         {"M 13", "2022/03/08 00:23", "2022/03/08 00:34"},
1854         {"M 5",  "2022/03/08 01:43", "2022/03/08 01:53"},
1855         {"M 12", "2022/03/08 03:40", "2022/03/08 03:51"},
1856         {"M 29", "2022/03/08 03:56", "2022/03/08 04:06"},
1857         {"M 39", "2022/03/08 04:15", "2022/03/08 04:26"},
1858         {"M 10", "2022/03/08 04:27", "2022/03/08 04:37"},
1859         {"M 27", "2022/03/08 04:38", "2022/03/08 04:49"},
1860         {"M 14", "2022/03/08 04:50", "2022/03/08 05:00"},
1861         {"M 34", "2022/03/08 20:03", "2022/03/08 20:14"}
1862     };
1863     QVERIFY(checkSchedule(scheduleMinAlt30, scheduler->process()->getGreedyScheduler()->getSchedule(), 300));
1864     // TODO: verify this test data.
1865 
1866     // The timing was affected by calculating horizon constraints.
1867     // Measure the time with and without a realistic artificial horizon.
1868     ArtificialHorizon largeHorizon;
1869     addHorizonConstraint(
1870         &largeHorizon, "h", true, QVector<double>(
1871     {
1872         // vector of azimuths
1873         67.623611, 71.494167, 73.817778, 75.726667, 77.536944, 79.640000, 81.505278, 82.337778, 83.820000, 84.479444,
1874         86.375556, 89.347500, 91.982500, 93.771667, 95.124722, 95.747778, 97.303889, 100.735278, 104.573611, 106.721389,
1875         108.360278, 110.640833, 111.963611, 114.940556, 116.497500, 118.858611, 119.981389, 122.832500, 124.695278, 125.882778,
1876         127.580278, 129.888889, 130.668333, 132.550833, 133.389167, 133.892222, 138.481111, 139.192778, 140.057500, 141.234722,
1877         142.308333, 144.151944, 145.714167, 146.290833, 149.275278, 151.138056, 152.107500, 153.526389, 154.321667, 155.640000,
1878         156.685833, 156.302778, 157.421667, 160.331389, 161.091389, 160.952778, 161.975556, 162.564167, 164.866944, 166.906389,
1879         167.750000, 167.782778, 169.212500, 170.241944, 170.642500, 172.948056, 174.382778, 174.738333, 175.333056, 175.878889,
1880         177.345000, 178.390278, 177.411111, 180.062500, 177.540278, 177.981111, 179.459444, 180.363056, 182.301667, 184.176111,
1881         185.036944, 188.303611, 190.110833, 191.809444, 196.293889, 197.398889, 196.634722, 196.238889, 198.553056, 199.896389,
1882         205.868333, 207.224722, 231.645278, 258.324167, 277.260833, 292.470833, 302.961111, 308.996389, 309.027500, 312.311667,
1883         313.423333, 316.827500, 316.471111, 322.656944, 329.775278, 330.606944, 333.355278, 340.709167, 342.927222, 344.010000,
1884         345.696389, 347.886111, 349.058611, 351.998889, 353.010278, 357.548611, 359.510278, 359.320278, 363.102500, 369.171389,
1885         371.129444, 372.717778, 375.897500, 379.531944, 380.118333, 383.015278, 385.493333, 32.556944, 35.456667, 35.773889,
1886         38.304167, 43.844722, 52.575556, 55.080000, 57.086667, 67.523333, 68.458056}),
1887     QVector<double>(
1888     {
1889         // vector of altitudes
1890         22.721111, 21.776944, 20.672222, 25.656667,
1891         27.865000, 29.283889, 29.107778, 27.240556, 26.704722, 28.126111, 28.722222, 29.286111, 28.931944, 26.810000,
1892         24.275278, 21.858333, 20.081944, 20.608333, 21.693056, 22.915833, 26.003333, 29.119167, 28.771667, 24.334444,
1893         22.909444, 21.960278, 20.691389, 24.635000, 24.556667, 22.429167, 24.333056, 24.526667, 24.417222, 26.273611,
1894         25.870833, 24.805556, 27.208333, 29.074167, 31.087500, 30.648333, 28.023889, 27.469722, 27.212222, 26.296667,
1895         24.987222, 23.888333, 25.336667, 25.510556, 24.482222, 24.494444, 23.861667, 22.288889, 19.551667, 20.260278,
1896         21.554722, 22.983056, 24.001111, 27.154722, 28.523056, 27.060278, 24.611944, 22.693333, 23.140833, 22.666389,
1897         20.984722, 27.248611, 27.015000, 24.659444, 23.478611, 21.943056, 20.511389, 21.130000, 23.937778, 23.787778,
1898         29.003056, 35.778056, 37.504167, 41.471111, 43.260556, 42.360833, 39.985000, 31.131389, 32.261111, 31.051944,
1899         32.915000, 31.470000, 30.330556, 29.764722, 28.692500, 24.937222, 24.907222, 41.375556, 44.511389, 43.528611,
1900         38.659722, 33.497500, 27.334444, 24.551667, 21.920278, 22.558333, 27.221111, 27.113611, 30.526667, 31.531667,
1901         25.062778, 27.808889, 24.439167, 23.505278, 22.182222, 23.534444, 22.537778, 23.492222, 22.485000, 23.643611,
1902         25.677222, 23.192778, 19.320556, 16.915556, 18.304444, 18.866389, 18.523889, 23.704167, 24.008611, 25.070000,
1903         28.784444, 30.421389, 35.479444, 33.950833, 35.842778, 34.506667, 34.424722, 28.031944, 30.806389, 29.865833,
1904         22.579722, 22.644444, 22.672222}));
1905 
1906     qCInfo(KSTARS_EKOS_TEST) << QString("Calculate schedule with large artificial horizon.");
1907     Ekos::SchedulerJob::setHorizon(&largeHorizon);
1908     scheduler->load(true, QString("file://%1").arg(esl30Path));
1909     const QVector<SPlan> scheduleAHMinAlt30 =
1910     {
1911         {"M 35", "2022/03/07 22:28", "2022/03/07 22:39"},
1912         {"M 38", "2022/03/07 22:40", "2022/03/07 22:50"},
1913         {"M 36", "2022/03/07 22:51", "2022/03/07 23:02"},
1914         {"M 40", "2022/03/07 23:03", "2022/03/07 23:13"},
1915         {"M 3",  "2022/03/07 23:14", "2022/03/07 23:25"},
1916         {"M 13", "2022/03/08 00:24", "2022/03/08 00:34"},
1917         {"M 5",  "2022/03/08 01:43", "2022/03/08 01:54"},
1918         {"M 12", "2022/03/08 03:41", "2022/03/08 03:51"},
1919         {"M 29", "2022/03/08 03:56", "2022/03/08 04:07"},
1920         {"M 39", "2022/03/08 04:16", "2022/03/08 04:26"},
1921         {"M 10", "2022/03/08 04:27", "2022/03/08 04:38"},
1922         {"M 27", "2022/03/08 04:39", "2022/03/08 04:49"},
1923         {"M 14", "2022/03/08 04:50", "2022/03/08 05:01"},
1924         {"M 34", "2022/03/08 20:03", "2022/03/08 20:13"},
1925         {"M 1",  "2022/03/08 20:14", "2022/03/08 20:25"}
1926     };
1927     QVERIFY(checkSchedule(scheduleAHMinAlt30, scheduler->process()->getGreedyScheduler()->getSchedule(), 300));
1928     // TODO: verify this test data.
1929 
1930 }
1931 
1932 void TestEkosSchedulerOps::prepareTestData(QList<QString> locationList, QList<QString> targetList)
1933 {
1934 #if QT_VERSION < QT_VERSION_CHECK(5,9,0)
1935     QSKIP("Bypassing fixture test on old Qt");
1936     Q_UNUSED(locationList)
1937 #else
1938     QTest::addColumn<QString>("location"); /*!< location the KStars test is running */
1939     QTest::addColumn<QString>("target");   /*!< scheduled target */
1940     for (QString location : locationList)
1941         for (QString target : targetList)
1942             QTest::newRow(QString("loc= \"%1\", target=\"%2\"").arg(location).arg(target).toLocal8Bit())
1943                     << location << target;
1944 #endif
1945 }
1946 
1947 /* *********************************************************************************
1948  *
1949  * Test data
1950  *
1951  * ********************************************************************************* */
1952 void TestEkosSchedulerOps::testCulminationStartup_data()
1953 {
1954     prepareTestData({"Heidelberg", "New York"}, {"Rasalhague"});
1955 }
1956 
1957 void TestEkosSchedulerOps::testRememberJobProgress_data()
1958 {
1959 #if QT_VERSION < QT_VERSION_CHECK(5,9,0)
1960     QSKIP("Bypassing fixture test on old Qt");
1961 #else
1962     QTest::addColumn<QString>("jobs"); /*!< Capture sequences */
1963     QTest::addColumn<QString>("frames"); /*!< Existing frames */
1964     QTest::addColumn<int>("iterations"); /*!< Existing frames */
1965     QTest::addColumn<bool>("scheduled"); /*!< Expected result: scheduled (true) or completed (false) */
1966 
1967     QTest::newRow("{Red:2}, scheduled=true") << "Red:2" << "Red:1" << 1 << true;
1968     QTest::newRow("{Red:2}, scheduled=false") << "Red:2" << "Red:2" << 1 << false;
1969     QTest::newRow("{Red:2, Red:1}, scheduled=true") << "Red:2, Red:1" << "Red:2" << 1 << true;
1970     QTest::newRow("{Red:2, Green:1, Red:1}, scheduled=true") << "Red:2, Green:1, Red:1" << "Red:4" << 1 << true;
1971     QTest::newRow("{Red:3, Green:1, Red:2}, 3x, scheduled=true") << "Red:3, Green:1, Red:2" << "Red:14, Green:3" << 3 << true;
1972     QTest::newRow("{Red:3, Green:1, Red:2}, 3x, scheduled=false") << "Red:3, Green:1, Red:2" << "Red:15, Green:3" << 3 << false;
1973 
1974 #endif
1975 }
1976 
1977 QTEST_KSTARS_MAIN(TestEkosSchedulerOps)
1978 
1979 #endif // HAVE_INDI
1980 
1981