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