1 /*  Ekos Scheduler Job
2     SPDX-FileCopyrightText: Jasem Mutlaq <mutlaqja@ikarustech.com>
3 
4     SPDX-License-Identifier: GPL-2.0-or-later
5 */
6 
7 #include "schedulerjob.h"
8 
9 #include "dms.h"
10 #include "artificialhorizoncomponent.h"
11 #include "kstarsdata.h"
12 #include "skymapcomposite.h"
13 #include "Options.h"
14 #include "scheduler.h"
15 #include "ksalmanac.h"
16 
17 #include <knotification.h>
18 
19 #include <QTableWidgetItem>
20 
21 #include <ekos_scheduler_debug.h>
22 
23 #define BAD_SCORE -1000
24 #define MIN_ALTITUDE 15.0
25 
26 
27 GeoLocation *SchedulerJob::storedGeo = nullptr;
28 KStarsDateTime *SchedulerJob::storedLocalTime = nullptr;
29 ArtificialHorizon *SchedulerJob::storedHorizon = nullptr;
30 
jobStatusString(JOBStatus state)31 QString SchedulerJob::jobStatusString(JOBStatus state)
32 {
33     switch(state)
34     {
35         case SchedulerJob::JOB_IDLE:
36             return "JOB_IDLE";
37         case SchedulerJob::JOB_EVALUATION:
38             return "JOB_EVALUATION";
39         case SchedulerJob::JOB_SCHEDULED:
40             return "JOB_SCHEDULED";
41         case SchedulerJob::JOB_BUSY:
42             return "JOB_BUSY";
43         case SchedulerJob::JOB_ERROR:
44             return "JOB_ERROR";
45         case SchedulerJob::JOB_ABORTED:
46             return "JOB_ABORTED";
47         case SchedulerJob::JOB_INVALID:
48             return "JOB_INVALID";
49         case SchedulerJob::JOB_COMPLETE:
50             return "JOB_COMPLETE";
51     }
52     return QString("????");
53 }
54 
jobStageString(JOBStage state)55 QString SchedulerJob::jobStageString(JOBStage state)
56 {
57     switch(state)
58     {
59         case SchedulerJob::STAGE_IDLE:
60             return "STAGE_IDLE";
61         case SchedulerJob::STAGE_SLEWING:
62             return "STAGE_SLEWING";
63         case SchedulerJob::STAGE_SLEW_COMPLETE:
64             return "STAGE_SLEW_COMPLETE";
65         case SchedulerJob::STAGE_FOCUSING:
66             return "STAGE_FOCUSING";
67         case SchedulerJob::STAGE_FOCUS_COMPLETE:
68             return "STAGE_FOCUS_COMPLETE";
69         case SchedulerJob::STAGE_ALIGNING:
70             return "STAGE_ALIGNING";
71         case SchedulerJob::STAGE_ALIGN_COMPLETE:
72             return "STAGE_ALIGN_COMPLETE";
73         case SchedulerJob::STAGE_RESLEWING:
74             return "STAGE_RESLEWING";
75         case SchedulerJob::STAGE_RESLEWING_COMPLETE:
76             return "STAGE_RESLEWING_COMPLETE";
77         case SchedulerJob::STAGE_POSTALIGN_FOCUSING:
78             return "STAGE_POSTALIGN_FOCUSING";
79         case SchedulerJob::STAGE_POSTALIGN_FOCUSING_COMPLETE:
80             return "STAGE_POSTALIGN_FOCUSING_COMPLETE";
81         case SchedulerJob::STAGE_GUIDING:
82             return "STAGE_GUIDING";
83         case SchedulerJob::STAGE_GUIDING_COMPLETE:
84             return "STAGE_GUIDING_COMPLETE";
85         case SchedulerJob::STAGE_CAPTURING:
86             return "STAGE_CAPTURING";
87         case SchedulerJob::STAGE_COMPLETE:
88             return "STAGE_COMPLETE";
89     }
90     return QString("????");
91 }
92 
SchedulerJob()93 SchedulerJob::SchedulerJob()
94 {
95     moon = dynamic_cast<KSMoon *>(KStarsData::Instance()->skyComposite()->findByName(i18n("Moon")));
96 }
97 
98 // Private constructor for unit testing.
SchedulerJob(KSMoon * moonPtr)99 SchedulerJob::SchedulerJob(KSMoon *moonPtr)
100 {
101     moon = moonPtr;
102 }
103 
setName(const QString & value)104 void SchedulerJob::setName(const QString &value)
105 {
106     name = value;
107     updateJobCells();
108 }
109 
getLocalTime()110 KStarsDateTime SchedulerJob::getLocalTime()
111 {
112     if (hasLocalTime())
113         return *storedLocalTime;
114     return getGeo()->UTtoLT(KStarsData::Instance()->clock()->utc());
115 }
116 
getGeo()117 GeoLocation const *SchedulerJob::getGeo()
118 {
119     if (hasGeo())
120         return storedGeo;
121     return KStarsData::Instance()->geo();
122 }
123 
getHorizon()124 ArtificialHorizon const *SchedulerJob::getHorizon()
125 {
126     if (hasHorizon())
127         return storedHorizon;
128     if (KStarsData::Instance() == nullptr || KStarsData::Instance()->skyComposite() == nullptr
129             || KStarsData::Instance()->skyComposite()->artificialHorizon() == nullptr)
130         return nullptr;
131     return &KStarsData::Instance()->skyComposite()->artificialHorizon()->getHorizon();
132 }
133 
setStartupCondition(const StartupCondition & value)134 void SchedulerJob::setStartupCondition(const StartupCondition &value)
135 {
136     startupCondition = value;
137 
138     /* Keep startup time and condition valid */
139     if (value == START_ASAP)
140         startupTime = QDateTime();
141 
142     /* Refresh estimated time - which update job cells */
143     setEstimatedTime(estimatedTime);
144 
145     /* Refresh dawn and dusk for startup date */
146     calculateDawnDusk(startupTime, nextDawn, nextDusk);
147 }
148 
setStartupTime(const QDateTime & value)149 void SchedulerJob::setStartupTime(const QDateTime &value)
150 {
151     startupTime = value;
152 
153     /* Keep startup time and condition valid */
154     if (value.isValid())
155         startupCondition = START_AT;
156     else
157         startupCondition = fileStartupCondition;
158 
159     // Refresh altitude - invalid date/time is taken care of when rendering
160     altitudeAtStartup = findAltitude(targetCoords, startupTime, &isSettingAtStartup);
161 
162     /* Refresh estimated time - which update job cells */
163     setEstimatedTime(estimatedTime);
164 
165     /* Refresh dawn and dusk for startup date */
166     calculateDawnDusk(startupTime, nextDawn, nextDusk);
167 }
168 
setSequenceFile(const QUrl & value)169 void SchedulerJob::setSequenceFile(const QUrl &value)
170 {
171     sequenceFile = value;
172 }
173 
setFITSFile(const QUrl & value)174 void SchedulerJob::setFITSFile(const QUrl &value)
175 {
176     fitsFile = value;
177 }
178 
setMinAltitude(const double & value)179 void SchedulerJob::setMinAltitude(const double &value)
180 {
181     minAltitude = value;
182 }
183 
hasAltitudeConstraint() const184 bool SchedulerJob::hasAltitudeConstraint() const
185 {
186     return hasMinAltitude() ||
187            (enforceArtificialHorizon && (getHorizon() != nullptr) && getHorizon()->altitudeConstraintsExist());
188 }
189 
setMinMoonSeparation(const double & value)190 void SchedulerJob::setMinMoonSeparation(const double &value)
191 {
192     minMoonSeparation = value;
193 }
194 
setEnforceWeather(bool value)195 void SchedulerJob::setEnforceWeather(bool value)
196 {
197     enforceWeather = value;
198 }
199 
setCompletionTime(const QDateTime & value)200 void SchedulerJob::setCompletionTime(const QDateTime &value)
201 {
202     /* If completion time is valid, automatically switch condition to FINISH_AT */
203     if (value.isValid())
204     {
205         setCompletionCondition(FINISH_AT);
206         completionTime = value;
207         altitudeAtCompletion = findAltitude(targetCoords, completionTime, &isSettingAtCompletion);
208         setEstimatedTime(-1);
209     }
210     /* If completion time is invalid, and job is looping, keep completion time undefined */
211     else if (FINISH_LOOP == completionCondition)
212     {
213         completionTime = QDateTime();
214         altitudeAtCompletion = findAltitude(targetCoords, completionTime, &isSettingAtCompletion);
215         setEstimatedTime(-1);
216     }
217     /* If completion time is invalid, deduce completion from startup and duration */
218     else if (startupTime.isValid())
219     {
220         completionTime = startupTime.addSecs(estimatedTime);
221         altitudeAtCompletion = findAltitude(targetCoords, completionTime, &isSettingAtCompletion);
222         updateJobCells();
223     }
224     /* Else just refresh estimated time - which update job cells */
225     else setEstimatedTime(estimatedTime);
226 
227 
228     /* Invariants */
229     Q_ASSERT_X(completionTime.isValid() ?
230                (FINISH_AT == completionCondition || FINISH_REPEAT == completionCondition || FINISH_SEQUENCE == completionCondition) :
231                FINISH_LOOP == completionCondition,
232                __FUNCTION__, "Valid completion time implies job is FINISH_AT/REPEAT/SEQUENCE, else job is FINISH_LOOP.");
233 }
234 
setCompletionCondition(const CompletionCondition & value)235 void SchedulerJob::setCompletionCondition(const CompletionCondition &value)
236 {
237     completionCondition = value;
238 
239     // Update repeats requirement, looping jobs have none
240     switch (completionCondition)
241     {
242         case FINISH_LOOP:
243             setCompletionTime(QDateTime());
244         /* Fall through */
245         case FINISH_AT:
246             if (0 < getRepeatsRequired())
247                 setRepeatsRequired(0);
248             break;
249 
250         case FINISH_SEQUENCE:
251             if (1 != getRepeatsRequired())
252                 setRepeatsRequired(1);
253             break;
254 
255         case FINISH_REPEAT:
256             if (0 == getRepeatsRequired())
257                 setRepeatsRequired(1);
258             break;
259 
260         default:
261             break;
262     }
263 
264     updateJobCells();
265 }
266 
setStepPipeline(const StepPipeline & value)267 void SchedulerJob::setStepPipeline(const StepPipeline &value)
268 {
269     stepPipeline = value;
270 }
271 
setState(const JOBStatus & value)272 void SchedulerJob::setState(const JOBStatus &value)
273 {
274     state = value;
275 
276     /* FIXME: move this to Scheduler, SchedulerJob is mostly a model */
277     if (JOB_ERROR == state)
278         KNotification::event(QLatin1String("EkosSchedulerJobFail"), i18n("Ekos job failed (%1)", getName()));
279 
280     /* If job becomes invalid or idle, automatically reset its startup characteristics, and force its duration to be reestimated */
281     if (JOB_INVALID == value || JOB_IDLE == value)
282     {
283         setStartupCondition(fileStartupCondition);
284         setStartupTime(fileStartupTime);
285         setEstimatedTime(-1);
286     }
287 
288     /* If job is aborted, automatically reset its startup characteristics */
289     if (JOB_ABORTED == value)
290     {
291         setStartupCondition(fileStartupCondition);
292         /* setStartupTime(fileStartupTime); */
293     }
294 
295     updateJobCells();
296 }
297 
setLeadTime(const int64_t & value)298 void SchedulerJob::setLeadTime(const int64_t &value)
299 {
300     leadTime = value;
301     updateJobCells();
302 }
303 
setScore(int value)304 void SchedulerJob::setScore(int value)
305 {
306     score = value;
307     updateJobCells();
308 }
309 
setCulminationOffset(const int16_t & value)310 void SchedulerJob::setCulminationOffset(const int16_t &value)
311 {
312     culminationOffset = value;
313 }
314 
setSequenceCount(const int count)315 void SchedulerJob::setSequenceCount(const int count)
316 {
317     sequenceCount = count;
318     updateJobCells();
319 }
320 
setNameCell(QTableWidgetItem * value)321 void SchedulerJob::setNameCell(QTableWidgetItem *value)
322 {
323     nameCell = value;
324 }
325 
setCompletedCount(const int count)326 void SchedulerJob::setCompletedCount(const int count)
327 {
328     completedCount = count;
329     updateJobCells();
330 }
331 
setStatusCell(QTableWidgetItem * value)332 void SchedulerJob::setStatusCell(QTableWidgetItem *value)
333 {
334     statusCell = value;
335     if (nullptr != statusCell)
336         statusCell->setToolTip(i18n("Current status of job '%1', managed by the Scheduler.\n"
337                                     "If invalid, the Scheduler was not able to find a proper observation time for the target.\n"
338                                     "If aborted, the Scheduler missed the scheduled time or encountered transitory issues and will reschedule the job.\n"
339                                     "If complete, the Scheduler verified that all sequence captures requested were stored, including repeats.",
340                                     name));
341 }
342 
setAltitudeCell(QTableWidgetItem * value)343 void SchedulerJob::setAltitudeCell(QTableWidgetItem *value)
344 {
345     altitudeCell = value;
346     if (nullptr != altitudeCell)
347         altitudeCell->setToolTip(i18n("Current altitude of the target of job '%1'.\n"
348                                       "A rising target is indicated with an arrow going up.\n"
349                                       "A setting target is indicated with an arrow going down.",
350                                       name));
351 }
352 
setStartupCell(QTableWidgetItem * value)353 void SchedulerJob::setStartupCell(QTableWidgetItem *value)
354 {
355     startupCell = value;
356     if (nullptr != startupCell)
357         startupCell->setToolTip(i18n("Startup time for job '%1', as estimated by the Scheduler.\n"
358                                      "The altitude at startup, if available, is displayed too.\n"
359                                      "Fixed time from user or culmination time is marked with a chronometer symbol. ",
360                                      name));
361 }
362 
setCompletionCell(QTableWidgetItem * value)363 void SchedulerJob::setCompletionCell(QTableWidgetItem *value)
364 {
365     completionCell = value;
366     if (nullptr != completionCell)
367         completionCell->setToolTip(i18n("Completion time for job '%1', as estimated by the Scheduler.\n"
368                                         "You may specify a fixed time to limit duration of looping jobs. "
369                                         "A warning symbol indicates the altitude at completion may cause the job to abort before completion.\n",
370                                         name));
371 }
372 
setCaptureCountCell(QTableWidgetItem * value)373 void SchedulerJob::setCaptureCountCell(QTableWidgetItem *value)
374 {
375     captureCountCell = value;
376     if (nullptr != captureCountCell)
377         captureCountCell->setToolTip(i18n("Count of captures stored for job '%1', based on its sequence job.\n"
378                                           "This is a summary, additional specific frame types may be required to complete the job.",
379                                           name));
380 }
381 
setScoreCell(QTableWidgetItem * value)382 void SchedulerJob::setScoreCell(QTableWidgetItem *value)
383 {
384     scoreCell = value;
385     if (nullptr != scoreCell)
386         scoreCell->setToolTip(i18n("Current score for job '%1', from its altitude, moon separation and sky darkness.\n"
387                                    "Negative if adequate altitude is not achieved yet or if there is no proper observation time today.\n"
388                                    "The Scheduler will refresh scores when picking a new candidate job.",
389                                    name));
390 }
391 
setLeadTimeCell(QTableWidgetItem * value)392 void SchedulerJob::setLeadTimeCell(QTableWidgetItem *value)
393 {
394     leadTimeCell = value;
395     if (nullptr != leadTimeCell)
396         leadTimeCell->setToolTip(i18n("Time interval from the job which precedes job '%1'.\n"
397                                       "Adjust the Lead Time in Ekos options to increase that duration and leave time for jobs to complete.\n"
398                                       "Rearrange jobs to minimize that duration and optimize your imaging time.",
399                                       name));
400 }
401 
setDateTimeDisplayFormat(const QString & value)402 void SchedulerJob::setDateTimeDisplayFormat(const QString &value)
403 {
404     dateTimeDisplayFormat = value;
405     updateJobCells();
406 }
407 
setStage(const JOBStage & value)408 void SchedulerJob::setStage(const JOBStage &value)
409 {
410     stage = value;
411     updateJobCells();
412 }
413 
setStageCell(QTableWidgetItem * cell)414 void SchedulerJob::setStageCell(QTableWidgetItem *cell)
415 {
416     stageCell = cell;
417     // FIXME: Add a tool tip if cell is used
418 }
419 
setStageLabel(QLabel * label)420 void SchedulerJob::setStageLabel(QLabel *label)
421 {
422     stageLabel = label;
423 }
424 
setFileStartupCondition(const StartupCondition & value)425 void SchedulerJob::setFileStartupCondition(const StartupCondition &value)
426 {
427     fileStartupCondition = value;
428 }
429 
setFileStartupTime(const QDateTime & value)430 void SchedulerJob::setFileStartupTime(const QDateTime &value)
431 {
432     fileStartupTime = value;
433 }
434 
setEstimatedTime(const int64_t & value)435 void SchedulerJob::setEstimatedTime(const int64_t &value)
436 {
437     /* Estimated time is generally the difference between startup and completion times:
438      * - It is fixed when startup and completion times are fixed, that is, we disregard the argument
439      * - Else mostly it pushes completion time from startup time
440      *
441      * However it cannot advance startup time when completion time is fixed because of the way jobs are scheduled.
442      * This situation requires a warning in the user interface when there is not enough time for the job to process.
443      */
444 
445     /* If startup and completion times are fixed, estimated time cannot change - disregard the argument */
446     if (START_ASAP != fileStartupCondition && FINISH_AT == completionCondition)
447     {
448         estimatedTime = startupTime.secsTo(completionTime);
449     }
450     /* If completion time isn't fixed, estimated time adjusts completion time */
451     else if (FINISH_AT != completionCondition && FINISH_LOOP != completionCondition)
452     {
453         estimatedTime = value;
454         completionTime = startupTime.addSecs(value);
455         altitudeAtCompletion = findAltitude(targetCoords, completionTime, &isSettingAtCompletion);
456     }
457     /* Else estimated time is simply stored as is - covers FINISH_LOOP from setCompletionTime */
458     else estimatedTime = value;
459 
460     updateJobCells();
461 }
462 
setInSequenceFocus(bool value)463 void SchedulerJob::setInSequenceFocus(bool value)
464 {
465     inSequenceFocus = value;
466 }
467 
setPriority(const uint8_t & value)468 void SchedulerJob::setPriority(const uint8_t &value)
469 {
470     priority = value;
471 }
472 
setEnforceTwilight(bool value)473 void SchedulerJob::setEnforceTwilight(bool value)
474 {
475     enforceTwilight = value;
476     calculateDawnDusk(startupTime, nextDawn, nextDusk);
477 }
478 
setEnforceArtificialHorizon(bool value)479 void SchedulerJob::setEnforceArtificialHorizon(bool value)
480 {
481     enforceArtificialHorizon = value;
482 }
483 
setEstimatedTimeCell(QTableWidgetItem * value)484 void SchedulerJob::setEstimatedTimeCell(QTableWidgetItem *value)
485 {
486     estimatedTimeCell = value;
487     if (estimatedTimeCell)
488         estimatedTimeCell->setToolTip(i18n("Duration job '%1' will take to complete when started, as estimated by the Scheduler.\n"
489                                            "Depends on the actions to be run, and the sequence job to be processed.",
490                                            name));
491 }
492 
setLightFramesRequired(bool value)493 void SchedulerJob::setLightFramesRequired(bool value)
494 {
495     lightFramesRequired = value;
496 }
497 
setRepeatsRequired(const uint16_t & value)498 void SchedulerJob::setRepeatsRequired(const uint16_t &value)
499 {
500     repeatsRequired = value;
501 
502     // Update completion condition to be compatible
503     if (1 < repeatsRequired)
504     {
505         if (FINISH_REPEAT != completionCondition)
506             setCompletionCondition(FINISH_REPEAT);
507     }
508     else if (0 < repeatsRequired)
509     {
510         if (FINISH_SEQUENCE != completionCondition)
511             setCompletionCondition(FINISH_SEQUENCE);
512     }
513     else
514     {
515         if (FINISH_LOOP != completionCondition)
516             setCompletionCondition(FINISH_LOOP);
517     }
518 
519     updateJobCells();
520 }
521 
setRepeatsRemaining(const uint16_t & value)522 void SchedulerJob::setRepeatsRemaining(const uint16_t &value)
523 {
524     repeatsRemaining = value;
525     updateJobCells();
526 }
527 
setCapturedFramesMap(const CapturedFramesMap & value)528 void SchedulerJob::setCapturedFramesMap(const CapturedFramesMap &value)
529 {
530     capturedFramesMap = value;
531 }
532 
setTargetCoords(const dms & ra,const dms & dec,double djd)533 void SchedulerJob::setTargetCoords(const dms &ra, const dms &dec, double djd)
534 {
535     targetCoords.setRA0(ra);
536     targetCoords.setDec0(dec);
537 
538     targetCoords.apparentCoord(static_cast<long double>(J2000), djd);
539 }
540 
setRotation(double value)541 void SchedulerJob::setRotation(double value)
542 {
543     rotation = value;
544 }
545 
updateJobCells()546 void SchedulerJob::updateJobCells()
547 {
548     if (nullptr != nameCell)
549     {
550         nameCell->setText(name);
551         if (nullptr != nameCell)
552             nameCell->tableWidget()->resizeColumnToContents(nameCell->column());
553     }
554 
555     if (nullptr != nameLabel)
556     {
557         nameLabel->setText(name + QString(":"));
558     }
559 
560     if (nullptr != statusCell)
561     {
562         static QMap<JOBStatus, QString> stateStrings;
563         static QString stateStringUnknown;
564         if (stateStrings.isEmpty())
565         {
566             stateStrings[JOB_IDLE] = i18n("Idle");
567             stateStrings[JOB_EVALUATION] = i18n("Evaluating");
568             stateStrings[JOB_SCHEDULED] = i18n("Scheduled");
569             stateStrings[JOB_BUSY] = i18n("Running");
570             stateStrings[JOB_INVALID] = i18n("Invalid");
571             stateStrings[JOB_COMPLETE] = i18n("Complete");
572             stateStrings[JOB_ABORTED] = i18n("Aborted");
573             stateStrings[JOB_ERROR] =  i18n("Error");
574             stateStringUnknown = i18n("Unknown");
575         }
576         statusCell->setText(stateStrings.value(state, stateStringUnknown));
577 
578         if (nullptr != statusCell->tableWidget())
579             statusCell->tableWidget()->resizeColumnToContents(statusCell->column());
580     }
581 
582     if (nullptr != stageCell || nullptr != stageLabel)
583     {
584         /* Translated string cache - overkill, probably, and doesn't warn about missing enums like switch/case should ; also, not thread-safe */
585         /* FIXME: this should work with a static initializer in C++11, but QT versions are touchy on this, and perhaps i18n can't be used? */
586         static QMap<JOBStage, QString> stageStrings;
587         static QString stageStringUnknown;
588         if (stageStrings.isEmpty())
589         {
590             stageStrings[STAGE_IDLE] = i18n("Idle");
591             stageStrings[STAGE_SLEWING] = i18n("Slewing");
592             stageStrings[STAGE_SLEW_COMPLETE] = i18n("Slew complete");
593             stageStrings[STAGE_FOCUSING] =
594                 stageStrings[STAGE_POSTALIGN_FOCUSING] = i18n("Focusing");
595             stageStrings[STAGE_FOCUS_COMPLETE] =
596                 stageStrings[STAGE_POSTALIGN_FOCUSING_COMPLETE ] = i18n("Focus complete");
597             stageStrings[STAGE_ALIGNING] = i18n("Aligning");
598             stageStrings[STAGE_ALIGN_COMPLETE] = i18n("Align complete");
599             stageStrings[STAGE_RESLEWING] = i18n("Repositioning");
600             stageStrings[STAGE_RESLEWING_COMPLETE] = i18n("Repositioning complete");
601             /*stageStrings[STAGE_CALIBRATING] = i18n("Calibrating");*/
602             stageStrings[STAGE_GUIDING] = i18n("Guiding");
603             stageStrings[STAGE_GUIDING_COMPLETE] = i18n("Guiding complete");
604             stageStrings[STAGE_CAPTURING] = i18n("Capturing");
605             stageStringUnknown = i18n("Unknown");
606         }
607         if (nullptr != stageCell)
608         {
609             stageCell->setText(stageStrings.value(stage, stageStringUnknown));
610             if (nullptr != stageCell->tableWidget())
611                 stageCell->tableWidget()->resizeColumnToContents(stageCell->column());
612         }
613         if (nullptr != stageLabel)
614         {
615             stageLabel->setText(QString("%1: %2").arg(name, stageStrings.value(stage, stageStringUnknown)));
616         }
617     }
618 
619     if (nullptr != startupCell)
620     {
621         /* Display startup time if it is valid */
622         if (startupTime.isValid())
623         {
624             startupCell->setText(QString("%1%2%L3° %4")
625                                  .arg(altitudeAtStartup < minAltitude ? QString(QChar(0x26A0)) : "")
626                                  .arg(QChar(isSettingAtStartup ? 0x2193 : 0x2191))
627                                  .arg(altitudeAtStartup, 0, 'f', 1)
628                                  .arg(startupTime.toString(dateTimeDisplayFormat)));
629 
630             switch (fileStartupCondition)
631             {
632                 /* If the original condition is START_AT/START_CULMINATION, startup time is fixed */
633                 case START_AT:
634                 case START_CULMINATION:
635                     startupCell->setIcon(QIcon::fromTheme("chronometer"));
636                     break;
637 
638                 /* If the original condition is START_ASAP, startup time is informational */
639                 case START_ASAP:
640                     startupCell->setIcon(QIcon());
641                     break;
642 
643                 default:
644                     break;
645             }
646         }
647         /* Else do not display any startup time */
648         else
649         {
650             startupCell->setText("-");
651             startupCell->setIcon(QIcon());
652         }
653 
654         if (nullptr != startupCell->tableWidget())
655             startupCell->tableWidget()->resizeColumnToContents(startupCell->column());
656     }
657 
658     if (nullptr != altitudeCell)
659     {
660         // FIXME: Cache altitude calculations
661         bool is_setting = false;
662         double const alt = findAltitude(targetCoords, QDateTime(), &is_setting);
663 
664         altitudeCell->setText(QString("%1%L2°")
665                               .arg(QChar(is_setting ? 0x2193 : 0x2191))
666                               .arg(alt, 0, 'f', 1));
667 
668         if (nullptr != altitudeCell->tableWidget())
669             altitudeCell->tableWidget()->resizeColumnToContents(altitudeCell->column());
670     }
671 
672     if (nullptr != completionCell)
673     {
674         /* Display completion time if it is valid and job is not looping */
675         if (FINISH_LOOP != completionCondition && completionTime.isValid())
676         {
677             completionCell->setText(QString("%1%2%L3° %4")
678                                     .arg(altitudeAtCompletion < minAltitude ? QString(QChar(0x26A0)) : "")
679                                     .arg(QChar(isSettingAtCompletion ? 0x2193 : 0x2191))
680                                     .arg(altitudeAtCompletion, 0, 'f', 1)
681                                     .arg(completionTime.toString(dateTimeDisplayFormat)));
682 
683             switch (completionCondition)
684             {
685                 case FINISH_AT:
686                     completionCell->setIcon(QIcon::fromTheme("chronometer"));
687                     break;
688 
689                 case FINISH_SEQUENCE:
690                 case FINISH_REPEAT:
691                 default:
692                     completionCell->setIcon(QIcon());
693                     break;
694             }
695         }
696         /* Else do not display any completion time */
697         else
698         {
699             completionCell->setText("-");
700             completionCell->setIcon(QIcon());
701         }
702 
703         if (nullptr != completionCell->tableWidget())
704             completionCell->tableWidget()->resizeColumnToContents(completionCell->column());
705     }
706 
707     if (nullptr != estimatedTimeCell)
708     {
709         if (0 < estimatedTime)
710             /* Seconds to ms - this doesn't follow dateTimeDisplayFormat, which renders YMD too */
711             estimatedTimeCell->setText(QTime::fromMSecsSinceStartOfDay(estimatedTime * 1000).toString("HH:mm:ss"));
712 #if 0
713         else if(0 == estimatedTime)
714             /* FIXME: this special case could be merged with the previous, kept for future to indicate actual duration */
715             estimatedTimeCell->setText("00:00:00");
716 #endif
717         else
718             /* Invalid marker */
719             estimatedTimeCell->setText("-");
720 
721         /* Warn the end-user if estimated time doesn't fit in the startup/completion interval */
722         if (estimatedTime < startupTime.secsTo(completionTime))
723             estimatedTimeCell->setIcon(QIcon::fromTheme("document-find"));
724         else
725             estimatedTimeCell->setIcon(QIcon());
726 
727         if (nullptr != estimatedTimeCell->tableWidget())
728             estimatedTimeCell->tableWidget()->resizeColumnToContents(estimatedTimeCell->column());
729     }
730 
731     if (nullptr != captureCountCell)
732     {
733         switch (completionCondition)
734         {
735             case FINISH_AT:
736             // FIXME: Attempt to calculate the number of frames until end - requires detailed imaging time
737 
738             case FINISH_LOOP:
739                 // If looping, display the count of completed frames
740                 captureCountCell->setText(QString("%L1/-").arg(completedCount));
741                 break;
742 
743             case FINISH_SEQUENCE:
744             case FINISH_REPEAT:
745             default:
746                 // If repeating, display the count of completed frames to the count of requested frames
747                 captureCountCell->setText(QString("%L1/%L2").arg(completedCount).arg(sequenceCount));
748                 break;
749         }
750 
751         if (nullptr != captureCountCell->tableWidget())
752             captureCountCell->tableWidget()->resizeColumnToContents(captureCountCell->column());
753     }
754 
755     if (nullptr != scoreCell)
756     {
757         if (0 <= score)
758             scoreCell->setText(QString("%L1").arg(score));
759         else
760             /* FIXME: negative scores are just weird for the end-user */
761             scoreCell->setText("<0");
762 
763         if (nullptr != scoreCell->tableWidget())
764             scoreCell->tableWidget()->resizeColumnToContents(scoreCell->column());
765     }
766 
767     if (nullptr != leadTimeCell)
768     {
769         // Display lead time, plus a warning if lead time is more than twice the lead time of the Ekos options
770         switch (state)
771         {
772             case JOB_INVALID:
773             case JOB_ERROR:
774             case JOB_COMPLETE:
775                 leadTimeCell->setText("-");
776                 break;
777 
778             default:
779                 leadTimeCell->setText(QString("%1%2")
780                                       .arg(Options::leadTime() * 60 * 2 < leadTime ? QString(QChar(0x26A0)) : "")
781                                       .arg(QTime::fromMSecsSinceStartOfDay(leadTime * 1000).toString("HH:mm:ss")));
782                 break;
783         }
784 
785         if (nullptr != leadTimeCell->tableWidget())
786             leadTimeCell->tableWidget()->resizeColumnToContents(leadTimeCell->column());
787     }
788 }
789 
reset()790 void SchedulerJob::reset()
791 {
792     state = JOB_IDLE;
793     stage = STAGE_IDLE;
794     estimatedTime = -1;
795     leadTime = 0;
796     startupCondition = fileStartupCondition;
797     startupTime = fileStartupCondition == START_AT ? fileStartupTime : QDateTime();
798 
799     /* Refresh dawn and dusk for startup date */
800     calculateDawnDusk(startupTime, nextDawn, nextDusk);
801 
802     /* No change to culmination offset */
803     repeatsRemaining = repeatsRequired;
804     updateJobCells();
805 }
806 
decreasingScoreOrder(SchedulerJob const * job1,SchedulerJob const * job2)807 bool SchedulerJob::decreasingScoreOrder(SchedulerJob const *job1, SchedulerJob const *job2)
808 {
809     return job1->getScore() > job2->getScore();
810 }
811 
increasingPriorityOrder(SchedulerJob const * job1,SchedulerJob const * job2)812 bool SchedulerJob::increasingPriorityOrder(SchedulerJob const *job1, SchedulerJob const *job2)
813 {
814     return job1->getPriority() < job2->getPriority();
815 }
816 
decreasingAltitudeOrder(SchedulerJob const * job1,SchedulerJob const * job2,QDateTime const & when)817 bool SchedulerJob::decreasingAltitudeOrder(SchedulerJob const *job1, SchedulerJob const *job2, QDateTime const &when)
818 {
819     bool A_is_setting = job1->isSettingAtStartup;
820     double const altA = when.isValid() ?
821                         findAltitude(job1->getTargetCoords(), when, &A_is_setting) :
822                         job1->altitudeAtStartup;
823 
824     bool B_is_setting = job2->isSettingAtStartup;
825     double const altB = when.isValid() ?
826                         findAltitude(job2->getTargetCoords(), when, &B_is_setting) :
827                         job2->altitudeAtStartup;
828 
829     // Sort with the setting target first
830     if (A_is_setting && !B_is_setting)
831         return true;
832     else if (!A_is_setting && B_is_setting)
833         return false;
834 
835     // If both targets rise or set, sort by decreasing altitude, considering a setting target is prioritary
836     return (A_is_setting && B_is_setting) ? altA < altB : altB < altA;
837 }
838 
increasingStartupTimeOrder(SchedulerJob const * job1,SchedulerJob const * job2)839 bool SchedulerJob::increasingStartupTimeOrder(SchedulerJob const *job1, SchedulerJob const *job2)
840 {
841     return job1->getStartupTime() < job2->getStartupTime();
842 }
843 
844 // This uses both the user-setting minAltitude, as well as any artificial horizon
845 // constraints the user might have setup.
getMinAltitudeConstraint(double azimuth) const846 double SchedulerJob::getMinAltitudeConstraint(double azimuth) const
847 {
848     double constraint = getMinAltitude();
849     if (getHorizon() != nullptr && enforceArtificialHorizon)
850         constraint = std::max(constraint, getHorizon()->altitudeConstraint(azimuth));
851     return constraint;
852 }
853 
getAltitudeScore(QDateTime const & when,double * altPtr) const854 int16_t SchedulerJob::getAltitudeScore(QDateTime const &when, double *altPtr) const
855 {
856     // FIXME: block calculating target coordinates at a particular time is duplicated in several places
857 
858     // Retrieve the argument date/time, or fall back to current time - don't use QDateTime's timezone!
859     KStarsDateTime ltWhen(when.isValid() ?
860                           Qt::UTC == when.timeSpec() ? getGeo()->UTtoLT(KStarsDateTime(when)) : when :
861                           getLocalTime());
862 
863     // Create a sky object with the target catalog coordinates
864     SkyPoint const target = getTargetCoords();
865     SkyObject o;
866     o.setRA0(target.ra0());
867     o.setDec0(target.dec0());
868 
869     // Update RA/DEC of the target for the current fraction of the day
870     KSNumbers numbers(ltWhen.djd());
871     o.updateCoordsNow(&numbers);
872 
873     // Compute local sidereal time for the current fraction of the day, calculate altitude
874     CachingDms const LST = getGeo()->GSTtoLST(getGeo()->LTtoUT(ltWhen).gst());
875     o.EquatorialToHorizontal(&LST, getGeo()->lat());
876     double const altitude = o.alt().Degrees();
877     double const azimuth = o.az().Degrees();
878     if (altPtr != nullptr)
879         *altPtr = altitude;
880 
881     double const SETTING_ALTITUDE_CUTOFF = Options::settingAltitudeCutoff();
882     int16_t score = BAD_SCORE - 1;
883 
884     const double minAlt = getMinAltitudeConstraint(azimuth);
885 
886     // If altitude is negative, bad score
887     // FIXME: some locations may allow negative altitudes
888     if (altitude < 0)
889     {
890         score = BAD_SCORE;
891     }
892     else if (hasAltitudeConstraint())
893     {
894         // If under altitude constraint, bad score
895         if (altitude < minAlt)
896             score = BAD_SCORE;
897         // Else if setting and under altitude cutoff, job would end soon after starting, bad score
898         // FIXME: half bad score when under altitude cutoff risk getting positive again
899         else
900         {
901             double offset = LST.Hours() - o.ra().Hours();
902             if (24.0 <= offset)
903                 offset -= 24.0;
904             else if (offset < 0.0)
905                 offset += 24.0;
906             if (0.0 <= offset && offset < 12.0)
907                 if (altitude - SETTING_ALTITUDE_CUTOFF < minAlt)
908                     score = BAD_SCORE / 2;
909         }
910     }
911     // If not constrained but below minimum hard altitude, set score to 10% of altitude value
912     else if (altitude < MIN_ALTITUDE)
913     {
914         score = static_cast <int16_t> (altitude / 10.0);
915     }
916 
917     // Else default score calculation without altitude constraint
918     if (score < BAD_SCORE)
919         score = static_cast <int16_t> ((1.5 * pow(1.06, altitude)) - (MIN_ALTITUDE / 10.0));
920 
921     //qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' target altitude is %3 degrees at %2 (score %4).")
922     //    .arg(getName())
923     //    .arg(when.toString(getDateTimeDisplayFormat()))
924     //    .arg(currentAlt, 0, 'f', minAltitude->decimals())
925     //    .arg(QString::asprintf("%+d", score));
926 
927     return score;
928 }
929 
getMoonSeparationScore(QDateTime const & when) const930 int16_t SchedulerJob::getMoonSeparationScore(QDateTime const &when) const
931 {
932     if (moon == nullptr) return 100;
933 
934     // FIXME: block calculating target coordinates at a particular time is duplicated in several places
935 
936     // Retrieve the argument date/time, or fall back to current time - don't use QDateTime's timezone!
937     KStarsDateTime ltWhen(when.isValid() ?
938                           Qt::UTC == when.timeSpec() ? getGeo()->UTtoLT(KStarsDateTime(when)) : when :
939                           getLocalTime());
940 
941     // Create a sky object with the target catalog coordinates
942     SkyPoint const target = getTargetCoords();
943     SkyObject o;
944     o.setRA0(target.ra0());
945     o.setDec0(target.dec0());
946 
947     // Update RA/DEC of the target for the current fraction of the day
948     KSNumbers numbers(ltWhen.djd());
949     o.updateCoordsNow(&numbers);
950 
951     // Update moon
952     //ut = getGeo()->LTtoUT(ltWhen);
953     //KSNumbers ksnum(ut.djd()); // BUG: possibly LT.djd() != UT.djd() because of translation
954     //LST = getGeo()->GSTtoLST(ut.gst());
955     CachingDms LST = getGeo()->GSTtoLST(getGeo()->LTtoUT(ltWhen).gst());
956     moon->updateCoords(&numbers, true, getGeo()->lat(), &LST, true);
957 
958     double const moonAltitude = moon->alt().Degrees();
959 
960     // Lunar illumination %
961     double const illum = moon->illum() * 100.0;
962 
963     // Moon/Sky separation p
964     double const separation = moon->angularDistanceTo(&o).Degrees();
965 
966     // Zenith distance of the moon
967     double const zMoon = (90 - moonAltitude);
968     // Zenith distance of target
969     double const zTarget = (90 - o.alt().Degrees());
970 
971     int16_t score = 0;
972 
973     // If target = Moon, or no illuminiation, or moon below horizon, return static score.
974     if (zMoon == zTarget || illum == 0 || zMoon >= 90)
975         score = 100;
976     else
977     {
978         // JM: Some magic voodoo formula I came up with!
979         double moonEffect = (pow(separation, 1.7) * pow(zMoon, 0.5)) / (pow(zTarget, 1.1) * pow(illum, 0.5));
980 
981         // Limit to 0 to 100 range.
982         moonEffect = KSUtils::clamp(moonEffect, 0.0, 100.0);
983 
984         if (getMinMoonSeparation() > 0)
985         {
986             if (separation < getMinMoonSeparation())
987                 score = BAD_SCORE * 5;
988             else
989                 score = moonEffect;
990         }
991         else
992             score = moonEffect;
993     }
994 
995     // Limit to 0 to 20
996     score /= 5.0;
997 
998     //qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' target is %L3 degrees from Moon (score %2).")
999     //    .arg(getName())
1000     //    .arg(separation, 0, 'f', 3)
1001     //    .arg(QString::asprintf("%+d", score));
1002 
1003     return score;
1004 }
1005 
1006 
getCurrentMoonSeparation() const1007 double SchedulerJob::getCurrentMoonSeparation() const
1008 {
1009     if (moon == nullptr) return 180.0;
1010 
1011     // FIXME: block calculating target coordinates at a particular time is duplicated in several places
1012 
1013     // Retrieve the argument date/time, or fall back to current time - don't use QDateTime's timezone!
1014     KStarsDateTime ltWhen(getLocalTime());
1015 
1016     // Create a sky object with the target catalog coordinates
1017     SkyPoint const target = getTargetCoords();
1018     SkyObject o;
1019     o.setRA0(target.ra0());
1020     o.setDec0(target.dec0());
1021 
1022     // Update RA/DEC of the target for the current fraction of the day
1023     KSNumbers numbers(ltWhen.djd());
1024     o.updateCoordsNow(&numbers);
1025 
1026     // Update moon
1027     //ut = getGeo()->LTtoUT(ltWhen);
1028     //KSNumbers ksnum(ut.djd()); // BUG: possibly LT.djd() != UT.djd() because of translation
1029     //LST = getGeo()->GSTtoLST(ut.gst());
1030     CachingDms LST = getGeo()->GSTtoLST(getGeo()->LTtoUT(ltWhen).gst());
1031     moon->updateCoords(&numbers, true, getGeo()->lat(), &LST, true);
1032 
1033     // Moon/Sky separation p
1034     return moon->angularDistanceTo(&o).Degrees();
1035 }
1036 
calculateAltitudeTime(QDateTime const & when) const1037 QDateTime SchedulerJob::calculateAltitudeTime(QDateTime const &when) const
1038 {
1039     // FIXME: block calculating target coordinates at a particular time is duplicated in several places
1040 
1041     // Retrieve the argument date/time, or fall back to current time - don't use QDateTime's timezone!
1042     KStarsDateTime ltWhen(when.isValid() ?
1043                           Qt::UTC == when.timeSpec() ? getGeo()->UTtoLT(KStarsDateTime(when)) : when :
1044                           getLocalTime());
1045 
1046     // Create a sky object with the target catalog coordinates
1047     SkyPoint const target = getTargetCoords();
1048     SkyObject o;
1049     o.setRA0(target.ra0());
1050     o.setDec0(target.dec0());
1051 
1052     // Calculate the UT at the argument time
1053     KStarsDateTime const ut = getGeo()->LTtoUT(ltWhen);
1054 
1055     double const SETTING_ALTITUDE_CUTOFF = Options::settingAltitudeCutoff();
1056 
1057     // Within the next 24 hours, search when the job target matches the altitude and moon constraints
1058     for (unsigned int minute = 0; minute < 24 * 60; minute++)
1059     {
1060         KStarsDateTime const ltOffset(ltWhen.addSecs(minute * 60));
1061 
1062         // Update RA/DEC of the target for the current fraction of the day
1063         KSNumbers numbers(ltOffset.djd());
1064         o.updateCoordsNow(&numbers);
1065 
1066         // Compute local sidereal time for the current fraction of the day, calculate altitude
1067         CachingDms const LST = getGeo()->GSTtoLST(getGeo()->LTtoUT(ltOffset).gst());
1068         o.EquatorialToHorizontal(&LST, getGeo()->lat());
1069         double const altitude = o.alt().Degrees();
1070         double const azimuth = o.az().Degrees();
1071         double const minAlt = getMinAltitudeConstraint(azimuth);
1072 
1073         if (minAlt <= altitude)
1074         {
1075             // Don't test proximity to dawn in this situation, we only cater for altitude here
1076 
1077             // Continue searching if Moon separation is not good enough
1078             if (0 < getMinMoonSeparation() && getMoonSeparationScore(ltOffset) < 0)
1079                 continue;
1080 
1081             // Continue searching if target is setting and under the cutoff
1082             double offset = LST.Hours() - o.ra().Hours();
1083             if (24.0 <= offset)
1084                 offset -= 24.0;
1085             else if (offset < 0.0)
1086                 offset += 24.0;
1087             if (0.0 <= offset && offset < 12.0)
1088                 if (altitude - SETTING_ALTITUDE_CUTOFF < minAlt)
1089                     continue;
1090 
1091             return ltOffset;
1092         }
1093     }
1094 
1095     return QDateTime();
1096 }
1097 
calculateCulmination(QDateTime const & when) const1098 QDateTime SchedulerJob::calculateCulmination(QDateTime const &when) const
1099 {
1100     // FIXME: culmination calculation is a min altitude requirement, should be an interval altitude requirement
1101 
1102     // FIXME: block calculating target coordinates at a particular time is duplicated in calculateCulmination
1103 
1104     // Retrieve the argument date/time, or fall back to current time - don't use QDateTime's timezone!
1105     KStarsDateTime ltWhen(when.isValid() ?
1106                           Qt::UTC == when.timeSpec() ? getGeo()->UTtoLT(KStarsDateTime(when)) : when :
1107                           getLocalTime());
1108 
1109     // Create a sky object with the target catalog coordinates
1110     SkyPoint const target = getTargetCoords();
1111     SkyObject o;
1112     o.setRA0(target.ra0());
1113     o.setDec0(target.dec0());
1114 
1115     // Update RA/DEC for the argument date/time
1116     KSNumbers numbers(ltWhen.djd());
1117     o.updateCoordsNow(&numbers);
1118 
1119     // Calculate transit date/time at the argument date - transitTime requires UT and returns LocalTime
1120     KStarsDateTime transitDateTime(ltWhen.date(), o.transitTime(getGeo()->LTtoUT(ltWhen), getGeo()), Qt::LocalTime);
1121 
1122     // Shift transit date/time by the argument offset
1123     KStarsDateTime observationDateTime = transitDateTime.addSecs(getCulminationOffset() * 60);
1124 
1125     // Relax observation time, culmination calculation is stable at minute only
1126     KStarsDateTime relaxedDateTime = observationDateTime.addSecs(Options::leadTime() * 60);
1127 
1128     // Verify resulting observation time is under lead time vs. argument time
1129     // If sooner, delay by 8 hours to get to the next transit - perhaps in a third call
1130     if (relaxedDateTime < ltWhen)
1131     {
1132         qCDebug(KSTARS_EKOS_SCHEDULER) << QString("Job '%1' startup %2 is posterior to transit %3, shifting by 8 hours.")
1133                                        .arg(getName())
1134                                        .arg(ltWhen.toString(getDateTimeDisplayFormat()))
1135                                        .arg(relaxedDateTime.toString(getDateTimeDisplayFormat()));
1136 
1137         return calculateCulmination(when.addSecs(8 * 60 * 60));
1138     }
1139 
1140     // Guarantees - culmination calculation is stable at minute level, so relax by lead time
1141     Q_ASSERT_X(observationDateTime.isValid(), __FUNCTION__, "Observation time for target culmination is valid.");
1142     Q_ASSERT_X(ltWhen <= relaxedDateTime, __FUNCTION__,
1143                "Observation time for target culmination is at or after than argument time");
1144 
1145     // Return consolidated culmination time
1146     return Qt::UTC == observationDateTime.timeSpec() ? getGeo()->UTtoLT(observationDateTime) : observationDateTime;
1147 }
1148 
findAltitude(const SkyPoint & target,const QDateTime & when,bool * is_setting,bool debug)1149 double SchedulerJob::findAltitude(const SkyPoint &target, const QDateTime &when, bool * is_setting, bool debug)
1150 {
1151     // FIXME: block calculating target coordinates at a particular time is duplicated in several places
1152 
1153     // Retrieve the argument date/time, or fall back to current time - don't use QDateTime's timezone!
1154     KStarsDateTime ltWhen(when.isValid() ?
1155                           Qt::UTC == when.timeSpec() ? getGeo()->UTtoLT(KStarsDateTime(when)) : when :
1156                           getLocalTime());
1157 
1158     // Create a sky object with the target catalog coordinates
1159     SkyObject o;
1160     o.setRA0(target.ra0());
1161     o.setDec0(target.dec0());
1162 
1163     // Update RA/DEC of the target for the current fraction of the day
1164     KSNumbers numbers(ltWhen.djd());
1165     o.updateCoordsNow(&numbers);
1166 
1167     // Calculate alt/az coordinates using KStars instance's geolocation
1168     CachingDms const LST = getGeo()->GSTtoLST(getGeo()->LTtoUT(ltWhen).gst());
1169     o.EquatorialToHorizontal(&LST, getGeo()->lat());
1170 
1171     // Hours are reduced to [0,24[, meridian being at 0
1172     double offset = LST.Hours() - o.ra().Hours();
1173     if (24.0 <= offset)
1174         offset -= 24.0;
1175     else if (offset < 0.0)
1176         offset += 24.0;
1177     bool const passed_meridian = 0.0 <= offset && offset < 12.0;
1178 
1179     if (debug)
1180         qCDebug(KSTARS_EKOS_SCHEDULER) << QString("When:%9 LST:%8 RA:%1 RA0:%2 DEC:%3 DEC0:%4 alt:%5 setting:%6 HA:%7")
1181                                        .arg(o.ra().toHMSString())
1182                                        .arg(o.ra0().toHMSString())
1183                                        .arg(o.dec().toHMSString())
1184                                        .arg(o.dec0().toHMSString())
1185                                        .arg(o.alt().Degrees())
1186                                        .arg(passed_meridian ? "yes" : "no")
1187                                        .arg(o.ra().Hours())
1188                                        .arg(LST.toHMSString())
1189                                        .arg(ltWhen.toString("HH:mm:ss"));
1190 
1191     if (is_setting)
1192         *is_setting = passed_meridian;
1193 
1194     return o.alt().Degrees();
1195 }
1196 
calculateDawnDusk(QDateTime const & when,QDateTime & nextDawn,QDateTime & nextDusk)1197 void SchedulerJob::calculateDawnDusk(QDateTime const &when, QDateTime &nextDawn, QDateTime &nextDusk)
1198 {
1199     QDateTime startup = when;
1200 
1201     if (!startup.isValid())
1202         startup = getLocalTime();
1203 
1204     // Our local midnight - the KStarsDateTime date+time constructor is safe for local times
1205     KStarsDateTime midnight(startup.date(), QTime(0, 0), Qt::LocalTime);
1206 
1207     QDateTime dawn = startup, dusk = startup;
1208 
1209     // Loop dawn and dusk calculation until the events found are the next events
1210     for ( ; dawn <= startup || dusk <= startup ; midnight = midnight.addDays(1))
1211     {
1212         // KSAlmanac computes the closest dawn and dusk events from the local sidereal time corresponding to the midnight argument
1213         KSAlmanac const ksal(midnight, getGeo());
1214 
1215         // If dawn is in the past compared to this observation, fetch the next dawn
1216         if (dawn <= startup)
1217             dawn = getGeo()->UTtoLT(ksal.getDate().addSecs((ksal.getDawnAstronomicalTwilight() * 24.0 + Options::dawnOffset()) *
1218                                     3600.0));
1219 
1220         // If dusk is in the past compared to this observation, fetch the next dusk
1221         if (dusk <= startup)
1222             dusk = getGeo()->UTtoLT(ksal.getDate().addSecs((ksal.getDuskAstronomicalTwilight() * 24.0 + Options::duskOffset()) *
1223                                     3600.0));
1224     }
1225 
1226     // Now we have the next events:
1227     // - if dawn comes first, observation runs during the night
1228     // - if dusk comes first, observation runs during the day
1229     nextDawn = dawn;
1230     nextDusk = dusk;
1231 }
1232 
runsDuringAstronomicalNightTime() const1233 bool SchedulerJob::runsDuringAstronomicalNightTime() const
1234 {
1235     // Calculate the next astronomical dawn time, adjusted with the Ekos pre-dawn offset
1236     QDateTime const earlyDawn = nextDawn.addSecs(-60.0 * abs(Options::preDawnTime()));
1237 
1238     // Dawn and dusk are ordered as the immediate next events following the observation time
1239     // Thus if dawn comes first, the job startup time occurs during the dusk/dawn interval.
1240     return nextDawn < nextDusk && startupTime <= earlyDawn;
1241 }
1242