1 /*
2     SPDX-FileCopyrightText: 2021 Hy Murveit <hy@murveit.com>
3 
4     SPDX-License-Identifier: GPL-2.0-or-later
5 */
6 
7 /*
8  * This file contains unit tests for the scheduler, in particular for the
9  * planning parts--evaluating jobs and setting proposed start/end times for them.
10  */
11 
12 #include "ekos/scheduler/scheduler.h"
13 #include "ekos/scheduler/schedulerjob.h"
14 #include "indi/indiproperty.h"
15 #include "ekos/capture/sequencejob.h"
16 #include "ekos/capture/placeholderpath.h"
17 #include "Options.h"
18 
19 #include <QtTest>
20 #include <memory>
21 
22 #include <QObject>
23 
24 using Ekos::Scheduler;
25 
26 class TestSchedulerUnit : public QObject
27 {
28         Q_OBJECT
29 
30     public:
31         /** @short Constructor */
32         TestSchedulerUnit();
33 
34         /** @short Destructor */
35         ~TestSchedulerUnit() override = default;
36 
37     private slots:
38         void darkSkyScoreTest();
39         void setupGeoAndTimeTest();
40         void setupJobTest_data();
41         void setupJobTest();
42         void loadSequenceQueueTest();
43         void estimateJobTimeTest();
44         void calculateJobScoreTest();
45         void evaluateJobsTest();
46 
47     private:
48         void runSetupJob(SchedulerJob &job,
49                          GeoLocation *geo, KStarsDateTime *localTime, const QString &name, int priority,
50                          const dms &ra, const dms &dec, double rotation, const QUrl &sequenceUrl,
51                          const QUrl &fitsUrl, SchedulerJob::StartupCondition sCond, const QDateTime &sTime,
52                          int16_t sOffset, SchedulerJob::CompletionCondition eCond, const QDateTime &eTime, int eReps,
53                          double minAlt, double minMoonSep = 0, bool enforceWeather = false, bool enforceTwilight = true,
54                          bool enforceArtificialHorizon = true, bool track = true, bool focus = true, bool align = true, bool guide = true);
55 };
56 
57 #include "testschedulerunit.moc"
58 
59 // The tests use this latitude/longitude, and happen around this QDateTime.
60 GeoLocation siliconValley(dms(-122, 10), dms(37, 26, 30), "Silicon Valley", "CA", "USA", -7);
61 KStarsDateTime midNight(QDateTime(QDate(2021, 4, 17), QTime(0, 0, 1), QTimeZone(-7 * 3600)));
62 
63 // At the midNight (midnight start of 4/17/2021) a star at the zenith is about
64 // at DEC=37d33'36" ~37.56 degrees, RA=12h32'48" ~ 188.2 degrees
65 // The tests in this file use this RA/DEC.
66 dms midnightRA(188.2), testDEC(37.56);
67 
68 // These altitudes were precomputed for the above GeoLocation and time (midnight), time offset
69 // by 12hours in the vector, one altitude per hour.
70 // That is, altitudes[0] corresponds to -12 hours from midNight.
71 QVector<double> svAltitudes =
72 {
73     -15.11, -13.92, -10.28, -4.53, // 12,1,2,3pm
74         2.94, 11.74, 21.53, 32.05,     // 4,5,6,7pm
75         43.10, 54.53, 66.22, 78.08,    // 8,9,10,11pm
76         89.99,                         // midnight
77         78.07, 66.21, 54.52, 43.09,    // 1,2,3,4am
78         32.04, 21.52, 11.73, 2.94,     // 5,6,7,8am
79         -4.53, -10.29, -13.92, -15.11  // 9,10,11,12
80     };
81 
82 // Used to keep the "correct information" about what's stored in an .esq file
83 // in order to test loadSequenceQueue() and processJobInfo(XML, job) which it calls.
84 struct CaptureJobDetails
85 {
86     QString filter;
87     int count;
88     double exposure;
89     CCDFrameType type;
90 };
91 
92 // This sequence corresponds to the contents of the sequence file 9filters.esq.
93 const QString seqFile9Filters = "9filters.esq";
94 QList<CaptureJobDetails> details9Filters =
95 {
96     {"Luminance", 6,  60.0, FRAME_LIGHT},
97     {"SII",      20,  30.0, FRAME_LIGHT},
98     {"OIII",      7,  20.0, FRAME_LIGHT},
99     {"H_Alpha",   5,  30.0, FRAME_LIGHT},
100     {"Red",       7,  90.0, FRAME_LIGHT},
101     {"Green",     7,  45.0, FRAME_LIGHT},
102     {"Blue",      2, 120.0, FRAME_LIGHT},
103     {"SII",       6,  30.0, FRAME_LIGHT},
104     {"OIII",      6,  10.0, FRAME_LIGHT}
105 };
106 
TestSchedulerUnit()107 TestSchedulerUnit::TestSchedulerUnit() : QObject()
108 {
109     // Remove the dither-enabled option. It adds a complexity to estimating the job time.
110     Options::setDitherEnabled(false);
111 
112     // Remove the setting-altitude-cutoff option.
113     // There's some slight complexity when setting near the altitude constraint.
114     // This is not tested yet.
115     Options::setSettingAltitudeCutoff(0);
116 
117     // Setting this true winds up calling KStarsData::Instance() in the scheduler via SkyPoint::apparentCoord().
118     // Unit tests don't instantiate KStarsData::Instance() and will crash.
119     Options::setUseRelativistic(false);
120 }
121 
122 // Tests that the doubles are within tolerance.
compareFloat(double d1,double d2,double tolerance=.0001)123 bool compareFloat(double d1, double d2, double tolerance = .0001)
124 {
125     return (fabs(d1 - d2) < tolerance);
126 }
127 
128 // Tests that the QDateTimes are within the tolerance in seconds.
compareTimes(const QDateTime & t1,const QDateTime & t2,int toleranceSecs=1)129 bool compareTimes(const QDateTime &t1, const QDateTime &t2, int toleranceSecs = 1)
130 {
131     int toleranceMsecs = toleranceSecs * 1000;
132     if (std::abs(t1.msecsTo(t2)) >= toleranceMsecs)
133     {
134         QWARN(qPrintable(QString("Comparison of %1 with %2 is out of %3s tolerance.").arg(t1.toString()).arg(t2.toString()).arg(
135                              toleranceSecs)));
136         return false;
137     }
138     else return true;
139 }
140 
141 // Test Scheduler::darkSkyScore().
142 // Picks an arbitary dawn and dusk fraction (.25 and .75) and makes sure the dark sky score is
143 // negative between dawn and dusk and not negative elsewhere.
darkSkyScoreTest()144 void TestSchedulerUnit::darkSkyScoreTest()
145 {
146     constexpr double _dawn = .25, _dusk = .75;
147     for (double offset = 0; offset < 1.0; offset += 0.1)
148     {
149         QDateTime t = midNight.addSecs(24 * 3600 * offset);
150         // Scheduler calculating dawn and dusk finds the NEXT dawn and dusks - let's simulate this
151         QDateTime dawn = midNight.addSecs(_dawn * 24.0 * 3600.0);
152         if (dawn < t) dawn = dawn.addDays(1);
153         QDateTime dusk = midNight.addSecs(_dusk * 24.0 * 3600.0);
154         if (dusk < t) dusk = dusk.addDays(1);
155         int16_t score = Scheduler::getDarkSkyScore(dawn, dusk, t);
156         if (offset < _dawn || offset > _dusk)
157             QVERIFY(score >= 0);
158         else
159             QVERIFY(score < 0);
160     }
161 }
162 
163 
164 // The runSetupJob() utility calls the static function Scheduler::setupJob() with all the args passed in
165 // and tests to see that the resulting SchedulerJob object has the values that were requested.
runSetupJob(SchedulerJob & job,GeoLocation * geo,KStarsDateTime * localTime,const QString & name,int priority,const dms & ra,const dms & dec,double rotation,const QUrl & sequenceUrl,const QUrl & fitsUrl,SchedulerJob::StartupCondition sCond,const QDateTime & sTime,int16_t sOffset,SchedulerJob::CompletionCondition eCond,const QDateTime & eTime,int eReps,double minAlt,double minMoonSep,bool enforceWeather,bool enforceTwilight,bool enforceArtificialHorizon,bool track,bool focus,bool align,bool guide)166 void TestSchedulerUnit::runSetupJob(
167     SchedulerJob &job, GeoLocation *geo, KStarsDateTime *localTime, const QString &name, int priority,
168     const dms &ra, const dms &dec, double rotation, const QUrl &sequenceUrl,
169     const QUrl &fitsUrl, SchedulerJob::StartupCondition sCond, const QDateTime &sTime,
170     int16_t sOffset, SchedulerJob::CompletionCondition eCond, const QDateTime &eTime, int eReps,
171     double minAlt, double minMoonSep, bool enforceWeather, bool enforceTwilight,
172     bool enforceArtificialHorizon, bool track, bool focus, bool align, bool guide)
173 {
174     // Setup the time and geo.
175     KStarsDateTime ut = geo->LTtoUT(*localTime);
176     job.setGeo(geo);
177     job.setLocalTime(localTime);
178     QVERIFY(job.hasLocalTime() && job.hasGeo());
179 
180     Scheduler::setupJob(job, name, priority, ra, dec, ut.djd(), rotation,
181                         sequenceUrl, fitsUrl,
182                         sCond, sTime, sOffset,
183                         eCond, eTime, eReps,
184                         minAlt, minMoonSep,
185                         enforceWeather, enforceTwilight, enforceArtificialHorizon,
186                         track, focus, align, guide);
187     QVERIFY(name == job.getName());
188     QVERIFY(priority == job.getPriority());
189     QVERIFY(ra == job.getTargetCoords().ra0());
190     QVERIFY(dec == job.getTargetCoords().dec0());
191     QVERIFY(rotation == job.getRotation());
192     QVERIFY(sequenceUrl == job.getSequenceFile());
193     QVERIFY(fitsUrl == job.getFITSFile());
194     QVERIFY(minAlt == job.getMinAltitude());
195     QVERIFY(minMoonSep == job.getMinMoonSeparation());
196     QVERIFY(enforceWeather == job.getEnforceWeather());
197     QVERIFY(enforceTwilight == job.getEnforceTwilight());
198     QVERIFY(enforceArtificialHorizon == job.getEnforceArtificialHorizon());
199 
200     QVERIFY(sCond == job.getStartupCondition());
201     switch (sCond)
202     {
203         case SchedulerJob::START_AT:
204             QVERIFY(sTime == job.getStartupTime());
205             QVERIFY(0 == job.getCulminationOffset());
206             break;
207         case SchedulerJob::START_CULMINATION:
208             QVERIFY(QDateTime() == job.getStartupTime());
209             QVERIFY(sOffset == job.getCulminationOffset());
210             break;
211         case SchedulerJob::START_ASAP:
212             QVERIFY(QDateTime() == job.getStartupTime());
213             QVERIFY(0 == job.getCulminationOffset());
214             break;
215     }
216 
217     QVERIFY(eCond == job.getCompletionCondition());
218     switch (eCond)
219     {
220         case SchedulerJob::FINISH_AT:
221             QVERIFY(eTime == job.getCompletionTime());
222             QVERIFY(0 == job.getRepeatsRequired());
223             QVERIFY(0 == job.getRepeatsRemaining());
224             break;
225         case SchedulerJob::FINISH_REPEAT:
226             QVERIFY(QDateTime() == job.getCompletionTime());
227             QVERIFY(eReps == job.getRepeatsRequired());
228             QVERIFY(eReps == job.getRepeatsRemaining());
229             break;
230         case SchedulerJob::FINISH_SEQUENCE:
231             QVERIFY(QDateTime() == job.getCompletionTime());
232             QVERIFY(1 == job.getRepeatsRequired());
233             QVERIFY(1 == job.getRepeatsRemaining());
234             break;
235         case SchedulerJob::FINISH_LOOP:
236             QVERIFY(QDateTime() == job.getCompletionTime());
237             QVERIFY(0 == job.getRepeatsRequired());
238             QVERIFY(0 == job.getRepeatsRemaining());
239             break;
240     }
241 
242     SchedulerJob::StepPipeline pipe = job.getStepPipeline();
243     QVERIFY((track && (pipe & SchedulerJob::USE_TRACK)) || (!track && !(pipe & SchedulerJob::USE_TRACK)));
244     QVERIFY((focus && (pipe & SchedulerJob::USE_FOCUS)) || (!focus && !(pipe & SchedulerJob::USE_FOCUS)));
245     QVERIFY((align && (pipe & SchedulerJob::USE_ALIGN)) || (!align && !(pipe & SchedulerJob::USE_ALIGN)));
246     QVERIFY((guide && (pipe & SchedulerJob::USE_GUIDE)) || (!guide && !(pipe & SchedulerJob::USE_GUIDE)));
247 }
248 
setupGeoAndTimeTest()249 void TestSchedulerUnit::setupGeoAndTimeTest()
250 {
251     SchedulerJob job(nullptr);
252     QVERIFY(!job.hasLocalTime() && !job.hasGeo());
253     job.setGeo(&siliconValley);
254     job.setLocalTime(&midNight);
255     QVERIFY(job.hasLocalTime() && job.hasGeo());
256     QVERIFY(job.getGeo()->lat() == siliconValley.lat());
257     QVERIFY(job.getGeo()->lng() == siliconValley.lng());
258     QVERIFY(job.getLocalTime() == midNight);
259 }
260 
261 Q_DECLARE_METATYPE(SchedulerJob::StartupCondition);
262 Q_DECLARE_METATYPE(SchedulerJob::CompletionCondition);
263 
264 // Test Scheduler::setupJob().
265 // Calls runSetupJob (which calls SchedulerJob::setupJob(...)) in a few different ways
266 // to test different kinds of SchedulerJob initializations.
setupJobTest_data()267 void TestSchedulerUnit::setupJobTest_data()
268 {
269     QTest::addColumn<SchedulerJob::StartupCondition>("START_CONDITION");
270     QTest::addColumn<QDateTime>("START_TIME");
271     QTest::addColumn<int>("START_OFFSET");
272     QTest::addColumn<SchedulerJob::CompletionCondition>("END_CONDITION");
273     QTest::addColumn<QDateTime>("END_TIME");
274     QTest::addColumn<int>("REPEATS");
275     QTest::addColumn<bool>("ENFORCE_WEATHER");
276     QTest::addColumn<bool>("ENFORCE_TWILIGHT");
277     QTest::addColumn<bool>("ENFORCE_ARTIFICIAL_HORIZON");
278     QTest::addColumn<bool>("TRACK");
279     QTest::addColumn<bool>("FOCUS");
280     QTest::addColumn<bool>("ALIGN");
281     QTest::addColumn<bool>("GUIDE");
282 
283     QTest::newRow("ASAP_TO_FINISH")
284             << SchedulerJob::START_ASAP << QDateTime() << 0 // start conditions
285             << SchedulerJob::FINISH_SEQUENCE << QDateTime() << 1 // end conditions
286             << false  // enforce weather
287             << true   // enforce twilight
288             << true   // enforce artificial horizon
289             << false  // track
290             << true   // focus
291             << true   // align
292             << true;  // guide
293 
294     QTest::newRow("START_AT_FINISH_AT")
295             << SchedulerJob::START_AT // start conditions
296             << QDateTime(QDate(2021, 4, 17), QTime(0, 1, 0), QTimeZone(-7 * 3600))
297             << 0
298             << SchedulerJob::FINISH_AT // end conditions
299             << QDateTime(QDate(2021, 4, 17), QTime(0, 2, 0), QTimeZone(-7 * 3600))
300             << 1
301             << true   // enforce weather
302             << false  // enforce twilight
303             << true   // enforce artificial horizon
304             << true   // track
305             << false  // focus
306             << true   // align
307             << true;  // guide
308 
309     QTest::newRow("CULMINATION_TO_REPEAT")
310             << SchedulerJob::START_CULMINATION << QDateTime() << 60 // start conditions
311             << SchedulerJob::FINISH_REPEAT << QDateTime() << 3 // end conditions
312             << true   // enforce weather
313             << true   // enforce twilight
314             << true   // enforce artificial horizon
315             << true   // track
316             << true   // focus
317             << false  // align
318             << true;  // guide
319 
320     QTest::newRow("ASAP_TO_LOOP")
321             << SchedulerJob::START_ASAP << QDateTime() << 0 // start conditions
322             << SchedulerJob::FINISH_SEQUENCE << QDateTime() << 1 // end conditions
323             << false  // enforce weather
324             << false  // enforce twilight
325             << true   // enforce artificial horizon
326             << true   // track
327             << true   // focus
328             << true   // align
329             << false; // guide
330 }
331 
setupJobTest()332 void TestSchedulerUnit::setupJobTest()
333 {
334     QFETCH(SchedulerJob::StartupCondition, START_CONDITION);
335     QFETCH(QDateTime, START_TIME);
336     QFETCH(int, START_OFFSET);
337     QFETCH(SchedulerJob::CompletionCondition, END_CONDITION);
338     QFETCH(QDateTime, END_TIME);
339     QFETCH(int, REPEATS);
340     QFETCH(bool, ENFORCE_WEATHER);
341     QFETCH(bool, ENFORCE_TWILIGHT);
342     QFETCH(bool, ENFORCE_ARTIFICIAL_HORIZON);
343     QFETCH(bool, TRACK);
344     QFETCH(bool, FOCUS);
345     QFETCH(bool, ALIGN);
346     QFETCH(bool, GUIDE);
347 
348     SchedulerJob job(nullptr);
349     runSetupJob(job, &siliconValley, &midNight, "Job1", 10,
350                 midnightRA, testDEC, 5.0, QUrl("1"), QUrl("2"),
351                 START_CONDITION, START_TIME, START_OFFSET,
352                 END_CONDITION, END_TIME, REPEATS,
353                 30.0, 5.0, ENFORCE_WEATHER, ENFORCE_TWILIGHT,
354                 ENFORCE_ARTIFICIAL_HORIZON, TRACK, FOCUS, ALIGN, GUIDE);
355 }
356 
357 namespace
358 {
359 // compareCaptureSequeuce() is a utility to use the CaptureJobDetails structure as a truth value
360 // to see if the capture sequeuce was loaded properly.
compareCaptureSequence(const QList<CaptureJobDetails> & details,const QList<Ekos::SequenceJob * > & jobs)361 void compareCaptureSequence(const QList<CaptureJobDetails> &details, const QList<Ekos::SequenceJob *> &jobs)
362 {
363     QVERIFY(details.size() == jobs.size());
364     for (int i = 0; i < jobs.size(); ++i)
365     {
366         QVERIFY(details[i].filter == jobs[i]->getFilterName());
367         QVERIFY(details[i].count == jobs[i]->getCount());
368         QVERIFY(details[i].exposure == jobs[i]->getExposure());
369         QVERIFY(details[i].type == jobs[i]->getFrameType());
370     }
371 }
372 }  // namespace
373 
374 // Test Scheduler::loadSequeuceQueue().
375 // Load sequenceQueue doesn't load all details of the sequence. It just loads what it
376 // needs to compute a duration estimate for the job.
377 // Hence, compareCaptureSequence just tests a few things that a capture sequence can contain.
378 // A full test for capture sequences should be written in capture testing.
loadSequenceQueueTest()379 void TestSchedulerUnit::loadSequenceQueueTest()
380 {
381     // Create a new SchedulerJob and pass in a null moon pointer.
382     SchedulerJob schedJob(nullptr);
383 
384     QList<Ekos::SequenceJob *> jobs;
385     bool hasAutoFocus = false;
386     // Read in the 9 filters file.
387     // The last arg is for logging. Use nullptr for testing.
388     QVERIFY(Scheduler::loadSequenceQueue(seqFile9Filters, &schedJob, jobs, hasAutoFocus, nullptr));
389     // Makes sure we have the basic details of the capture sequence were read properly.
390     compareCaptureSequence(details9Filters, jobs);
391 }
392 
393 namespace
394 {
395 // This utility computes the sum of time taken for all exposures in a capture sequence.
computeExposureDurations(const QList<CaptureJobDetails> & details)396 double computeExposureDurations(const QList<CaptureJobDetails> &details)
397 {
398     double sum = 0;
399     for (int i = 0; i < details.size(); ++i)
400         sum += details[i].count * details[i].exposure;
401     return sum;
402 }
403 }  // namespace
404 
405 // Test Scheduler::estimateJobTime(). Tests the estimates of job completion time.
406 // Focuses on the non-heuristic aspects (e.g. sum of num_exposures * exposure_duration for all the
407 // jobs in the capture sequence).
estimateJobTimeTest()408 void TestSchedulerUnit::estimateJobTimeTest()
409 {
410     // Some computations use the local time, which is normally taken from KStars::Instance()->lt()
411     // unless this is set. The Instance does not exist when running this unit test.
412     Scheduler::setLocalTime(&midNight);
413 
414     // First test, start ASAP and finish when the sequence is done.
415     SchedulerJob job(nullptr);
416     runSetupJob(job, &siliconValley, &midNight, "Job1", 10,
417                 midnightRA, testDEC, 5.0,
418                 QUrl(QString("file:%1").arg(seqFile9Filters)), QUrl(""),
419                 SchedulerJob::START_ASAP, QDateTime(), 0,
420                 SchedulerJob::FINISH_SEQUENCE, QDateTime(), 1,
421                 30.0, 5.0, false, false);
422 
423     // Initial map has no previous captures.
424     QMap<QString, uint16_t> capturedFramesCount;
425     QVERIFY(Scheduler::estimateJobTime(&job, capturedFramesCount, nullptr));
426 
427     // The time estimate is essentially the sum of (exposure times * the number of exposures) for each filter.
428     // There are other heuristics added to take initial track, focus, align & guide into account.
429     // We're not testing these heuristics here, so they are set to false in the job setup above (last 4 bools).
430     const double exposureDuration = computeExposureDurations(details9Filters);
431     const int overhead = Scheduler::timeHeuristics(&job);
432     QVERIFY(compareFloat(exposureDuration + overhead, job.getEstimatedTime()));
433 
434     // Repeat the above test, but repeat the sequence 1,2,3,4,...,10 times.
435     for (int i = 1; i <= 10; ++i)
436     {
437         job.setCompletionCondition(SchedulerJob::FINISH_REPEAT);
438         job.setRepeatsRequired(i);
439         QVERIFY(Scheduler::estimateJobTime(&job, capturedFramesCount, nullptr));
440         QVERIFY(compareFloat(overhead + i * exposureDuration, job.getEstimatedTime()));
441     }
442 
443     // Resetting the number of repeats. This has a side-effect of changing the completion condition,
444     // so we must change completion condition below.
445     job.setRepeatsRequired(1);
446 
447     // Test again, this time looping indefinitely.
448     // In this case the scheduler should estimate negative completion time, as the sequence doesn't
449     // end until the user stops it (or it is interrupted by altitude or daylight). The scheduler
450     // doesn't estimate those stopping conditions at this point.
451     job.setCompletionCondition(SchedulerJob::FINISH_LOOP);
452     QVERIFY(Scheduler::estimateJobTime(&job, capturedFramesCount, nullptr));
453     QVERIFY(job.getEstimatedTime() < 0);
454 
455     // Test again with a fixed end time. The scheduler estimates the time from "now" until the end time.
456     // Perhaps it should estimate the max of that and the FINISH_SEQUENCE time??
457     job.setCompletionCondition(SchedulerJob::FINISH_AT);
458     KStarsDateTime stopTime(QDateTime(QDate(2021, 4, 17), QTime(1, 0, 0), QTimeZone(-7 * 3600)));
459     job.setCompletionTime(stopTime);
460     QVERIFY(Scheduler::estimateJobTime(&job, capturedFramesCount, nullptr));
461     QVERIFY(midNight.secsTo(stopTime) == job.getEstimatedTime());
462 
463     // Test again, similar to above but given a START_AT time as well.
464     // Now it should return the interval between the start and end times.
465     // Again, perhaps that should be max'd with the FINISH_SEQUENCE time?
466     job.setStartupCondition(SchedulerJob::START_AT);
467     job.setStartupTime(midNight.addSecs(1800));
468     QVERIFY(Scheduler::estimateJobTime(&job, capturedFramesCount, nullptr));
469     QVERIFY(midNight.secsTo(stopTime) - 1800 == job.getEstimatedTime());
470 
471     // Small test of accounting for already completed captures.
472     // 1. Explicitly load the capture jobs
473     job.setStartupCondition(SchedulerJob::START_ASAP);
474     job.setCompletionCondition(SchedulerJob::FINISH_SEQUENCE);
475     QList<Ekos::SequenceJob *> jobs;
476     bool hasAutoFocus = false;
477     // The last arg is for logging. Use nullptr for testing.
478     QVERIFY(Scheduler::loadSequenceQueue(seqFile9Filters, &job, jobs, hasAutoFocus, nullptr));
479     // 2. Get the signiture of the first job
480     QString sig0 = jobs[0]->getSignature();
481     // 3. The first job has 6 exposures, each of 20s duration. Set it up that 2 are already done.
482     capturedFramesCount[sig0] = 2;
483     QVERIFY(Scheduler::estimateJobTime(&job, capturedFramesCount, nullptr));
484     // 4. Now expect that we have 2*60s=120s less job time, but only if we're remembering job progress.
485     // First don't remember job progress, and the scheduler should provide the standard estimate.
486     Options::setRememberJobProgress(false);
487     QVERIFY(Scheduler::estimateJobTime(&job, capturedFramesCount, nullptr));
488     QVERIFY(compareFloat(overhead + exposureDuration, job.getEstimatedTime()));
489     // Next remember the progress, the job should reduce the estimate by 120s (the 2 completed exposures).
490     Options::setRememberJobProgress(true);
491     QVERIFY(Scheduler::estimateJobTime(&job, capturedFramesCount, nullptr));
492     QVERIFY(compareFloat(overhead + exposureDuration - 120, job.getEstimatedTime()));
493 }
494 
495 // Test Scheduler::calculateJobScore() and SchedulerJob::getAltitudeScore().
calculateJobScoreTest()496 void TestSchedulerUnit::calculateJobScoreTest()
497 {
498     // Some computations use the local time, which is taken from KStars::Instance()->lt()
499     // unless this is set. The Instance does not exist when running this unit test.
500     Scheduler::setLocalTime(&midNight);
501 
502     // The nullptr is moon pointer. Not currently tested.
503     SchedulerJob job(nullptr);
504 
505     runSetupJob(job, &siliconValley, &midNight, "Job1", 10,
506                 midnightRA, testDEC, 5.0,
507                 QUrl(QString("file:%1").arg(seqFile9Filters)), QUrl(""),
508                 SchedulerJob::START_ASAP, QDateTime(), 0,
509                 SchedulerJob::FINISH_SEQUENCE, QDateTime(), 1,
510                 30.0);
511 
512     // You can't get a job score until estimateJobTime has been called.
513     QMap<QString, uint16_t> capturedFramesCount;
514     QVERIFY(Scheduler::estimateJobTime(&job, capturedFramesCount, nullptr));
515 
516     // These scores were pre-computed for the svAltitudes from the SiliconValley GeoLocation
517     // above at RA,DEC = midnightRA, testDEC, with the time offset by 12.
518     // That is, altScores[0] corresponds to -12 hours from midNight.
519     QVector<int> altScores =
520     {
521         -1000, -1000, -1000, -1000, 0, 1, 3, 8, 16, 34, 69, 140, 282, 140,
522             69, 34, 16, 8, 3, 1, 0, -1000, -1000, -1000, -1000,
523         };
524 
525     // Loops checking the score calculations for different altitudes.
526     for (int hours = -12; hours <= 12; hours++)
527     {
528         const auto time = midNight.addSecs(hours * 3600);
529         double alt;
530         job.setMinAltitude(0);
531         // Check the expected altitude and altitude score, with minAltitude = 0.
532         int16_t altScore = job.getAltitudeScore(midNight.addSecs(hours * 3600), &alt);
533         QVERIFY(altScore == altScores[hours + 12]);
534         QVERIFY(compareFloat(alt, svAltitudes[hours + 12], .1));
535 
536         // Vary minAltitude and re-check the altitude and other scores.
537         for (double altConstraint = -30; altConstraint < 60; altConstraint += 12)
538         {
539             job.setMinAltitude(altConstraint);
540             altScore = job.getAltitudeScore(time, &alt);
541             if (alt < altConstraint)
542                 QVERIFY(altScore == -1000);
543             else
544                 QVERIFY(altScore == altScores[hours + 12]);
545 
546             const int moonScore = 100;
547             const double _dawn = .25, _dusk = .75;
548             QDateTime const dawn = midNight.addSecs(_dawn * 24.0 * 3600.0);
549             QDateTime const dusk = midNight.addSecs(_dusk * 24.0 * 3600.0);
550             int darkScore = Scheduler::getDarkSkyScore(dawn, dusk, time);
551 
552             job.setEnforceTwilight(true);
553             int16_t overallScore = Scheduler::calculateJobScore(&job, dawn, dusk, time);
554 
555             // The overall score is a combination of:
556             // - the altitude score,
557             // - the darkSkyScore (if enforcing twilight)
558             // - the moon separation score (not yet tested, and disabled here).
559             QVERIFY((overallScore == altScore + darkScore + moonScore) ||
560                     ((overallScore == darkScore) && (darkScore < 0)) ||
561                     ((overallScore == darkScore + altScore) && (darkScore + altScore < 0)));
562 
563             job.setEnforceTwilight(false);
564             darkScore = 0;
565             overallScore = Scheduler::calculateJobScore(&job, dawn, dusk, time);
566             QVERIFY((overallScore == altScore + moonScore) ||
567                     ((overallScore == altScore) && (altScore < 0)));
568         }
569     }
570 }
571 
572 // Test Scheduler::evaluateJobs().
evaluateJobsTest()573 void TestSchedulerUnit::evaluateJobsTest()
574 {
575     auto now = midNight;
576     Scheduler::setLocalTime(&now);
577     // The nullptr is moon pointer. Not currently tested.
578     SchedulerJob job(nullptr);
579 
580     const double _dawn = .25, _dusk = .75;
581     QDateTime const dawn = midNight.addSecs(_dawn * 24.0 * 3600.0);
582     QDateTime const dusk = midNight.addSecs(_dusk * 24.0 * 3600.0);
583     const bool rescheduleErrors = true;
584     const bool restart = true;
585     bool possiblyDelay = true;
586     const QMap<QString, uint16_t> capturedFrames;
587     QList<SchedulerJob *> jobs;
588     const Ekos::SchedulerState state = Ekos::SCHEDULER_IDLE;
589     QList<SchedulerJob *> sortedJobs;
590     const double minAltitude = 30.0;
591 
592     // Test 1: Evaluating an empty jobs list should return an empty list.
593     sortedJobs = Scheduler::evaluateJobs(jobs, state, capturedFrames,  dawn, dusk,
594                                          rescheduleErrors, restart, &possiblyDelay, nullptr);
595     QVERIFY(sortedJobs.empty());
596 
597     // Test 2: Add one job to the list.
598     // It should be on the list, and scheduled starting right away (there are no conflicting constraints)
599     // and ending at the estimated completion interval after "now" .
600     runSetupJob(job, &siliconValley, &midNight, "Job1", 10,
601                 midnightRA, testDEC, 0.0,
602                 QUrl(QString("file:%1").arg(seqFile9Filters)), QUrl(""),
603                 SchedulerJob::START_ASAP, QDateTime(), 0,
604                 SchedulerJob::FINISH_SEQUENCE, QDateTime(), 1,
605                 minAltitude);
606     jobs.append(&job);
607     sortedJobs = Scheduler::evaluateJobs(jobs, state, capturedFrames,  dawn, dusk,
608                                          rescheduleErrors, restart, &possiblyDelay, nullptr);
609     // Should have the one same job on both lists.
610     QVERIFY(sortedJobs.size() == 1);
611     QVERIFY(jobs[0] == sortedJobs[0]);
612     QVERIFY(jobs[0] == &job);
613     // The job should start now.
614     QVERIFY(job.getStartupTime() == now);
615     // It should finish when its exposures are done.
616     QVERIFY(compareTimes(job.getCompletionTime(),
617                          now.addSecs(Scheduler::timeHeuristics(&job) +
618                                      computeExposureDurations(details9Filters))));
619 
620     Scheduler::calculateDawnDusk();
621 
622     // The job should run inside the twilight interval and have the same twilight values as Scheduler current values
623     QVERIFY(job.runsDuringAstronomicalNightTime());
624     QVERIFY(job.getDawnAstronomicalTwilight() == Scheduler::Dawn);
625     QVERIFY(job.getDuskAstronomicalTwilight() == Scheduler::Dusk);
626 
627     // The job can start now, thus the next events are dawn, then dusk
628     QVERIFY(Scheduler::Dawn <= Scheduler::Dusk);
629 
630     jobs.clear();
631     sortedJobs.clear();
632 
633     // Test 3: In this case, there are two jobs.
634     // The first must wait for to get above the min altitude (which is set to 80-degrees).
635     // The second one has no constraints, but is scheduled after the first.
636 
637     // Start the scheduler at 8pm but minAltitude won't be reached until after 11pm.
638     // Job repetition takes about 45 minutes plus a little overhead.
639     // Thus, first job, with two repetitions will be scheduled 11:10pm --> 12:43am.
640     SchedulerJob job1(nullptr);
641     auto localTime8pm = midNight.addSecs(-4 * 3600);
642     Scheduler::setLocalTime(&localTime8pm);
643     runSetupJob(job1, &siliconValley, &localTime8pm, "Job1", 10,
644                 midnightRA, testDEC, 0.0,
645                 QUrl(QString("file:%1").arg(seqFile9Filters)), QUrl(""),
646                 SchedulerJob::START_ASAP, QDateTime(), 0,
647                 SchedulerJob::FINISH_REPEAT, QDateTime(), 2,
648                 80.0);
649     jobs.append(&job1);
650 
651     // The second job has no blocking constraints, but will be scheduled after
652     // the first one. Thus it should get scheduled to start 5 minutes after the first
653     // finishes, or at 12:48am lasting about 45 minutes --> 1:36am.
654     SchedulerJob job2(nullptr);
655     runSetupJob(job2, &siliconValley, &localTime8pm, "Job2", 10,
656                 midnightRA, testDEC, 0.0,
657                 QUrl(QString("file:%1").arg(seqFile9Filters)), QUrl(""),
658                 SchedulerJob::START_ASAP, QDateTime(), 0,
659                 SchedulerJob::FINISH_SEQUENCE, QDateTime(), 1,
660                 30.0);
661     jobs.append(&job2);
662 
663     sortedJobs = Scheduler::evaluateJobs(jobs, state, capturedFrames,  dawn, dusk,
664                                          rescheduleErrors, restart, &possiblyDelay, nullptr);
665 
666     QVERIFY(sortedJobs.size() == 2);
667     QVERIFY(sortedJobs[0] == &job1);
668     QVERIFY(sortedJobs[1] == &job2);
669     QVERIFY(compareTimes(sortedJobs[0]->getStartupTime(), midNight.addSecs(-50 * 60), 300));
670     QVERIFY(compareTimes(sortedJobs[0]->getCompletionTime(), midNight.addSecs(43 * 60), 300));
671 
672     QVERIFY(compareTimes(sortedJobs[1]->getStartupTime(), midNight.addSecs(48 * 60), 300));
673     QVERIFY(compareTimes(sortedJobs[1]->getCompletionTime(), midNight.addSecs(1 * 3600 + 36 * 60), 300));
674 
675     Scheduler::calculateDawnDusk();
676 
677     // The two job should run inside the twilight interval and have the same twilight values as Scheduler current values
678     QVERIFY(sortedJobs[0]->runsDuringAstronomicalNightTime());
679     QVERIFY(sortedJobs[1]->runsDuringAstronomicalNightTime());
680     QVERIFY(sortedJobs[0]->getDawnAstronomicalTwilight() == Scheduler::Dawn);
681     QVERIFY(sortedJobs[1]->getDuskAstronomicalTwilight() == Scheduler::Dusk);
682 
683     // The two job can start now, thus the next events for today are dawn, then dusk
684     QVERIFY(Scheduler::Dawn <= Scheduler::Dusk);
685 
686     jobs.clear();
687     sortedJobs.clear();
688 
689     // Test 4: Similar to above, but the first job estimate will run past dawn as it has 10 repetitions.
690     // In actuality, the planning part of the scheduler doesn't deal with estimating it stopping at dawn,
691     // but would find out at dawn when it evaluates the job and interrupts it (not part of this test).
692     // The second job, therefore, should be initially scheduled to start "tomorrow" just after dusk.
693 
694     // Start the scheduler at 8pm but minAltitude won't be reached until ~11:10pm, and job3 estimated conclusion is 6:39am.
695     SchedulerJob job3(nullptr);
696     Scheduler::setLocalTime(&localTime8pm);
697     runSetupJob(job3, &siliconValley, &localTime8pm, "Job1", 10,
698                 midnightRA, testDEC, 0.0,
699                 QUrl(QString("file:%1").arg(seqFile9Filters)), QUrl(""),
700                 SchedulerJob::START_ASAP, QDateTime(), 0,
701                 SchedulerJob::FINISH_REPEAT, QDateTime(), 10,
702                 80.0);
703     jobs.append(&job3);
704 
705     // The second job should be scheduled "tomorrow" starting after dusk
706     SchedulerJob job4(nullptr);
707     runSetupJob(job4, &siliconValley, &localTime8pm, "Job2", 10,
708                 midnightRA, testDEC, 0.0,
709                 QUrl(QString("file:%1").arg(seqFile9Filters)), QUrl(""),
710                 SchedulerJob::START_ASAP, QDateTime(), 0,
711                 SchedulerJob::FINISH_SEQUENCE, QDateTime(), 1,
712                 30.0);
713     jobs.append(&job4);
714 
715     sortedJobs = Scheduler::evaluateJobs(jobs, state, capturedFrames,  dawn, dusk,
716                                          rescheduleErrors, restart, &possiblyDelay, nullptr);
717 
718     QVERIFY(sortedJobs.size() == 2);
719     QVERIFY(sortedJobs[0] == &job3);
720     QVERIFY(sortedJobs[1] == &job4);
721     QVERIFY(compareTimes(sortedJobs[0]->getStartupTime(), midNight.addSecs(-50 * 60), 300));
722     QVERIFY(compareTimes(sortedJobs[0]->getCompletionTime(), midNight.addSecs(6 * 3600 + 39 * 60), 300));
723 
724     QVERIFY(compareTimes(sortedJobs[1]->getStartupTime(), midNight.addSecs(18 * 3600 + 54 * 60), 300));
725     QVERIFY(compareTimes(sortedJobs[1]->getCompletionTime(), midNight.addSecs(19 * 3600 + 44 * 60), 300));
726 
727     jobs.clear();
728     sortedJobs.clear();
729 }
730 
731 QTEST_GUILESS_MAIN(TestSchedulerUnit)
732